diff --git a/.coveragerc b/.coveragerc index d2192ca2e46eba..f06e9356d21e39 100644 --- a/.coveragerc +++ b/.coveragerc @@ -4,6 +4,8 @@ source = homeassistant omit = homeassistant/__main__.py homeassistant/scripts/*.py + homeassistant/util/async.py + homeassistant/monkey_patch.py homeassistant/helpers/typing.py homeassistant/helpers/signal.py @@ -59,6 +61,11 @@ omit = homeassistant/components/coinbase.py homeassistant/components/sensor/coinbase.py + homeassistant/components/cast/* + homeassistant/components/*/cast.py + + homeassistant/components/cloudflare.py + homeassistant/components/comfoconnect.py homeassistant/components/*/comfoconnect.py @@ -95,7 +102,7 @@ omit = homeassistant/components/*/envisalink.py homeassistant/components/fritzbox.py - homeassistant/components/*/fritzbox.py + homeassistant/components/switch/fritzbox.py homeassistant/components/eufy.py homeassistant/components/*/eufy.py @@ -121,13 +128,16 @@ omit = homeassistant/components/homematicip_cloud.py homeassistant/components/*/homematicip_cloud.py + homeassistant/components/hydrawise.py + homeassistant/components/*/hydrawise.py + homeassistant/components/ihc/* homeassistant/components/*/ihc.py homeassistant/components/insteon_local.py homeassistant/components/*/insteon_local.py - homeassistant/components/insteon_plm.py + homeassistant/components/insteon_plm/* homeassistant/components/*/insteon_plm.py homeassistant/components/ios.py @@ -151,6 +161,9 @@ omit = homeassistant/components/knx.py homeassistant/components/*/knx.py + homeassistant/components/konnected.py + homeassistant/components/*/konnected.py + homeassistant/components/lametric.py homeassistant/components/*/lametric.py @@ -181,24 +194,30 @@ omit = homeassistant/components/mychevy.py homeassistant/components/*/mychevy.py - homeassistant/components/mysensors.py + homeassistant/components/mysensors/* homeassistant/components/*/mysensors.py homeassistant/components/neato.py homeassistant/components/*/neato.py - homeassistant/components/nest.py + homeassistant/components/nest/__init__.py homeassistant/components/*/nest.py homeassistant/components/netatmo.py homeassistant/components/*/netatmo.py + homeassistant/components/netgear_lte.py + homeassistant/components/*/netgear_lte.py + homeassistant/components/octoprint.py homeassistant/components/*/octoprint.py homeassistant/components/opencv.py homeassistant/components/*/opencv.py + homeassistant/components/openuv.py + homeassistant/components/*/openuv.py + homeassistant/components/pilight.py homeassistant/components/*/pilight.py @@ -211,7 +230,7 @@ omit = homeassistant/components/raincloud.py homeassistant/components/*/raincloud.py - homeassistant/components/rainmachine.py + homeassistant/components/rainmachine/* homeassistant/components/*/rainmachine.py homeassistant/components/raspihats.py @@ -226,18 +245,27 @@ omit = homeassistant/components/rpi_pfio.py homeassistant/components/*/rpi_pfio.py + homeassistant/components/sabnzbd.py + homeassistant/components/*/sabnzbd.py + homeassistant/components/satel_integra.py homeassistant/components/*/satel_integra.py homeassistant/components/scsgate.py homeassistant/components/*/scsgate.py + homeassistant/components/sisyphus.py + homeassistant/components/*/sisyphus.py + homeassistant/components/skybell.py homeassistant/components/*/skybell.py homeassistant/components/smappee.py homeassistant/components/*/smappee.py + homeassistant/components/sonos/__init__.py + homeassistant/components/*/sonos.py + homeassistant/components/tado.py homeassistant/components/*/tado.py @@ -300,6 +328,9 @@ omit = homeassistant/components/wink/* homeassistant/components/*/wink.py + homeassistant/components/wirelesstag.py + homeassistant/components/*/wirelesstag.py + homeassistant/components/xiaomi_aqara.py homeassistant/components/*/xiaomi_aqara.py @@ -318,6 +349,12 @@ omit = homeassistant/components/zoneminder.py homeassistant/components/*/zoneminder.py + homeassistant/components/tuya.py + homeassistant/components/*/tuya.py + + homeassistant/components/spider.py + homeassistant/components/*/spider.py + homeassistant/components/alarm_control_panel/alarmdotcom.py homeassistant/components/alarm_control_panel/canary.py homeassistant/components/alarm_control_panel/concord232.py @@ -337,11 +374,13 @@ omit = homeassistant/components/binary_sensor/ping.py homeassistant/components/binary_sensor/rest.py homeassistant/components/binary_sensor/tapsaff.py + homeassistant/components/binary_sensor/uptimerobot.py homeassistant/components/browser.py homeassistant/components/calendar/caldav.py homeassistant/components/calendar/todoist.py homeassistant/components/camera/bloomsky.py homeassistant/components/camera/canary.py + homeassistant/components/camera/familyhub.py homeassistant/components/camera/ffmpeg.py homeassistant/components/camera/foscam.py homeassistant/components/camera/mjpeg.py @@ -351,6 +390,7 @@ omit = homeassistant/components/camera/rpi_camera.py homeassistant/components/camera/synology.py homeassistant/components/camera/xeoma.py + homeassistant/components/camera/xiaomi.py homeassistant/components/camera/yi.py homeassistant/components/climate/econet.py homeassistant/components/climate/ephember.py @@ -366,6 +406,9 @@ omit = homeassistant/components/climate/sensibo.py homeassistant/components/climate/touchline.py homeassistant/components/climate/venstar.py + homeassistant/components/climate/zhong_hong.py + homeassistant/components/cover/aladdin_connect.py + homeassistant/components/cover/brunt.py homeassistant/components/cover/garadget.py homeassistant/components/cover/gogogate2.py homeassistant/components/cover/homematic.py @@ -373,6 +416,7 @@ omit = homeassistant/components/cover/myq.py homeassistant/components/cover/opengarage.py homeassistant/components/cover/rpi_gpio.py + homeassistant/components/cover/ryobi_gdo.py homeassistant/components/cover/scsgate.py homeassistant/components/device_tracker/actiontec.py homeassistant/components/device_tracker/aruba.py @@ -384,6 +428,7 @@ omit = homeassistant/components/device_tracker/bt_home_hub_5.py homeassistant/components/device_tracker/cisco_ios.py homeassistant/components/device_tracker/ddwrt.py + homeassistant/components/device_tracker/freebox.py homeassistant/components/device_tracker/fritz.py homeassistant/components/device_tracker/google_maps.py homeassistant/components/device_tracker/gpslogger.py @@ -398,6 +443,7 @@ omit = homeassistant/components/device_tracker/netgear.py homeassistant/components/device_tracker/nmap_tracker.py homeassistant/components/device_tracker/ping.py + homeassistant/components/device_tracker/ritassist.py homeassistant/components/device_tracker/sky_hub.py homeassistant/components/device_tracker/snmp.py homeassistant/components/device_tracker/swisscom.py @@ -412,7 +458,6 @@ omit = homeassistant/components/emoncms_history.py homeassistant/components/emulated_hue/upnp.py homeassistant/components/fan/mqtt.py - homeassistant/components/feedreader.py homeassistant/components/folder_watcher.py homeassistant/components/foursquare.py homeassistant/components/goalfeed.py @@ -428,6 +473,7 @@ omit = homeassistant/components/light/decora_wifi.py homeassistant/components/light/decora.py homeassistant/components/light/flux_led.py + homeassistant/components/light/futurenow.py homeassistant/components/light/greenwave.py homeassistant/components/light/hue.py homeassistant/components/light/hyperion.py @@ -435,6 +481,7 @@ omit = homeassistant/components/light/lifx_legacy.py homeassistant/components/light/lifx.py homeassistant/components/light/limitlessled.py + homeassistant/components/light/lw12wifi.py homeassistant/components/light/mystrom.py homeassistant/components/light/nanoleaf_aurora.py homeassistant/components/light/osramlightify.py @@ -449,6 +496,7 @@ omit = homeassistant/components/light/yeelightsunflower.py homeassistant/components/light/zengge.py homeassistant/components/lirc.py + homeassistant/components/lock/kiwi.py homeassistant/components/lock/lockitron.py homeassistant/components/lock/nello.py homeassistant/components/lock/nuki.py @@ -459,19 +507,21 @@ omit = homeassistant/components/media_player/aquostv.py homeassistant/components/media_player/bluesound.py homeassistant/components/media_player/braviatv.py - homeassistant/components/media_player/cast.py homeassistant/components/media_player/channels.py homeassistant/components/media_player/clementine.py homeassistant/components/media_player/cmus.py homeassistant/components/media_player/denon.py homeassistant/components/media_player/denonavr.py homeassistant/components/media_player/directv.py + homeassistant/components/media_player/dlna_dmr.py homeassistant/components/media_player/dunehd.py homeassistant/components/media_player/emby.py + homeassistant/components/media_player/epson.py homeassistant/components/media_player/firetv.py homeassistant/components/media_player/frontier_silicon.py homeassistant/components/media_player/gpmdp.py homeassistant/components/media_player/gstreamer.py + homeassistant/components/media_player/horizon.py homeassistant/components/media_player/itunes.py homeassistant/components/media_player/kodi.py homeassistant/components/media_player/lg_netcast.py @@ -487,13 +537,13 @@ omit = homeassistant/components/media_player/pandora.py homeassistant/components/media_player/philips_js.py homeassistant/components/media_player/pioneer.py + homeassistant/components/media_player/pjlink.py homeassistant/components/media_player/plex.py homeassistant/components/media_player/roku.py homeassistant/components/media_player/russound_rio.py homeassistant/components/media_player/russound_rnet.py homeassistant/components/media_player/snapcast.py homeassistant/components/media_player/songpal.py - homeassistant/components/media_player/sonos.py homeassistant/components/media_player/spotify.py homeassistant/components/media_player/squeezebox.py homeassistant/components/media_player/ue_smart_radio.py @@ -510,9 +560,10 @@ omit = homeassistant/components/notify/aws_sqs.py homeassistant/components/notify/ciscospark.py homeassistant/components/notify/clickatell.py - homeassistant/components/notify/clicksend_tts.py homeassistant/components/notify/clicksend.py + homeassistant/components/notify/clicksend_tts.py homeassistant/components/notify/discord.py + homeassistant/components/notify/flock.py homeassistant/components/notify/free_mobile.py homeassistant/components/notify/gntp.py homeassistant/components/notify/group.py @@ -525,7 +576,6 @@ omit = homeassistant/components/notify/message_bird.py homeassistant/components/notify/mycroft.py homeassistant/components/notify/nfandroidtv.py - homeassistant/components/notify/nma.py homeassistant/components/notify/prowl.py homeassistant/components/notify/pushbullet.py homeassistant/components/notify/pushetta.py @@ -582,16 +632,19 @@ omit = homeassistant/components/sensor/domain_expiry.py homeassistant/components/sensor/dte_energy_bridge.py homeassistant/components/sensor/dublin_bus_transport.py + homeassistant/components/sensor/duke_energy.py homeassistant/components/sensor/dwd_weather_warnings.py homeassistant/components/sensor/ebox.py homeassistant/components/sensor/eddystone_temperature.py homeassistant/components/sensor/eliqonline.py homeassistant/components/sensor/emoncms.py + homeassistant/components/sensor/enphase_envoy.py homeassistant/components/sensor/envirophat.py homeassistant/components/sensor/etherscan.py homeassistant/components/sensor/fastdotcom.py homeassistant/components/sensor/fedex.py homeassistant/components/sensor/filesize.py + homeassistant/components/sensor/fints.py homeassistant/components/sensor/fitbit.py homeassistant/components/sensor/fixer.py homeassistant/components/sensor/folder.py @@ -611,6 +664,7 @@ omit = homeassistant/components/sensor/imap_email_content.py homeassistant/components/sensor/imap.py homeassistant/components/sensor/influxdb.py + homeassistant/components/sensor/iperf3.py homeassistant/components/sensor/irish_rail_transport.py homeassistant/components/sensor/kwb.py homeassistant/components/sensor/lacrosse.py @@ -619,6 +673,7 @@ omit = homeassistant/components/sensor/loopenergy.py homeassistant/components/sensor/luftdaten.py homeassistant/components/sensor/lyft.py + homeassistant/components/sensor/magicseaweed.py homeassistant/components/sensor/metoffice.py homeassistant/components/sensor/miflora.py homeassistant/components/sensor/mitemp_bt.py @@ -629,6 +684,7 @@ omit = homeassistant/components/sensor/nederlandse_spoorwegen.py homeassistant/components/sensor/netdata.py homeassistant/components/sensor/neurio_energy.py + homeassistant/components/sensor/nsw_fuel_station.py homeassistant/components/sensor/nut.py homeassistant/components/sensor/nzbget.py homeassistant/components/sensor/ohmconnect.py @@ -650,7 +706,6 @@ omit = homeassistant/components/sensor/radarr.py homeassistant/components/sensor/rainbird.py homeassistant/components/sensor/ripple.py - homeassistant/components/sensor/sabnzbd.py homeassistant/components/sensor/scrape.py homeassistant/components/sensor/sense.py homeassistant/components/sensor/sensehat.py @@ -734,6 +789,7 @@ omit = homeassistant/components/tts/picotts.py homeassistant/components/vacuum/mqtt.py homeassistant/components/vacuum/roomba.py + homeassistant/components/watson_iot.py homeassistant/components/weather/bom.py homeassistant/components/weather/buienradar.py homeassistant/components/weather/darksky.py diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 9a8e6812cf31a5..c2f65f9a8bed8a 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -20,7 +20,7 @@ If user exposed functionality or configuration variables are added/changed: If the code communicates with devices, web services, or third-party tools: - [ ] New dependencies have been added to the `REQUIREMENTS` variable ([example][ex-requir]). - [ ] New dependencies are only imported inside functions that use them ([example][ex-import]). - - [ ] New dependencies have been added to `requirements_all.txt` by running `script/gen_requirements_all.py`. + - [ ] New or updated dependencies have been added to `requirements_all.txt` by running `script/gen_requirements_all.py`. - [ ] New files were added to `.coveragerc`. If the code does not interact with devices: diff --git a/.gitignore b/.gitignore index bf49a1b61c1fea..c2b0d964a6225a 100644 --- a/.gitignore +++ b/.gitignore @@ -107,3 +107,6 @@ desktop.ini # Secrets .lokalise_token + +# monkeytype +monkeytype.sqlite3 diff --git a/.isort.cfg b/.isort.cfg new file mode 100644 index 00000000000000..79a65508287448 --- /dev/null +++ b/.isort.cfg @@ -0,0 +1,2 @@ +[settings] +multi_line_output=4 diff --git a/.travis.yml b/.travis.yml index bf2d05bb185137..920e8b57047740 100644 --- a/.travis.yml +++ b/.travis.yml @@ -10,17 +10,24 @@ matrix: env: TOXENV=lint - python: "3.5.3" env: TOXENV=pylint - # - python: "3.5" - # env: TOXENV=typing - python: "3.5.3" - env: TOXENV=py35 + env: TOXENV=typing + - python: "3.5.3" + env: TOXENV=cov + after_success: coveralls - python: "3.6" env: TOXENV=py36 - # - python: "3.6-dev" - # env: TOXENV=py36 - # allow_failures: - # - python: "3.5" - # env: TOXENV=typing + - python: "3.7" + env: TOXENV=py37 + dist: xenial + - python: "3.8-dev" + env: TOXENV=py38 + dist: xenial + if: branch = dev AND type = push + allow_failures: + - python: "3.8-dev" + env: TOXENV=py38 + dist: xenial cache: directories: @@ -39,4 +46,3 @@ deploy: on: branch: dev condition: $TOXENV = lint -after_success: coveralls diff --git a/CODEOWNERS b/CODEOWNERS index 33966d1badbd8a..53f577d02ebe80 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -70,6 +70,7 @@ homeassistant/components/sensor/filter.py @dgomes homeassistant/components/sensor/gearbest.py @HerrHofrat homeassistant/components/sensor/irish_rail_transport.py @ttroy50 homeassistant/components/sensor/miflora.py @danielhiversen @ChristianKuehnel +homeassistant/components/sensor/nsw_fuel_station.py @nickw444 homeassistant/components/sensor/pollen.py @bachya homeassistant/components/sensor/qnap.py @colinodell homeassistant/components/sensor/sma.py @kellerza @@ -78,7 +79,6 @@ homeassistant/components/sensor/sytadin.py @gautric homeassistant/components/sensor/tibber.py @danielhiversen homeassistant/components/sensor/upnp.py @dgomes homeassistant/components/sensor/waqi.py @andrey-git -homeassistant/components/switch/rainmachine.py @bachya homeassistant/components/switch/tplink.py @rytilahti homeassistant/components/vacuum/roomba.py @pschmitt homeassistant/components/xiaomi_aqara.py @danielhiversen @syssi @@ -94,10 +94,16 @@ homeassistant/components/*/hive.py @Rendili @KJonline homeassistant/components/homekit/* @cdce8p homeassistant/components/knx.py @Julius2342 homeassistant/components/*/knx.py @Julius2342 +homeassistant/components/konnected.py @heythisisnate +homeassistant/components/*/konnected.py @heythisisnate homeassistant/components/matrix.py @tinloaf homeassistant/components/*/matrix.py @tinloaf +homeassistant/components/openuv.py @bachya +homeassistant/components/*/openuv.py @bachya homeassistant/components/qwikswitch.py @kellerza homeassistant/components/*/qwikswitch.py @kellerza +homeassistant/components/rainmachine/* @bachya +homeassistant/components/*/rainmachine.py @bachya homeassistant/components/*/rfxtrx.py @danielhiversen homeassistant/components/tahoma.py @philklei homeassistant/components/*/tahoma.py @philklei diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9ad922d70456c2..86e212bb11d696 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -1,6 +1,6 @@ # Contributing to Home Assistant -Everybody is invited and welcome to contribute to Home Assistant. There is a lot to do...if you are not a developer perhaps you would like to help with the documentation on [home-assistant.io](https://home-assistant.io/)? If you are a developer and have devices in your home which aren't working with Home Assistant yet, why not spent a couple of hours and help to integrate them? +Everybody is invited and welcome to contribute to Home Assistant. There is a lot to do...if you are not a developer perhaps you would like to help with the documentation on [home-assistant.io](https://home-assistant.io/)? If you are a developer and have devices in your home which aren't working with Home Assistant yet, why not spend a couple of hours and help to integrate them? The process is straight-forward. diff --git a/Dockerfile b/Dockerfile index 5081b4ba721992..c84e6162d04d93 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,8 +10,8 @@ LABEL maintainer="Paulus Schoutsen " #ENV INSTALL_OPENALPR no #ENV INSTALL_FFMPEG no #ENV INSTALL_LIBCEC no -#ENV INSTALL_PHANTOMJS no #ENV INSTALL_SSOCR no +#ENV INSTALL_IPERF3 no VOLUME /config diff --git a/README.rst b/README.rst index 7f0d41b00eab98..6cf19d89c3c7c9 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ -Home Assistant |Build Status| |Coverage Status| |Chat Status| -============================================================= +Home Assistant |Build Status| |Coverage Status| |Chat Status| |Reviewed by Hound| +================================================================================= Home Assistant is a home automation platform running on Python 3. It is able to track and control all devices at home and offer a platform for automating control. @@ -33,6 +33,8 @@ of a component, check the `Home Assistant help section None: """Attempt to use uvloop.""" import asyncio @@ -240,7 +241,7 @@ def cmdline() -> List[str]: def setup_and_run_hass(config_dir: str, - args: argparse.Namespace) -> Optional[int]: + args: argparse.Namespace) -> int: """Set up HASS and run.""" from homeassistant import bootstrap @@ -259,7 +260,7 @@ def setup_and_run_hass(config_dir: str, config = { 'frontend': {}, 'demo': {} - } + } # type: Dict[str, Any] hass = bootstrap.from_config_dict( config, config_dir=config_dir, verbose=args.verbose, skip_pip=args.skip_pip, log_rotate_days=args.log_rotate_days, @@ -273,17 +274,17 @@ def setup_and_run_hass(config_dir: str, log_no_color=args.log_no_color) if hass is None: - return None + return -1 if args.open_ui: # Imported here to avoid importing asyncio before monkey patch from homeassistant.util.async_ import run_callback_threadsafe - def open_browser(event): - """Open the webinterface in a browser.""" - if hass.config.api is not None: + def open_browser(_: Any) -> None: + """Open the web interface in a browser.""" + if hass.config.api is not None: # type: ignore import webbrowser - webbrowser.open(hass.config.api.base_url) + webbrowser.open(hass.config.api.base_url) # type: ignore run_callback_threadsafe( hass.loop, diff --git a/homeassistant/auth.py b/homeassistant/auth.py deleted file mode 100644 index 55de9309954e18..00000000000000 --- a/homeassistant/auth.py +++ /dev/null @@ -1,505 +0,0 @@ -"""Provide an authentication layer for Home Assistant.""" -import asyncio -import binascii -from collections import OrderedDict -from datetime import datetime, timedelta -import os -import importlib -import logging -import uuid - -import attr -import voluptuous as vol -from voluptuous.humanize import humanize_error - -from homeassistant import data_entry_flow, requirements -from homeassistant.core import callback -from homeassistant.const import CONF_TYPE, CONF_NAME, CONF_ID -from homeassistant.exceptions import HomeAssistantError -from homeassistant.util.decorator import Registry -from homeassistant.util import dt as dt_util - - -_LOGGER = logging.getLogger(__name__) - - -AUTH_PROVIDERS = Registry() - -AUTH_PROVIDER_SCHEMA = vol.Schema({ - vol.Required(CONF_TYPE): str, - vol.Optional(CONF_NAME): str, - # Specify ID if you have two auth providers for same type. - vol.Optional(CONF_ID): str, -}, extra=vol.ALLOW_EXTRA) - -ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30) -DATA_REQS = 'auth_reqs_processed' - - -class AuthError(HomeAssistantError): - """Generic authentication error.""" - - -class InvalidUser(AuthError): - """Raised when an invalid user has been specified.""" - - -class InvalidPassword(AuthError): - """Raised when an invalid password has been supplied.""" - - -class UnknownError(AuthError): - """When an unknown error occurs.""" - - -def generate_secret(entropy=32): - """Generate a secret. - - Backport of secrets.token_hex from Python 3.6 - - Event loop friendly. - """ - return binascii.hexlify(os.urandom(entropy)).decode('ascii') - - -class AuthProvider: - """Provider of user authentication.""" - - DEFAULT_TITLE = 'Unnamed auth provider' - - initialized = False - - def __init__(self, store, config): - """Initialize an auth provider.""" - self.store = store - self.config = config - - @property - def id(self): # pylint: disable=invalid-name - """Return id of the auth provider. - - Optional, can be None. - """ - return self.config.get(CONF_ID) - - @property - def type(self): - """Return type of the provider.""" - return self.config[CONF_TYPE] - - @property - def name(self): - """Return the name of the auth provider.""" - return self.config.get(CONF_NAME, self.DEFAULT_TITLE) - - async def async_credentials(self): - """Return all credentials of this provider.""" - return await self.store.credentials_for_provider(self.type, self.id) - - @callback - def async_create_credentials(self, data): - """Create credentials.""" - return Credentials( - auth_provider_type=self.type, - auth_provider_id=self.id, - data=data, - ) - - # Implement by extending class - - async def async_initialize(self): - """Initialize the auth provider. - - Optional. - """ - - async def async_credential_flow(self): - """Return the data flow for logging in with auth provider.""" - raise NotImplementedError - - async def async_get_or_create_credentials(self, flow_result): - """Get credentials based on the flow result.""" - raise NotImplementedError - - async def async_user_meta_for_credentials(self, credentials): - """Return extra user metadata for credentials. - - Will be used to populate info when creating a new user. - """ - return {} - - -@attr.s(slots=True) -class User: - """A user.""" - - id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) - is_owner = attr.ib(type=bool, default=False) - is_active = attr.ib(type=bool, default=False) - name = attr.ib(type=str, default=None) - # For persisting and see if saved? - # store = attr.ib(type=AuthStore, default=None) - - # List of credentials of a user. - credentials = attr.ib(type=list, default=attr.Factory(list)) - - # Tokens associated with a user. - refresh_tokens = attr.ib(type=dict, default=attr.Factory(dict)) - - def as_dict(self): - """Convert user object to a dictionary.""" - return { - 'id': self.id, - 'is_owner': self.is_owner, - 'is_active': self.is_active, - 'name': self.name, - } - - -@attr.s(slots=True) -class RefreshToken: - """RefreshToken for a user to grant new access tokens.""" - - user = attr.ib(type=User) - client_id = attr.ib(type=str) - id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) - created_at = attr.ib(type=datetime, default=attr.Factory(dt_util.utcnow)) - access_token_expiration = attr.ib(type=timedelta, - default=ACCESS_TOKEN_EXPIRATION) - token = attr.ib(type=str, - default=attr.Factory(lambda: generate_secret(64))) - access_tokens = attr.ib(type=list, default=attr.Factory(list)) - - -@attr.s(slots=True) -class AccessToken: - """Access token to access the API. - - These will only ever be stored in memory and not be persisted. - """ - - refresh_token = attr.ib(type=RefreshToken) - created_at = attr.ib(type=datetime, default=attr.Factory(dt_util.utcnow)) - token = attr.ib(type=str, - default=attr.Factory(generate_secret)) - - @property - def expires(self): - """Return datetime when this token expires.""" - return self.created_at + self.refresh_token.access_token_expiration - - -@attr.s(slots=True) -class Credentials: - """Credentials for a user on an auth provider.""" - - auth_provider_type = attr.ib(type=str) - auth_provider_id = attr.ib(type=str) - - # Allow the auth provider to store data to represent their auth. - data = attr.ib(type=dict) - - id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) - is_new = attr.ib(type=bool, default=True) - - -@attr.s(slots=True) -class Client: - """Client that interacts with Home Assistant on behalf of a user.""" - - name = attr.ib(type=str) - id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) - secret = attr.ib(type=str, default=attr.Factory(generate_secret)) - - -async def load_auth_provider_module(hass, provider): - """Load an auth provider.""" - try: - module = importlib.import_module( - 'homeassistant.auth_providers.{}'.format(provider)) - except ImportError: - _LOGGER.warning('Unable to find auth provider %s', provider) - return None - - if hass.config.skip_pip or not hasattr(module, 'REQUIREMENTS'): - return module - - processed = hass.data.get(DATA_REQS) - - if processed is None: - processed = hass.data[DATA_REQS] = set() - elif provider in processed: - return module - - req_success = await requirements.async_process_requirements( - hass, 'auth provider {}'.format(provider), module.REQUIREMENTS) - - if not req_success: - return None - - return module - - -async def auth_manager_from_config(hass, provider_configs): - """Initialize an auth manager from config.""" - store = AuthStore(hass) - if provider_configs: - providers = await asyncio.gather( - *[_auth_provider_from_config(hass, store, config) - for config in provider_configs]) - else: - providers = [] - # So returned auth providers are in same order as config - provider_hash = OrderedDict() - for provider in providers: - if provider is None: - continue - - key = (provider.type, provider.id) - - if key in provider_hash: - _LOGGER.error( - 'Found duplicate provider: %s. Please add unique IDs if you ' - 'want to have the same provider twice.', key) - continue - - provider_hash[key] = provider - manager = AuthManager(hass, store, provider_hash) - return manager - - -async def _auth_provider_from_config(hass, store, config): - """Initialize an auth provider from a config.""" - provider_name = config[CONF_TYPE] - module = await load_auth_provider_module(hass, provider_name) - - if module is None: - return None - - try: - config = module.CONFIG_SCHEMA(config) - except vol.Invalid as err: - _LOGGER.error('Invalid configuration for auth provider %s: %s', - provider_name, humanize_error(config, err)) - return None - - return AUTH_PROVIDERS[provider_name](store, config) - - -class AuthManager: - """Manage the authentication for Home Assistant.""" - - def __init__(self, hass, store, providers): - """Initialize the auth manager.""" - self._store = store - self._providers = providers - self.login_flow = data_entry_flow.FlowManager( - hass, self._async_create_login_flow, - self._async_finish_login_flow) - self.access_tokens = {} - - @property - def async_auth_providers(self): - """Return a list of available auth providers.""" - return self._providers.values() - - async def async_get_user(self, user_id): - """Retrieve a user.""" - return await self._store.async_get_user(user_id) - - async def async_get_or_create_user(self, credentials): - """Get or create a user.""" - return await self._store.async_get_or_create_user( - credentials, self._async_get_auth_provider(credentials)) - - async def async_link_user(self, user, credentials): - """Link credentials to an existing user.""" - await self._store.async_link_user(user, credentials) - - async def async_remove_user(self, user): - """Remove a user.""" - await self._store.async_remove_user(user) - - async def async_create_refresh_token(self, user, client_id): - """Create a new refresh token for a user.""" - return await self._store.async_create_refresh_token(user, client_id) - - async def async_get_refresh_token(self, token): - """Get refresh token by token.""" - return await self._store.async_get_refresh_token(token) - - @callback - def async_create_access_token(self, refresh_token): - """Create a new access token.""" - access_token = AccessToken(refresh_token) - self.access_tokens[access_token.token] = access_token - return access_token - - @callback - def async_get_access_token(self, token): - """Get an access token.""" - return self.access_tokens.get(token) - - async def async_create_client(self, name): - """Create a new client.""" - return await self._store.async_create_client(name) - - async def async_get_client(self, client_id): - """Get a client.""" - return await self._store.async_get_client(client_id) - - async def _async_create_login_flow(self, handler, *, source, data): - """Create a login flow.""" - auth_provider = self._providers[handler] - - if not auth_provider.initialized: - auth_provider.initialized = True - await auth_provider.async_initialize() - - return await auth_provider.async_credential_flow() - - async def _async_finish_login_flow(self, result): - """Result of a credential login flow.""" - auth_provider = self._providers[result['handler']] - return await auth_provider.async_get_or_create_credentials( - result['data']) - - @callback - def _async_get_auth_provider(self, credentials): - """Helper to get auth provider from a set of credentials.""" - auth_provider_key = (credentials.auth_provider_type, - credentials.auth_provider_id) - return self._providers[auth_provider_key] - - -class AuthStore: - """Stores authentication info. - - Any mutation to an object should happen inside the auth store. - - The auth store is lazy. It won't load the data from disk until a method is - called that needs it. - """ - - def __init__(self, hass): - """Initialize the auth store.""" - self.hass = hass - self.users = None - self.clients = None - self._load_lock = asyncio.Lock(loop=hass.loop) - - async def credentials_for_provider(self, provider_type, provider_id): - """Return credentials for specific auth provider type and id.""" - if self.users is None: - await self.async_load() - - return [ - credentials - for user in self.users.values() - for credentials in user.credentials - if (credentials.auth_provider_type == provider_type and - credentials.auth_provider_id == provider_id) - ] - - async def async_get_user(self, user_id): - """Retrieve a user.""" - if self.users is None: - await self.async_load() - - return self.users.get(user_id) - - async def async_get_or_create_user(self, credentials, auth_provider): - """Get or create a new user for given credentials. - - If link_user is passed in, the credentials will be linked to the passed - in user if the credentials are new. - """ - if self.users is None: - await self.async_load() - - # New credentials, store in user - if credentials.is_new: - info = await auth_provider.async_user_meta_for_credentials( - credentials) - # Make owner and activate user if it's the first user. - if self.users: - is_owner = False - is_active = False - else: - is_owner = True - is_active = True - - new_user = User( - is_owner=is_owner, - is_active=is_active, - name=info.get('name'), - ) - self.users[new_user.id] = new_user - await self.async_link_user(new_user, credentials) - return new_user - - for user in self.users.values(): - for creds in user.credentials: - if (creds.auth_provider_type == credentials.auth_provider_type - and creds.auth_provider_id == - credentials.auth_provider_id): - return user - - raise ValueError('We got credentials with ID but found no user') - - async def async_link_user(self, user, credentials): - """Add credentials to an existing user.""" - user.credentials.append(credentials) - await self.async_save() - credentials.is_new = False - - async def async_remove_user(self, user): - """Remove a user.""" - self.users.pop(user.id) - await self.async_save() - - async def async_create_refresh_token(self, user, client_id): - """Create a new token for a user.""" - refresh_token = RefreshToken(user, client_id) - user.refresh_tokens[refresh_token.token] = refresh_token - await self.async_save() - return refresh_token - - async def async_get_refresh_token(self, token): - """Get refresh token by token.""" - if self.users is None: - await self.async_load() - - for user in self.users.values(): - refresh_token = user.refresh_tokens.get(token) - if refresh_token is not None: - return refresh_token - - return None - - async def async_create_client(self, name): - """Create a new client.""" - if self.clients is None: - await self.async_load() - - client = Client(name) - self.clients[client.id] = client - await self.async_save() - return client - - async def async_get_client(self, client_id): - """Get a client.""" - if self.clients is None: - await self.async_load() - - return self.clients.get(client_id) - - async def async_load(self): - """Load the users.""" - async with self._load_lock: - self.users = {} - self.clients = {} - - async def async_save(self): - """Save users.""" - pass diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py new file mode 100644 index 00000000000000..148f97702e3a2c --- /dev/null +++ b/homeassistant/auth/__init__.py @@ -0,0 +1,268 @@ +"""Provide an authentication layer for Home Assistant.""" +import asyncio +import logging +from collections import OrderedDict +from typing import List, Awaitable + +import jwt + +from homeassistant import data_entry_flow +from homeassistant.core import callback, HomeAssistant +from homeassistant.util import dt as dt_util + +from . import auth_store +from .providers import auth_provider_from_config + +_LOGGER = logging.getLogger(__name__) + + +async def auth_manager_from_config( + hass: HomeAssistant, + provider_configs: List[dict]) -> Awaitable['AuthManager']: + """Initialize an auth manager from config.""" + store = auth_store.AuthStore(hass) + if provider_configs: + providers = await asyncio.gather( + *[auth_provider_from_config(hass, store, config) + for config in provider_configs]) + else: + providers = [] + # So returned auth providers are in same order as config + provider_hash = OrderedDict() + for provider in providers: + if provider is None: + continue + + key = (provider.type, provider.id) + + if key in provider_hash: + _LOGGER.error( + 'Found duplicate provider: %s. Please add unique IDs if you ' + 'want to have the same provider twice.', key) + continue + + provider_hash[key] = provider + manager = AuthManager(hass, store, provider_hash) + return manager + + +class AuthManager: + """Manage the authentication for Home Assistant.""" + + def __init__(self, hass, store, providers): + """Initialize the auth manager.""" + self._store = store + self._providers = providers + self.login_flow = data_entry_flow.FlowManager( + hass, self._async_create_login_flow, + self._async_finish_login_flow) + + @property + def active(self): + """Return if any auth providers are registered.""" + return bool(self._providers) + + @property + def support_legacy(self): + """ + Return if legacy_api_password auth providers are registered. + + Should be removed when we removed legacy_api_password auth providers. + """ + for provider_type, _ in self._providers: + if provider_type == 'legacy_api_password': + return True + return False + + @property + def auth_providers(self): + """Return a list of available auth providers.""" + return list(self._providers.values()) + + async def async_get_users(self): + """Retrieve all users.""" + return await self._store.async_get_users() + + async def async_get_user(self, user_id): + """Retrieve a user.""" + return await self._store.async_get_user(user_id) + + async def async_create_system_user(self, name): + """Create a system user.""" + return await self._store.async_create_user( + name=name, + system_generated=True, + is_active=True, + ) + + async def async_create_user(self, name): + """Create a user.""" + kwargs = { + 'name': name, + 'is_active': True, + } + + if await self._user_should_be_owner(): + kwargs['is_owner'] = True + + return await self._store.async_create_user(**kwargs) + + async def async_get_or_create_user(self, credentials): + """Get or create a user.""" + if not credentials.is_new: + for user in await self._store.async_get_users(): + for creds in user.credentials: + if creds.id == credentials.id: + return user + + raise ValueError('Unable to find the user.') + + auth_provider = self._async_get_auth_provider(credentials) + + if auth_provider is None: + raise RuntimeError('Credential with unknown provider encountered') + + info = await auth_provider.async_user_meta_for_credentials( + credentials) + + return await self._store.async_create_user( + credentials=credentials, + name=info.get('name'), + is_active=info.get('is_active', False) + ) + + async def async_link_user(self, user, credentials): + """Link credentials to an existing user.""" + await self._store.async_link_user(user, credentials) + + async def async_remove_user(self, user): + """Remove a user.""" + tasks = [ + self.async_remove_credentials(credentials) + for credentials in user.credentials + ] + + if tasks: + await asyncio.wait(tasks) + + await self._store.async_remove_user(user) + + async def async_activate_user(self, user): + """Activate a user.""" + await self._store.async_activate_user(user) + + async def async_deactivate_user(self, user): + """Deactivate a user.""" + if user.is_owner: + raise ValueError('Unable to deactive the owner') + await self._store.async_deactivate_user(user) + + async def async_remove_credentials(self, credentials): + """Remove credentials.""" + provider = self._async_get_auth_provider(credentials) + + if (provider is not None and + hasattr(provider, 'async_will_remove_credentials')): + await provider.async_will_remove_credentials(credentials) + + await self._store.async_remove_credentials(credentials) + + async def async_create_refresh_token(self, user, client_id=None): + """Create a new refresh token for a user.""" + if not user.is_active: + raise ValueError('User is not active') + + if user.system_generated and client_id is not None: + raise ValueError( + 'System generated users cannot have refresh tokens connected ' + 'to a client.') + + if not user.system_generated and client_id is None: + raise ValueError('Client is required to generate a refresh token.') + + return await self._store.async_create_refresh_token(user, client_id) + + async def async_get_refresh_token(self, token_id): + """Get refresh token by id.""" + return await self._store.async_get_refresh_token(token_id) + + async def async_get_refresh_token_by_token(self, token): + """Get refresh token by token.""" + return await self._store.async_get_refresh_token_by_token(token) + + @callback + def async_create_access_token(self, refresh_token): + """Create a new access token.""" + # pylint: disable=no-self-use + return jwt.encode({ + 'iss': refresh_token.id, + 'iat': dt_util.utcnow(), + 'exp': dt_util.utcnow() + refresh_token.access_token_expiration, + }, refresh_token.jwt_key, algorithm='HS256').decode() + + async def async_validate_access_token(self, token): + """Return if an access token is valid.""" + try: + unverif_claims = jwt.decode(token, verify=False) + except jwt.InvalidTokenError: + return None + + refresh_token = await self.async_get_refresh_token( + unverif_claims.get('iss')) + + if refresh_token is None: + jwt_key = '' + issuer = '' + else: + jwt_key = refresh_token.jwt_key + issuer = refresh_token.id + + try: + jwt.decode( + token, + jwt_key, + leeway=10, + issuer=issuer, + algorithms=['HS256'] + ) + except jwt.InvalidTokenError: + return None + + if not refresh_token.user.is_active: + return None + + return refresh_token + + async def _async_create_login_flow(self, handler, *, context, data): + """Create a login flow.""" + auth_provider = self._providers[handler] + + return await auth_provider.async_credential_flow(context) + + async def _async_finish_login_flow(self, context, result): + """Result of a credential login flow.""" + if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + return None + + auth_provider = self._providers[result['handler']] + return await auth_provider.async_get_or_create_credentials( + result['data']) + + @callback + def _async_get_auth_provider(self, credentials): + """Helper to get auth provider from a set of credentials.""" + auth_provider_key = (credentials.auth_provider_type, + credentials.auth_provider_id) + return self._providers.get(auth_provider_key) + + async def _user_should_be_owner(self): + """Determine if user should be owner. + + A user should be an owner if it is the first non-system user that is + being created. + """ + for user in await self._store.async_get_users(): + if not user.system_generated: + return False + + return True diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py new file mode 100644 index 00000000000000..806cd109d78e64 --- /dev/null +++ b/homeassistant/auth/auth_store.py @@ -0,0 +1,236 @@ +"""Storage for auth models.""" +from collections import OrderedDict +from datetime import timedelta +import hmac + +from homeassistant.util import dt as dt_util + +from . import models + +STORAGE_VERSION = 1 +STORAGE_KEY = 'auth' + + +class AuthStore: + """Stores authentication info. + + Any mutation to an object should happen inside the auth store. + + The auth store is lazy. It won't load the data from disk until a method is + called that needs it. + """ + + def __init__(self, hass): + """Initialize the auth store.""" + self.hass = hass + self._users = None + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + + async def async_get_users(self): + """Retrieve all users.""" + if self._users is None: + await self.async_load() + + return list(self._users.values()) + + async def async_get_user(self, user_id): + """Retrieve a user by id.""" + if self._users is None: + await self.async_load() + + return self._users.get(user_id) + + async def async_create_user(self, name, is_owner=None, is_active=None, + system_generated=None, credentials=None): + """Create a new user.""" + if self._users is None: + await self.async_load() + + kwargs = { + 'name': name + } + + if is_owner is not None: + kwargs['is_owner'] = is_owner + + if is_active is not None: + kwargs['is_active'] = is_active + + if system_generated is not None: + kwargs['system_generated'] = system_generated + + new_user = models.User(**kwargs) + + self._users[new_user.id] = new_user + + if credentials is None: + await self.async_save() + return new_user + + # Saving is done inside the link. + await self.async_link_user(new_user, credentials) + return new_user + + async def async_link_user(self, user, credentials): + """Add credentials to an existing user.""" + user.credentials.append(credentials) + await self.async_save() + credentials.is_new = False + + async def async_remove_user(self, user): + """Remove a user.""" + self._users.pop(user.id) + await self.async_save() + + async def async_activate_user(self, user): + """Activate a user.""" + user.is_active = True + await self.async_save() + + async def async_deactivate_user(self, user): + """Activate a user.""" + user.is_active = False + await self.async_save() + + async def async_remove_credentials(self, credentials): + """Remove credentials.""" + for user in self._users.values(): + found = None + + for index, cred in enumerate(user.credentials): + if cred is credentials: + found = index + break + + if found is not None: + user.credentials.pop(found) + break + + await self.async_save() + + async def async_create_refresh_token(self, user, client_id=None): + """Create a new token for a user.""" + refresh_token = models.RefreshToken(user=user, client_id=client_id) + user.refresh_tokens[refresh_token.id] = refresh_token + await self.async_save() + return refresh_token + + async def async_get_refresh_token(self, token_id): + """Get refresh token by id.""" + if self._users is None: + await self.async_load() + + for user in self._users.values(): + refresh_token = user.refresh_tokens.get(token_id) + if refresh_token is not None: + return refresh_token + + return None + + async def async_get_refresh_token_by_token(self, token): + """Get refresh token by token.""" + if self._users is None: + await self.async_load() + + found = None + + for user in self._users.values(): + for refresh_token in user.refresh_tokens.values(): + if hmac.compare_digest(refresh_token.token, token): + found = refresh_token + + return found + + async def async_load(self): + """Load the users.""" + data = await self._store.async_load() + + # Make sure that we're not overriding data if 2 loads happened at the + # same time + if self._users is not None: + return + + users = OrderedDict() + + if data is None: + self._users = users + return + + for user_dict in data['users']: + users[user_dict['id']] = models.User(**user_dict) + + for cred_dict in data['credentials']: + users[cred_dict['user_id']].credentials.append(models.Credentials( + id=cred_dict['id'], + is_new=False, + auth_provider_type=cred_dict['auth_provider_type'], + auth_provider_id=cred_dict['auth_provider_id'], + data=cred_dict['data'], + )) + + for rt_dict in data['refresh_tokens']: + # Filter out the old keys that don't have jwt_key (pre-0.76) + if 'jwt_key' not in rt_dict: + continue + + token = models.RefreshToken( + id=rt_dict['id'], + user=users[rt_dict['user_id']], + client_id=rt_dict['client_id'], + created_at=dt_util.parse_datetime(rt_dict['created_at']), + access_token_expiration=timedelta( + seconds=rt_dict['access_token_expiration']), + token=rt_dict['token'], + jwt_key=rt_dict['jwt_key'] + ) + users[rt_dict['user_id']].refresh_tokens[token.id] = token + + self._users = users + + async def async_save(self): + """Save users.""" + users = [ + { + 'id': user.id, + 'is_owner': user.is_owner, + 'is_active': user.is_active, + 'name': user.name, + 'system_generated': user.system_generated, + } + for user in self._users.values() + ] + + credentials = [ + { + 'id': credential.id, + 'user_id': user.id, + 'auth_provider_type': credential.auth_provider_type, + 'auth_provider_id': credential.auth_provider_id, + 'data': credential.data, + } + for user in self._users.values() + for credential in user.credentials + ] + + refresh_tokens = [ + { + 'id': refresh_token.id, + 'user_id': user.id, + 'client_id': refresh_token.client_id, + 'created_at': refresh_token.created_at.isoformat(), + 'access_token_expiration': + refresh_token.access_token_expiration.total_seconds(), + 'token': refresh_token.token, + 'jwt_key': refresh_token.jwt_key, + } + for user in self._users.values() + for refresh_token in user.refresh_tokens.values() + ] + + data = { + 'users': users, + 'credentials': credentials, + 'refresh_tokens': refresh_tokens, + } + + await self._store.async_save(data, delay=1) diff --git a/homeassistant/auth/const.py b/homeassistant/auth/const.py new file mode 100644 index 00000000000000..082d8966275670 --- /dev/null +++ b/homeassistant/auth/const.py @@ -0,0 +1,4 @@ +"""Constants for the auth module.""" +from datetime import timedelta + +ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30) diff --git a/homeassistant/auth/models.py b/homeassistant/auth/models.py new file mode 100644 index 00000000000000..3f49c56bce67eb --- /dev/null +++ b/homeassistant/auth/models.py @@ -0,0 +1,57 @@ +"""Auth models.""" +from datetime import datetime, timedelta +import uuid + +import attr + +from homeassistant.util import dt as dt_util + +from .const import ACCESS_TOKEN_EXPIRATION +from .util import generate_secret + + +@attr.s(slots=True) +class User: + """A user.""" + + name = attr.ib(type=str) + id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) + is_owner = attr.ib(type=bool, default=False) + is_active = attr.ib(type=bool, default=False) + system_generated = attr.ib(type=bool, default=False) + + # List of credentials of a user. + credentials = attr.ib(type=list, default=attr.Factory(list), cmp=False) + + # Tokens associated with a user. + refresh_tokens = attr.ib(type=dict, default=attr.Factory(dict), cmp=False) + + +@attr.s(slots=True) +class RefreshToken: + """RefreshToken for a user to grant new access tokens.""" + + user = attr.ib(type=User) + client_id = attr.ib(type=str) + id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) + created_at = attr.ib(type=datetime, default=attr.Factory(dt_util.utcnow)) + access_token_expiration = attr.ib(type=timedelta, + default=ACCESS_TOKEN_EXPIRATION) + token = attr.ib(type=str, + default=attr.Factory(lambda: generate_secret(64))) + jwt_key = attr.ib(type=str, + default=attr.Factory(lambda: generate_secret(64))) + + +@attr.s(slots=True) +class Credentials: + """Credentials for a user on an auth provider.""" + + auth_provider_type = attr.ib(type=str) + auth_provider_id = attr.ib(type=str) + + # Allow the auth provider to store data to represent their auth. + data = attr.ib(type=dict) + + id = attr.ib(type=str, default=attr.Factory(lambda: uuid.uuid4().hex)) + is_new = attr.ib(type=bool, default=True) diff --git a/homeassistant/auth/providers/__init__.py b/homeassistant/auth/providers/__init__.py new file mode 100644 index 00000000000000..ac5b6107b8ac99 --- /dev/null +++ b/homeassistant/auth/providers/__init__.py @@ -0,0 +1,143 @@ +"""Auth providers for Home Assistant.""" +import importlib +import logging + +import voluptuous as vol +from voluptuous.humanize import humanize_error + +from homeassistant import requirements +from homeassistant.core import callback +from homeassistant.const import CONF_TYPE, CONF_NAME, CONF_ID +from homeassistant.util.decorator import Registry + +from homeassistant.auth.models import Credentials + +_LOGGER = logging.getLogger(__name__) +DATA_REQS = 'auth_prov_reqs_processed' + +AUTH_PROVIDERS = Registry() + +AUTH_PROVIDER_SCHEMA = vol.Schema({ + vol.Required(CONF_TYPE): str, + vol.Optional(CONF_NAME): str, + # Specify ID if you have two auth providers for same type. + vol.Optional(CONF_ID): str, +}, extra=vol.ALLOW_EXTRA) + + +async def auth_provider_from_config(hass, store, config): + """Initialize an auth provider from a config.""" + provider_name = config[CONF_TYPE] + module = await load_auth_provider_module(hass, provider_name) + + if module is None: + return None + + try: + config = module.CONFIG_SCHEMA(config) + except vol.Invalid as err: + _LOGGER.error('Invalid configuration for auth provider %s: %s', + provider_name, humanize_error(config, err)) + return None + + return AUTH_PROVIDERS[provider_name](hass, store, config) + + +async def load_auth_provider_module(hass, provider): + """Load an auth provider.""" + try: + module = importlib.import_module( + 'homeassistant.auth.providers.{}'.format(provider)) + except ImportError: + _LOGGER.warning('Unable to find auth provider %s', provider) + return None + + if hass.config.skip_pip or not hasattr(module, 'REQUIREMENTS'): + return module + + processed = hass.data.get(DATA_REQS) + + if processed is None: + processed = hass.data[DATA_REQS] = set() + elif provider in processed: + return module + + req_success = await requirements.async_process_requirements( + hass, 'auth provider {}'.format(provider), module.REQUIREMENTS) + + if not req_success: + return None + + processed.add(provider) + return module + + +class AuthProvider: + """Provider of user authentication.""" + + DEFAULT_TITLE = 'Unnamed auth provider' + + def __init__(self, hass, store, config): + """Initialize an auth provider.""" + self.hass = hass + self.store = store + self.config = config + + @property + def id(self): # pylint: disable=invalid-name + """Return id of the auth provider. + + Optional, can be None. + """ + return self.config.get(CONF_ID) + + @property + def type(self): + """Return type of the provider.""" + return self.config[CONF_TYPE] + + @property + def name(self): + """Return the name of the auth provider.""" + return self.config.get(CONF_NAME, self.DEFAULT_TITLE) + + async def async_credentials(self): + """Return all credentials of this provider.""" + users = await self.store.async_get_users() + return [ + credentials + for user in users + for credentials in user.credentials + if (credentials.auth_provider_type == self.type and + credentials.auth_provider_id == self.id) + ] + + @callback + def async_create_credentials(self, data): + """Create credentials.""" + return Credentials( + auth_provider_type=self.type, + auth_provider_id=self.id, + data=data, + ) + + # Implement by extending class + + async def async_credential_flow(self, context): + """Return the data flow for logging in with auth provider.""" + raise NotImplementedError + + async def async_get_or_create_credentials(self, flow_result): + """Get credentials based on the flow result.""" + raise NotImplementedError + + async def async_user_meta_for_credentials(self, credentials): + """Return extra user metadata for credentials. + + Will be used to populate info when creating a new user. + + Values to populate: + - name: string + - is_active: boolean + """ + return {} diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py new file mode 100644 index 00000000000000..5a2355264ab8ae --- /dev/null +++ b/homeassistant/auth/providers/homeassistant.py @@ -0,0 +1,238 @@ +"""Home Assistant auth provider.""" +import base64 +from collections import OrderedDict +import hashlib +import hmac +from typing import Dict # noqa: F401 pylint: disable=unused-import + +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.const import CONF_ID +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError + +from homeassistant.auth.util import generate_secret + +from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS + +STORAGE_VERSION = 1 +STORAGE_KEY = 'auth_provider.homeassistant' + + +def _disallow_id(conf): + """Disallow ID in config.""" + if CONF_ID in conf: + raise vol.Invalid( + 'ID is not allowed for the homeassistant auth provider.') + + return conf + + +CONFIG_SCHEMA = vol.All(AUTH_PROVIDER_SCHEMA, _disallow_id) + + +class InvalidAuth(HomeAssistantError): + """Raised when we encounter invalid authentication.""" + + +class InvalidUser(HomeAssistantError): + """Raised when invalid user is specified. + + Will not be raised when validating authentication. + """ + + +class Data: + """Hold the user data.""" + + def __init__(self, hass): + """Initialize the user data store.""" + self.hass = hass + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + self._data = None + + async def async_load(self): + """Load stored data.""" + data = await self._store.async_load() + + if data is None: + data = { + 'salt': generate_secret(), + 'users': [] + } + + self._data = data + + @property + def users(self): + """Return users.""" + return self._data['users'] + + def validate_login(self, username: str, password: str) -> None: + """Validate a username and password. + + Raises InvalidAuth if auth invalid. + """ + hashed = self.hash_password(password) + + found = None + + # Compare all users to avoid timing attacks. + for user in self._data['users']: + if username == user['username']: + found = user + + if found is None: + # Do one more compare to make timing the same as if user was found. + hmac.compare_digest(hashed, hashed) + raise InvalidAuth + + if not hmac.compare_digest(hashed, + base64.b64decode(found['password'])): + raise InvalidAuth + + def hash_password(self, password: str, for_storage: bool = False) -> bytes: + """Encode a password.""" + hashed = hashlib.pbkdf2_hmac( + 'sha512', password.encode(), self._data['salt'].encode(), 100000) + if for_storage: + hashed = base64.b64encode(hashed) + return hashed + + def add_auth(self, username: str, password: str) -> None: + """Add a new authenticated user/pass.""" + if any(user['username'] == username for user in self.users): + raise InvalidUser + + self.users.append({ + 'username': username, + 'password': self.hash_password(password, True).decode(), + }) + + @callback + def async_remove_auth(self, username: str) -> None: + """Remove authentication.""" + index = None + for i, user in enumerate(self.users): + if user['username'] == username: + index = i + break + + if index is None: + raise InvalidUser + + self.users.pop(index) + + def change_password(self, username: str, new_password: str) -> None: + """Update the password. + + Raises InvalidUser if user cannot be found. + """ + for user in self.users: + if user['username'] == username: + user['password'] = self.hash_password( + new_password, True).decode() + break + else: + raise InvalidUser + + async def async_save(self): + """Save data.""" + await self._store.async_save(self._data) + + +@AUTH_PROVIDERS.register('homeassistant') +class HassAuthProvider(AuthProvider): + """Auth provider based on a local storage of users in HASS config dir.""" + + DEFAULT_TITLE = 'Home Assistant Local' + + data = None + + async def async_initialize(self): + """Initialize the auth provider.""" + if self.data is not None: + return + + self.data = Data(self.hass) + await self.data.async_load() + + async def async_credential_flow(self, context): + """Return a flow to login.""" + return LoginFlow(self) + + async def async_validate_login(self, username: str, password: str): + """Helper to validate a username and password.""" + if self.data is None: + await self.async_initialize() + + await self.hass.async_add_executor_job( + self.data.validate_login, username, password) + + async def async_get_or_create_credentials(self, flow_result): + """Get credentials based on the flow result.""" + username = flow_result['username'] + + for credential in await self.async_credentials(): + if credential.data['username'] == username: + return credential + + # Create new credentials. + return self.async_create_credentials({ + 'username': username + }) + + async def async_user_meta_for_credentials(self, credentials): + """Get extra info for this credential.""" + return { + 'name': credentials.data['username'], + 'is_active': True, + } + + async def async_will_remove_credentials(self, credentials): + """When credentials get removed, also remove the auth.""" + if self.data is None: + await self.async_initialize() + + try: + self.data.async_remove_auth(credentials.data['username']) + await self.data.async_save() + except InvalidUser: + # Can happen if somehow we didn't clean up a credential + pass + + +class LoginFlow(data_entry_flow.FlowHandler): + """Handler for the login flow.""" + + def __init__(self, auth_provider): + """Initialize the login flow.""" + self._auth_provider = auth_provider + + async def async_step_init(self, user_input=None): + """Handle the step of the form.""" + errors = {} + + if user_input is not None: + try: + await self._auth_provider.async_validate_login( + user_input['username'], user_input['password']) + except InvalidAuth: + errors['base'] = 'invalid_auth' + + if not errors: + return self.async_create_entry( + title=self._auth_provider.name, + data=user_input + ) + + schema = OrderedDict() # type: Dict[str, type] + schema['username'] = str + schema['password'] = str + + return self.async_show_form( + step_id='init', + data_schema=vol.Schema(schema), + errors=errors, + ) diff --git a/homeassistant/auth_providers/insecure_example.py b/homeassistant/auth/providers/insecure_example.py similarity index 81% rename from homeassistant/auth_providers/insecure_example.py rename to homeassistant/auth/providers/insecure_example.py index 8538e8c2f3eabb..96f824140ed877 100644 --- a/homeassistant/auth_providers/insecure_example.py +++ b/homeassistant/auth/providers/insecure_example.py @@ -4,9 +4,12 @@ import voluptuous as vol -from homeassistant import auth, data_entry_flow +from homeassistant.exceptions import HomeAssistantError +from homeassistant import data_entry_flow from homeassistant.core import callback +from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS + USER_SCHEMA = vol.Schema({ vol.Required('username'): str, @@ -15,16 +18,20 @@ }) -CONFIG_SCHEMA = auth.AUTH_PROVIDER_SCHEMA.extend({ +CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({ vol.Required('users'): [USER_SCHEMA] }, extra=vol.PREVENT_EXTRA) -@auth.AUTH_PROVIDERS.register('insecure_example') -class ExampleAuthProvider(auth.AuthProvider): +class InvalidAuthError(HomeAssistantError): + """Raised when submitting invalid authentication.""" + + +@AUTH_PROVIDERS.register('insecure_example') +class ExampleAuthProvider(AuthProvider): """Example auth provider based on hardcoded usernames and passwords.""" - async def async_credential_flow(self): + async def async_credential_flow(self, context): """Return a flow to login.""" return LoginFlow(self) @@ -43,18 +50,15 @@ def async_validate_login(self, username, password): # Do one more compare to make timing the same as if user was found. hmac.compare_digest(password.encode('utf-8'), password.encode('utf-8')) - raise auth.InvalidUser + raise InvalidAuthError if not hmac.compare_digest(user['password'].encode('utf-8'), password.encode('utf-8')): - raise auth.InvalidPassword + raise InvalidAuthError async def async_get_or_create_credentials(self, flow_result): """Get credentials based on the flow result.""" username = flow_result['username'] - password = flow_result['password'] - - self.async_validate_login(username, password) for credential in await self.async_credentials(): if credential.data['username'] == username: @@ -71,14 +75,16 @@ async def async_user_meta_for_credentials(self, credentials): Will be used to populate info when creating a new user. """ username = credentials.data['username'] + info = { + 'is_active': True, + } for user in self.config['users']: if user['username'] == username: - return { - 'name': user.get('name') - } + info['name'] = user.get('name') + break - return {} + return info class LoginFlow(data_entry_flow.FlowHandler): @@ -96,7 +102,7 @@ async def async_step_init(self, user_input=None): try: self._auth_provider.async_validate_login( user_input['username'], user_input['password']) - except (auth.InvalidUser, auth.InvalidPassword): + except InvalidAuthError: errors['base'] = 'invalid_auth' if not errors: diff --git a/homeassistant/auth/providers/legacy_api_password.py b/homeassistant/auth/providers/legacy_api_password.py new file mode 100644 index 00000000000000..f2f467e07ec1f0 --- /dev/null +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -0,0 +1,110 @@ +""" +Support Legacy API password auth provider. + +It will be removed when auth system production ready +""" +from collections import OrderedDict +import hmac + +import voluptuous as vol + +from homeassistant.exceptions import HomeAssistantError +from homeassistant import data_entry_flow +from homeassistant.core import callback + +from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS + + +USER_SCHEMA = vol.Schema({ + vol.Required('username'): str, +}) + + +CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({ +}, extra=vol.PREVENT_EXTRA) + +LEGACY_USER = 'homeassistant' + + +class InvalidAuthError(HomeAssistantError): + """Raised when submitting invalid authentication.""" + + +@AUTH_PROVIDERS.register('legacy_api_password') +class LegacyApiPasswordAuthProvider(AuthProvider): + """Example auth provider based on hardcoded usernames and passwords.""" + + DEFAULT_TITLE = 'Legacy API Password' + + async def async_credential_flow(self, context): + """Return a flow to login.""" + return LoginFlow(self) + + @callback + def async_validate_login(self, password): + """Helper to validate a username and password.""" + if not hasattr(self.hass, 'http'): + raise ValueError('http component is not loaded') + + if self.hass.http.api_password is None: + raise ValueError('http component is not configured using' + ' api_password') + + if not hmac.compare_digest(self.hass.http.api_password.encode('utf-8'), + password.encode('utf-8')): + raise InvalidAuthError + + async def async_get_or_create_credentials(self, flow_result): + """Return LEGACY_USER always.""" + for credential in await self.async_credentials(): + if credential.data['username'] == LEGACY_USER: + return credential + + return self.async_create_credentials({ + 'username': LEGACY_USER + }) + + async def async_user_meta_for_credentials(self, credentials): + """ + Set name as LEGACY_USER always. + + Will be used to populate info when creating a new user. + """ + return { + 'name': LEGACY_USER, + 'is_active': True, + } + + +class LoginFlow(data_entry_flow.FlowHandler): + """Handler for the login flow.""" + + def __init__(self, auth_provider): + """Initialize the login flow.""" + self._auth_provider = auth_provider + + async def async_step_init(self, user_input=None): + """Handle the step of the form.""" + errors = {} + + if user_input is not None: + try: + self._auth_provider.async_validate_login( + user_input['password']) + except InvalidAuthError: + errors['base'] = 'invalid_auth' + + if not errors: + return self.async_create_entry( + title=self._auth_provider.name, + data={} + ) + + schema = OrderedDict() + schema['password'] = str + + return self.async_show_form( + step_id='init', + data_schema=vol.Schema(schema), + errors=errors, + ) diff --git a/homeassistant/auth/util.py b/homeassistant/auth/util.py new file mode 100644 index 00000000000000..402caae4618d0f --- /dev/null +++ b/homeassistant/auth/util.py @@ -0,0 +1,13 @@ +"""Auth utils.""" +import binascii +import os + + +def generate_secret(entropy: int = 32) -> str: + """Generate a secret. + + Backport of secrets.token_hex from Python 3.6 + + Event loop friendly. + """ + return binascii.hexlify(os.urandom(entropy)).decode('ascii') diff --git a/homeassistant/auth_providers/__init__.py b/homeassistant/auth_providers/__init__.py deleted file mode 100644 index 4705e7580ca447..00000000000000 --- a/homeassistant/auth_providers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Auth providers for Home Assistant.""" diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 826cc563e82b6f..43c7168dd2e861 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -1,5 +1,4 @@ """Provide methods to bootstrap a Home Assistant instance.""" -import asyncio import logging import logging.handlers import os @@ -17,7 +16,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE from homeassistant.setup import async_setup_component from homeassistant.util.logging import AsyncHandler -from homeassistant.util.package import async_get_user_site, get_user_site +from homeassistant.util.package import async_get_user_site, is_virtual_env from homeassistant.util.yaml import clear_secret_cache from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.signal import async_register_signal_handling @@ -29,9 +28,8 @@ # hass.data key for logging information. DATA_LOGGING = 'logging' -FIRST_INIT_COMPONENT = set(( - 'system_log', 'recorder', 'mqtt', 'mqtt_eventstream', 'logger', - 'introduction', 'frontend', 'history')) +FIRST_INIT_COMPONENT = {'system_log', 'recorder', 'mqtt', 'mqtt_eventstream', + 'logger', 'introduction', 'frontend', 'history'} def from_config_dict(config: Dict[str, Any], @@ -53,8 +51,9 @@ def from_config_dict(config: Dict[str, Any], if config_dir is not None: config_dir = os.path.abspath(config_dir) hass.config.config_dir = config_dir - hass.loop.run_until_complete( - async_mount_local_lib_path(config_dir, hass.loop)) + if not is_virtual_env(): + hass.loop.run_until_complete( + async_mount_local_lib_path(config_dir)) # run task hass = hass.loop.run_until_complete( @@ -95,7 +94,8 @@ async def async_from_config_dict(config: Dict[str, Any], conf_util.async_log_exception(ex, 'homeassistant', core_config, hass) return None - await hass.async_add_job(conf_util.process_ha_config_upgrade, hass) + await hass.async_add_executor_job( + conf_util.process_ha_config_upgrade, hass) hass.config.skip_pip = skip_pip if skip_pip: @@ -123,7 +123,6 @@ async def async_from_config_dict(config: Dict[str, Any], components.update(hass.config_entries.async_domains()) # setup components - # pylint: disable=not-an-iterable res = await core_components.async_setup(hass, config) if not res: _LOGGER.error("Home Assistant core failed to initialize. " @@ -138,7 +137,7 @@ async def async_from_config_dict(config: Dict[str, Any], for component in components: if component not in FIRST_INIT_COMPONENT: continue - hass.async_add_job(async_setup_component(hass, component, config)) + hass.async_create_task(async_setup_component(hass, component, config)) await hass.async_block_till_done() @@ -146,7 +145,7 @@ async def async_from_config_dict(config: Dict[str, Any], for component in components: if component in FIRST_INIT_COMPONENT: continue - hass.async_add_job(async_setup_component(hass, component, config)) + hass.async_create_task(async_setup_component(hass, component, config)) await hass.async_block_till_done() @@ -163,7 +162,8 @@ def from_config_file(config_path: str, skip_pip: bool = True, log_rotate_days: Any = None, log_file: Any = None, - log_no_color: bool = False): + log_no_color: bool = False)\ + -> Optional[core.HomeAssistant]: """Read the configuration file and try to start all the functionality. Will add functionality to 'hass' parameter if given, @@ -188,7 +188,8 @@ async def async_from_config_file(config_path: str, skip_pip: bool = True, log_rotate_days: Any = None, log_file: Any = None, - log_no_color: bool = False): + log_no_color: bool = False)\ + -> Optional[core.HomeAssistant]: """Read the configuration file and try to start all the functionality. Will add functionality to 'hass' parameter. @@ -197,13 +198,15 @@ async def async_from_config_file(config_path: str, # Set config dir to directory holding config file config_dir = os.path.abspath(os.path.dirname(config_path)) hass.config.config_dir = config_dir - await async_mount_local_lib_path(config_dir, hass.loop) + + if not is_virtual_env(): + await async_mount_local_lib_path(config_dir) async_enable_logging(hass, verbose, log_rotate_days, log_file, log_no_color) try: - config_dict = await hass.async_add_job( + config_dict = await hass.async_add_executor_job( conf_util.load_yaml_config_file, config_path) except HomeAssistantError as err: _LOGGER.error("Error loading %s: %s", config_path, err) @@ -211,16 +214,15 @@ async def async_from_config_file(config_path: str, finally: clear_secret_cache() - hass = await async_from_config_dict( + return await async_from_config_dict( config_dict, hass, enable_log=False, skip_pip=skip_pip) - return hass @core.callback def async_enable_logging(hass: core.HomeAssistant, verbose: bool = False, - log_rotate_days=None, - log_file=None, + log_rotate_days: Optional[int] = None, + log_file: Optional[str] = None, log_no_color: bool = False) -> None: """Set up the logging. @@ -278,7 +280,8 @@ def async_enable_logging(hass: core.HomeAssistant, if log_rotate_days: err_handler = logging.handlers.TimedRotatingFileHandler( - err_log_path, when='midnight', backupCount=log_rotate_days) + err_log_path, when='midnight', + backupCount=log_rotate_days) # type: logging.FileHandler else: err_handler = logging.FileHandler( err_log_path, mode='w', delay=True) @@ -288,16 +291,16 @@ def async_enable_logging(hass: core.HomeAssistant, async_handler = AsyncHandler(hass.loop, err_handler) - async def async_stop_async_handler(event): + async def async_stop_async_handler(_: Any) -> None: """Cleanup async handler.""" - logging.getLogger('').removeHandler(async_handler) + logging.getLogger('').removeHandler(async_handler) # type: ignore await async_handler.async_close(blocking=True) hass.bus.async_listen_once( EVENT_HOMEASSISTANT_CLOSE, async_stop_async_handler) logger = logging.getLogger('') - logger.addHandler(async_handler) + logger.addHandler(async_handler) # type: ignore logger.setLevel(logging.INFO) # Save the log file location for access by other components. @@ -307,23 +310,13 @@ async def async_stop_async_handler(event): "Unable to setup error log %s (access denied)", err_log_path) -def mount_local_lib_path(config_dir: str) -> str: - """Add local library to Python Path.""" - deps_dir = os.path.join(config_dir, 'deps') - lib_dir = get_user_site(deps_dir) - if lib_dir not in sys.path: - sys.path.insert(0, lib_dir) - return deps_dir - - -async def async_mount_local_lib_path(config_dir: str, - loop: asyncio.AbstractEventLoop) -> str: +async def async_mount_local_lib_path(config_dir: str) -> str: """Add local library to Python Path. This function is a coroutine. """ deps_dir = os.path.join(config_dir, 'deps') - lib_dir = await async_get_user_site(deps_dir, loop=loop) + lib_dir = await async_get_user_site(deps_dir) if lib_dir not in sys.path: sys.path.insert(0, lib_dir) return deps_dir diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index f0c4f7bb3e2380..bf1577cbf01f38 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -10,6 +10,7 @@ import asyncio import itertools as it import logging +from typing import Awaitable import homeassistant.core as ha import homeassistant.config as conf_util @@ -109,7 +110,7 @@ def async_reload_core_config(hass): @asyncio.coroutine -def async_setup(hass, config): +def async_setup(hass: ha.HomeAssistant, config: dict) -> Awaitable[bool]: """Set up general services related to Home Assistant.""" @asyncio.coroutine def async_handle_turn_service(service): @@ -167,7 +168,7 @@ def async_handle_turn_service(service): def async_handle_core_service(call): """Service handler for handling core services.""" if call.service == SERVICE_HOMEASSISTANT_STOP: - hass.async_add_job(hass.async_stop()) + hass.async_create_task(hass.async_stop()) return try: @@ -183,7 +184,7 @@ def async_handle_core_service(call): return if call.service == SERVICE_HOMEASSISTANT_RESTART: - hass.async_add_job(hass.async_stop(RESTART_EXIT_CODE)) + hass.async_create_task(hass.async_stop(RESTART_EXIT_CODE)) hass.services.async_register( ha.DOMAIN, SERVICE_HOMEASSISTANT_STOP, async_handle_core_service) diff --git a/homeassistant/components/abode.py b/homeassistant/components/abode.py index 6d5feb87dc2b12..bafbc0781caf73 100644 --- a/homeassistant/components/abode.py +++ b/homeassistant/components/abode.py @@ -85,7 +85,7 @@ ] -class AbodeSystem(object): +class AbodeSystem: """Abode System class.""" def __init__(self, username, password, cache, diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py index d603843f51f3fb..100444c02116d4 100644 --- a/homeassistant/components/ads/__init__.py +++ b/homeassistant/components/ads/__init__.py @@ -110,7 +110,7 @@ def handle_write_data_by_name(call): ) -class AdsHub(object): +class AdsHub: """Representation of an ADS connection.""" def __init__(self, ads_client): diff --git a/homeassistant/components/alarm_control_panel/__init__.py b/homeassistant/components/alarm_control_panel/__init__.py index 25e303cbe853c3..0a4dd6bde784e6 100644 --- a/homeassistant/components/alarm_control_panel/__init__.py +++ b/homeassistant/components/alarm_control_panel/__init__.py @@ -121,7 +121,7 @@ def alarm_arm_custom_bypass(hass, code=None, entity_id=None): @asyncio.coroutine def async_setup(hass, config): """Track states and offer events for sensors.""" - component = EntityComponent( + component = hass.data[DOMAIN] = EntityComponent( logging.getLogger(__name__), DOMAIN, hass, SCAN_INTERVAL) yield from component.async_setup(config) @@ -154,6 +154,16 @@ def async_alarm_service_handler(service): return True +async def async_setup_entry(hass, entry): + """Setup a config entry.""" + return await hass.data[DOMAIN].async_setup_entry(entry) + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + # pylint: disable=no-self-use class AlarmControlPanel(Entity): """An abstract class for alarm control devices.""" @@ -177,7 +187,7 @@ def async_alarm_disarm(self, code=None): This method must be run in the event loop and returns a coroutine. """ - return self.hass.async_add_job(self.alarm_disarm, code) + return self.hass.async_add_executor_job(self.alarm_disarm, code) def alarm_arm_home(self, code=None): """Send arm home command.""" @@ -188,7 +198,7 @@ def async_alarm_arm_home(self, code=None): This method must be run in the event loop and returns a coroutine. """ - return self.hass.async_add_job(self.alarm_arm_home, code) + return self.hass.async_add_executor_job(self.alarm_arm_home, code) def alarm_arm_away(self, code=None): """Send arm away command.""" @@ -199,7 +209,7 @@ def async_alarm_arm_away(self, code=None): This method must be run in the event loop and returns a coroutine. """ - return self.hass.async_add_job(self.alarm_arm_away, code) + return self.hass.async_add_executor_job(self.alarm_arm_away, code) def alarm_arm_night(self, code=None): """Send arm night command.""" @@ -210,7 +220,7 @@ def async_alarm_arm_night(self, code=None): This method must be run in the event loop and returns a coroutine. """ - return self.hass.async_add_job(self.alarm_arm_night, code) + return self.hass.async_add_executor_job(self.alarm_arm_night, code) def alarm_trigger(self, code=None): """Send alarm trigger command.""" @@ -221,7 +231,7 @@ def async_alarm_trigger(self, code=None): This method must be run in the event loop and returns a coroutine. """ - return self.hass.async_add_job(self.alarm_trigger, code) + return self.hass.async_add_executor_job(self.alarm_trigger, code) def alarm_arm_custom_bypass(self, code=None): """Send arm custom bypass command.""" @@ -232,7 +242,8 @@ def async_alarm_arm_custom_bypass(self, code=None): This method must be run in the event loop and returns a coroutine. """ - return self.hass.async_add_job(self.alarm_arm_custom_bypass, code) + return self.hass.async_add_executor_job( + self.alarm_arm_custom_bypass, code) @property def state_attributes(self): diff --git a/homeassistant/components/alarm_control_panel/alarmdecoder.py b/homeassistant/components/alarm_control_panel/alarmdecoder.py index 49df9f2cefabe7..626022e362a5bc 100644 --- a/homeassistant/components/alarm_control_panel/alarmdecoder.py +++ b/homeassistant/components/alarm_control_panel/alarmdecoder.py @@ -100,8 +100,8 @@ def should_poll(self): @property def code_format(self): - """Return the regex for code format or None if no code is required.""" - return '^\\d{4,6}$' + """Return one or more digits/characters.""" + return 'Number' @property def state(self): diff --git a/homeassistant/components/alarm_control_panel/alarmdotcom.py b/homeassistant/components/alarm_control_panel/alarmdotcom.py index 31d933732862cb..736334c956ae14 100644 --- a/homeassistant/components/alarm_control_panel/alarmdotcom.py +++ b/homeassistant/components/alarm_control_panel/alarmdotcom.py @@ -6,6 +6,7 @@ """ import asyncio import logging +import re import voluptuous as vol @@ -79,17 +80,21 @@ def name(self): @property def code_format(self): - """Return one or more characters if code is defined.""" - return None if self._code is None else '.+' + """Return one or more digits/characters.""" + if self._code is None: + return None + if isinstance(self._code, str) and re.search('^\\d+$', self._code): + return 'Number' + return 'Any' @property def state(self): """Return the state of the device.""" if self._alarm.state.lower() == 'disarmed': return STATE_ALARM_DISARMED - elif self._alarm.state.lower() == 'armed stay': + if self._alarm.state.lower() == 'armed stay': return STATE_ALARM_ARMED_HOME - elif self._alarm.state.lower() == 'armed away': + if self._alarm.state.lower() == 'armed away': return STATE_ALARM_ARMED_AWAY return STATE_UNKNOWN diff --git a/homeassistant/components/alarm_control_panel/arlo.py b/homeassistant/components/alarm_control_panel/arlo.py index 333bde9ee36a75..0f8913f85a01c7 100644 --- a/homeassistant/components/alarm_control_panel/arlo.py +++ b/homeassistant/components/alarm_control_panel/arlo.py @@ -4,15 +4,17 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/alarm_control_panel.arlo/ """ -import asyncio import logging import voluptuous as vol import homeassistant.helpers.config_validation as cv +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.components.alarm_control_panel import ( AlarmControlPanel, PLATFORM_SCHEMA) -from homeassistant.components.arlo import (DATA_ARLO, CONF_ATTRIBUTION) +from homeassistant.components.arlo import ( + DATA_ARLO, CONF_ATTRIBUTION, SIGNAL_UPDATE_ARLO) from homeassistant.const import ( ATTR_ATTRIBUTION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED) @@ -36,21 +38,20 @@ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Arlo Alarm Control Panels.""" - data = hass.data[DATA_ARLO] + arlo = hass.data[DATA_ARLO] - if not data.base_stations: + if not arlo.base_stations: return home_mode_name = config.get(CONF_HOME_MODE_NAME) away_mode_name = config.get(CONF_AWAY_MODE_NAME) base_stations = [] - for base_station in data.base_stations: + for base_station in arlo.base_stations: base_stations.append(ArloBaseStation(base_station, home_mode_name, away_mode_name)) - async_add_devices(base_stations, True) + add_devices(base_stations, True) class ArloBaseStation(AlarmControlPanel): @@ -68,6 +69,16 @@ def icon(self): """Return icon.""" return ICON + async def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ARLO, self._update_callback) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + @property def state(self): """Return the state of the device.""" @@ -75,30 +86,22 @@ def state(self): def update(self): """Update the state of the device.""" - # PyArlo sometimes returns None for mode. So retry 3 times before - # returning None. - num_retries = 3 - i = 0 - while i < num_retries: - mode = self._base_station.mode - if mode: - self._state = self._get_state_from_mode(mode) - return - i += 1 - self._state = None - - @asyncio.coroutine - def async_alarm_disarm(self, code=None): + _LOGGER.debug("Updating Arlo Alarm Control Panel %s", self.name) + mode = self._base_station.mode + if mode: + self._state = self._get_state_from_mode(mode) + else: + self._state = None + + async def async_alarm_disarm(self, code=None): """Send disarm command.""" self._base_station.mode = DISARMED - @asyncio.coroutine - def async_alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code=None): """Send arm away command. Uses custom mode.""" self._base_station.mode = self._away_mode_name - @asyncio.coroutine - def async_alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code=None): """Send arm home command. Uses custom mode.""" self._base_station.mode = self._home_mode_name @@ -119,10 +122,10 @@ def _get_state_from_mode(self, mode): """Convert Arlo mode to Home Assistant state.""" if mode == ARMED: return STATE_ALARM_ARMED_AWAY - elif mode == DISARMED: + if mode == DISARMED: return STATE_ALARM_DISARMED - elif mode == self._home_mode_name: + if mode == self._home_mode_name: return STATE_ALARM_ARMED_HOME - elif mode == self._away_mode_name: + if mode == self._away_mode_name: return STATE_ALARM_ARMED_AWAY - return None + return mode diff --git a/homeassistant/components/alarm_control_panel/canary.py b/homeassistant/components/alarm_control_panel/canary.py index 2e0e9994e100d6..3cd44dcc84ca95 100644 --- a/homeassistant/components/alarm_control_panel/canary.py +++ b/homeassistant/components/alarm_control_panel/canary.py @@ -55,9 +55,9 @@ def state(self): mode = location.mode if mode.name == LOCATION_MODE_AWAY: return STATE_ALARM_ARMED_AWAY - elif mode.name == LOCATION_MODE_HOME: + if mode.name == LOCATION_MODE_HOME: return STATE_ALARM_ARMED_HOME - elif mode.name == LOCATION_MODE_NIGHT: + if mode.name == LOCATION_MODE_NIGHT: return STATE_ALARM_ARMED_NIGHT return None diff --git a/homeassistant/components/alarm_control_panel/concord232.py b/homeassistant/components/alarm_control_panel/concord232.py index d48a107f33dee3..9a65fdaff06d10 100644 --- a/homeassistant/components/alarm_control_panel/concord232.py +++ b/homeassistant/components/alarm_control_panel/concord232.py @@ -80,7 +80,7 @@ def name(self): @property def code_format(self): """Return the characters if code is defined.""" - return '[0-9]{4}([0-9]{2})?' + return 'Number' @property def state(self): diff --git a/homeassistant/components/alarm_control_panel/demo.py b/homeassistant/components/alarm_control_panel/demo.py index c080a136c080e6..d2366e5836c91e 100644 --- a/homeassistant/components/alarm_control_panel/demo.py +++ b/homeassistant/components/alarm_control_panel/demo.py @@ -5,7 +5,7 @@ https://home-assistant.io/components/demo/ """ import datetime -import homeassistant.components.alarm_control_panel.manual as manual +from homeassistant.components.alarm_control_panel import manual from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_CUSTOM_BYPASS, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, diff --git a/homeassistant/components/alarm_control_panel/envisalink.py b/homeassistant/components/alarm_control_panel/envisalink.py index e5003f1ba1d8a7..25224484c797ad 100644 --- a/homeassistant/components/alarm_control_panel/envisalink.py +++ b/homeassistant/components/alarm_control_panel/envisalink.py @@ -106,7 +106,7 @@ def code_format(self): """Regex for code format or None if no code is required.""" if self._code: return None - return '^\\d{4,6}$' + return 'Number' @property def state(self): diff --git a/homeassistant/components/alarm_control_panel/homematicip_cloud.py b/homeassistant/components/alarm_control_panel/homematicip_cloud.py new file mode 100644 index 00000000000000..79f872951dbe22 --- /dev/null +++ b/homeassistant/components/alarm_control_panel/homematicip_cloud.py @@ -0,0 +1,84 @@ +""" +Support for HomematicIP alarm control panel. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/alarm_control_panel.homematicip_cloud/ +""" + +import logging + +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, + STATE_ALARM_TRIGGERED) +from homeassistant.components.alarm_control_panel import AlarmControlPanel +from homeassistant.components.homematicip_cloud import ( + HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN, + HMIPC_HAPID) + + +DEPENDENCIES = ['homematicip_cloud'] + +_LOGGER = logging.getLogger(__name__) + +HMIP_ZONE_AWAY = 'EXTERNAL' +HMIP_ZONE_HOME = 'INTERNAL' + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the HomematicIP alarm control devices.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the HomematicIP alarm control panel from a config entry.""" + from homematicip.aio.group import AsyncSecurityZoneGroup + + home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home + devices = [] + for group in home.groups: + if isinstance(group, AsyncSecurityZoneGroup): + devices.append(HomematicipSecurityZone(home, group)) + + if devices: + async_add_devices(devices) + + +class HomematicipSecurityZone(HomematicipGenericDevice, AlarmControlPanel): + """Representation of an HomematicIP security zone group.""" + + def __init__(self, home, device): + """Initialize the security zone group.""" + device.modelType = 'Group-SecurityZone' + device.windowState = '' + super().__init__(home, device) + + @property + def state(self): + """Return the state of the device.""" + from homematicip.base.enums import WindowState + + if self._device.active: + if (self._device.sabotage or self._device.motionDetected or + self._device.windowState == WindowState.OPEN): + return STATE_ALARM_TRIGGERED + + active = self._home.get_security_zones_activation() + if active == (True, True): + return STATE_ALARM_ARMED_AWAY + if active == (False, True): + return STATE_ALARM_ARMED_HOME + + return STATE_ALARM_DISARMED + + async def async_alarm_disarm(self, code=None): + """Send disarm command.""" + await self._home.set_security_zones_activation(False, False) + + async def async_alarm_arm_home(self, code=None): + """Send arm home command.""" + await self._home.set_security_zones_activation(True, False) + + async def async_alarm_arm_away(self, code=None): + """Send arm away command.""" + await self._home.set_security_zones_activation(True, True) diff --git a/homeassistant/components/alarm_control_panel/ifttt.py b/homeassistant/components/alarm_control_panel/ifttt.py index 7bdc1ccd9d9177..9941f70a2e4a8e 100644 --- a/homeassistant/components/alarm_control_panel/ifttt.py +++ b/homeassistant/components/alarm_control_panel/ifttt.py @@ -5,6 +5,7 @@ https://home-assistant.io/components/alarm_control_panel.ifttt/ """ import logging +import re import voluptuous as vol @@ -124,8 +125,12 @@ def assumed_state(self): @property def code_format(self): - """Return one or more characters.""" - return None if self._code is None else '.+' + """Return one or more digits/characters.""" + if self._code is None: + return None + if isinstance(self._code, str) and re.search('^\\d+$', self._code): + return 'Number' + return 'Any' def alarm_disarm(self, code=None): """Send disarm command.""" diff --git a/homeassistant/components/alarm_control_panel/manual.py b/homeassistant/components/alarm_control_panel/manual.py index 5beb5261607d01..b2b7c45d410146 100644 --- a/homeassistant/components/alarm_control_panel/manual.py +++ b/homeassistant/components/alarm_control_panel/manual.py @@ -7,6 +7,7 @@ import copy import datetime import logging +import re import voluptuous as vol @@ -201,8 +202,12 @@ def _within_pending_time(self, state): @property def code_format(self): - """Return one or more characters.""" - return None if self._code is None else '.+' + """Return one or more digits/characters.""" + if self._code is None: + return None + if isinstance(self._code, str) and re.search('^\\d+$', self._code): + return 'Number' + return 'Any' def alarm_disarm(self, code=None): """Send disarm command.""" diff --git a/homeassistant/components/alarm_control_panel/manual_mqtt.py b/homeassistant/components/alarm_control_panel/manual_mqtt.py index 4b08ad67292d27..942d0dc159a8c5 100644 --- a/homeassistant/components/alarm_control_panel/manual_mqtt.py +++ b/homeassistant/components/alarm_control_panel/manual_mqtt.py @@ -8,6 +8,7 @@ import copy import datetime import logging +import re import voluptuous as vol @@ -18,7 +19,7 @@ STATE_ALARM_DISARMED, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, CONF_PLATFORM, CONF_NAME, CONF_CODE, CONF_DELAY_TIME, CONF_PENDING_TIME, CONF_TRIGGER_TIME, CONF_DISARM_AFTER_TRIGGER) -import homeassistant.components.mqtt as mqtt +from homeassistant.components import mqtt from homeassistant.helpers.event import async_track_state_change from homeassistant.core import callback @@ -237,8 +238,12 @@ def _within_pending_time(self, state): @property def code_format(self): - """Return one or more characters.""" - return None if self._code is None else '.+' + """Return one or more digits/characters.""" + if self._code is None: + return None + if isinstance(self._code, str) and re.search('^\\d+$', self._code): + return 'Number' + return 'Any' def alarm_disarm(self, code=None): """Send disarm command.""" diff --git a/homeassistant/components/alarm_control_panel/mqtt.py b/homeassistant/components/alarm_control_panel/mqtt.py index 1422136c405433..54b85ffbe232a4 100644 --- a/homeassistant/components/alarm_control_panel/mqtt.py +++ b/homeassistant/components/alarm_control_panel/mqtt.py @@ -6,12 +6,13 @@ """ import asyncio import logging +import re import voluptuous as vol from homeassistant.core import callback import homeassistant.components.alarm_control_panel as alarm -import homeassistant.components.mqtt as mqtt +from homeassistant.components import mqtt from homeassistant.const import ( STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, STATE_UNKNOWN, @@ -19,7 +20,7 @@ from homeassistant.components.mqtt import ( CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, - MqttAvailability) + CONF_RETAIN, MqttAvailability) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -48,11 +49,15 @@ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the MQTT Alarm Control Panel platform.""" + if discovery_info is not None: + config = PLATFORM_SCHEMA(discovery_info) + async_add_devices([MqttAlarm( config.get(CONF_NAME), config.get(CONF_STATE_TOPIC), config.get(CONF_COMMAND_TOPIC), config.get(CONF_QOS), + config.get(CONF_RETAIN), config.get(CONF_PAYLOAD_DISARM), config.get(CONF_PAYLOAD_ARM_HOME), config.get(CONF_PAYLOAD_ARM_AWAY), @@ -65,9 +70,9 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class MqttAlarm(MqttAvailability, alarm.AlarmControlPanel): """Representation of a MQTT alarm status.""" - def __init__(self, name, state_topic, command_topic, qos, payload_disarm, - payload_arm_home, payload_arm_away, code, availability_topic, - payload_available, payload_not_available): + def __init__(self, name, state_topic, command_topic, qos, retain, + payload_disarm, payload_arm_home, payload_arm_away, code, + availability_topic, payload_available, payload_not_available): """Init the MQTT Alarm Control Panel.""" super().__init__(availability_topic, qos, payload_available, payload_not_available) @@ -76,6 +81,7 @@ def __init__(self, name, state_topic, command_topic, qos, payload_disarm, self._state_topic = state_topic self._command_topic = command_topic self._qos = qos + self._retain = retain self._payload_disarm = payload_disarm self._payload_arm_home = payload_arm_home self._payload_arm_away = payload_arm_away @@ -117,8 +123,12 @@ def state(self): @property def code_format(self): - """One or more characters if code is defined.""" - return None if self._code is None else '.+' + """Return one or more digits/characters.""" + if self._code is None: + return None + if isinstance(self._code, str) and re.search('^\\d+$', self._code): + return 'Number' + return 'Any' @asyncio.coroutine def async_alarm_disarm(self, code=None): @@ -129,7 +139,8 @@ def async_alarm_disarm(self, code=None): if not self._validate_code(code, 'disarming'): return mqtt.async_publish( - self.hass, self._command_topic, self._payload_disarm, self._qos) + self.hass, self._command_topic, self._payload_disarm, self._qos, + self._retain) @asyncio.coroutine def async_alarm_arm_home(self, code=None): @@ -140,7 +151,8 @@ def async_alarm_arm_home(self, code=None): if not self._validate_code(code, 'arming home'): return mqtt.async_publish( - self.hass, self._command_topic, self._payload_arm_home, self._qos) + self.hass, self._command_topic, self._payload_arm_home, self._qos, + self._retain) @asyncio.coroutine def async_alarm_arm_away(self, code=None): @@ -151,7 +163,8 @@ def async_alarm_arm_away(self, code=None): if not self._validate_code(code, 'arming away'): return mqtt.async_publish( - self.hass, self._command_topic, self._payload_arm_away, self._qos) + self.hass, self._command_topic, self._payload_arm_away, self._qos, + self._retain) def _validate_code(self, code, state): """Validate given code.""" diff --git a/homeassistant/components/alarm_control_panel/nx584.py b/homeassistant/components/alarm_control_panel/nx584.py index ceb79c1dc7b4f2..ca6f1a44a6f4c6 100644 --- a/homeassistant/components/alarm_control_panel/nx584.py +++ b/homeassistant/components/alarm_control_panel/nx584.py @@ -69,8 +69,8 @@ def name(self): @property def code_format(self): - """Return che characters if code is defined.""" - return '[0-9]{4}([0-9]{2})?' + """Return one or more digits/characters.""" + return 'Number' @property def state(self): diff --git a/homeassistant/components/alarm_control_panel/satel_integra.py b/homeassistant/components/alarm_control_panel/satel_integra.py index 964047f91e9655..4ac3a93fff4b73 100644 --- a/homeassistant/components/alarm_control_panel/satel_integra.py +++ b/homeassistant/components/alarm_control_panel/satel_integra.py @@ -66,7 +66,7 @@ def should_poll(self): @property def code_format(self): """Return the regex for code format or None if no code is required.""" - return '^\\d{4,6}$' + return 'Number' @property def state(self): diff --git a/homeassistant/components/alarm_control_panel/simplisafe.py b/homeassistant/components/alarm_control_panel/simplisafe.py index 3b991c5b236b63..b400a927b5e475 100644 --- a/homeassistant/components/alarm_control_panel/simplisafe.py +++ b/homeassistant/components/alarm_control_panel/simplisafe.py @@ -5,26 +5,26 @@ https://home-assistant.io/components/alarm_control_panel.simplisafe/ """ import logging +import re import voluptuous as vol -import homeassistant.components.alarm_control_panel as alarm -from homeassistant.components.alarm_control_panel import PLATFORM_SCHEMA +from homeassistant.components.alarm_control_panel import ( + PLATFORM_SCHEMA, AlarmControlPanel) from homeassistant.const import ( CONF_CODE, CONF_NAME, CONF_PASSWORD, CONF_USERNAME, - EVENT_HOMEASSISTANT_STOP, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_UNKNOWN) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['simplisafe-python==1.0.5'] +REQUIREMENTS = ['simplisafe-python==2.0.2'] _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'SimpliSafe' -DOMAIN = 'simplisafe' -NOTIFICATION_ID = 'simplisafe_notification' -NOTIFICATION_TITLE = 'SimpliSafe Setup' +ATTR_ALARM_ACTIVE = "alarm_active" +ATTR_TEMPERATURE = "temperature" PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_PASSWORD): cv.string, @@ -36,36 +36,27 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the SimpliSafe platform.""" - from simplipy.api import SimpliSafeApiInterface, get_systems + from simplipy.api import SimpliSafeApiInterface, SimpliSafeAPIException name = config.get(CONF_NAME) code = config.get(CONF_CODE) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) - simplisafe = SimpliSafeApiInterface() - status = simplisafe.set_credentials(username, password) - if status: - hass.data[DOMAIN] = simplisafe - locations = get_systems(simplisafe) - for location in locations: - add_devices([SimpliSafeAlarm(location, name, code)]) - else: - message = 'Failed to log into SimpliSafe. Check credentials.' - _LOGGER.error(message) - hass.components.persistent_notification.create( - message, - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) - return False - - def logout(event): - """Logout of the SimpliSafe API.""" - hass.data[DOMAIN].logout() - - hass.bus.listen(EVENT_HOMEASSISTANT_STOP, logout) - - -class SimpliSafeAlarm(alarm.AlarmControlPanel): + try: + simplisafe = SimpliSafeApiInterface(username, password) + except SimpliSafeAPIException: + _LOGGER.error("Failed to setup SimpliSafe") + return + + systems = [] + + for system in simplisafe.get_systems(): + systems.append(SimpliSafeAlarm(system, name, code)) + + add_devices(systems) + + +class SimpliSafeAlarm(AlarmControlPanel): """Representation of a SimpliSafe alarm.""" def __init__(self, simplisafe, name, code): @@ -74,27 +65,37 @@ def __init__(self, simplisafe, name, code): self._name = name self._code = str(code) if code else None + @property + def unique_id(self): + """Return the unique ID.""" + return self.simplisafe.location_id + @property def name(self): """Return the name of the device.""" if self._name is not None: return self._name - return 'Alarm {}'.format(self.simplisafe.location_id()) + return 'Alarm {}'.format(self.simplisafe.location_id) @property def code_format(self): - """Return one or more characters if code is defined.""" - return None if self._code is None else '.+' + """Return one or more digits/characters.""" + if self._code is None: + return None + if isinstance(self._code, str) and re.search('^\\d+$', self._code): + return 'Number' + return 'Any' @property def state(self): """Return the state of the device.""" - status = self.simplisafe.state() - if status == 'off': + status = self.simplisafe.state + if status.lower() == 'off': state = STATE_ALARM_DISARMED - elif status == 'home': + elif status.lower() == 'home' or status.lower() == 'home_count': state = STATE_ALARM_ARMED_HOME - elif status == 'away': + elif (status.lower() == 'away' or status.lower() == 'exitDelay' or + status.lower() == 'away_count'): state = STATE_ALARM_ARMED_AWAY else: state = STATE_UNKNOWN @@ -103,14 +104,13 @@ def state(self): @property def device_state_attributes(self): """Return the state attributes.""" - return { - 'alarm': self.simplisafe.alarm(), - 'co': self.simplisafe.carbon_monoxide(), - 'fire': self.simplisafe.fire(), - 'flood': self.simplisafe.flood(), - 'last_event': self.simplisafe.last_event(), - 'temperature': self.simplisafe.temperature(), - } + attributes = {} + + attributes[ATTR_ALARM_ACTIVE] = self.simplisafe.alarm_active + if self.simplisafe.temperature is not None: + attributes[ATTR_TEMPERATURE] = self.simplisafe.temperature + + return attributes def update(self): """Update alarm status.""" diff --git a/homeassistant/components/alarm_control_panel/totalconnect.py b/homeassistant/components/alarm_control_panel/totalconnect.py index 1f383e32f925c7..674eac97f8c590 100644 --- a/homeassistant/components/alarm_control_panel/totalconnect.py +++ b/homeassistant/components/alarm_control_panel/totalconnect.py @@ -18,7 +18,7 @@ STATE_ALARM_ARMED_CUSTOM_BYPASS) -REQUIREMENTS = ['total_connect_client==0.17'] +REQUIREMENTS = ['total_connect_client==0.18'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/alarm_control_panel/verisure.py b/homeassistant/components/alarm_control_panel/verisure.py index 74d63b1fb9c0f9..59bfe15fa9b4cd 100644 --- a/homeassistant/components/alarm_control_panel/verisure.py +++ b/homeassistant/components/alarm_control_panel/verisure.py @@ -60,8 +60,8 @@ def state(self): @property def code_format(self): - """Return the code format as regex.""" - return '^\\d{%s}$' % self._digits + """Return one or more digits/characters.""" + return 'Number' @property def changed_by(self): diff --git a/homeassistant/components/alarmdecoder.py b/homeassistant/components/alarmdecoder.py index bc7f1910803bf7..1377b2a6c3aa14 100644 --- a/homeassistant/components/alarmdecoder.py +++ b/homeassistant/components/alarmdecoder.py @@ -34,6 +34,8 @@ CONF_ZONE_TYPE = 'type' CONF_ZONE_RFID = 'rfid' CONF_ZONES = 'zones' +CONF_RELAY_ADDR = 'relayaddr' +CONF_RELAY_CHAN = 'relaychan' DEFAULT_DEVICE_TYPE = 'socket' DEFAULT_DEVICE_HOST = 'localhost' @@ -53,6 +55,7 @@ SIGNAL_ZONE_FAULT = 'alarmdecoder.zone_fault' SIGNAL_ZONE_RESTORE = 'alarmdecoder.zone_restore' SIGNAL_RFX_MESSAGE = 'alarmdecoder.rfx_message' +SIGNAL_REL_MESSAGE = 'alarmdecoder.rel_message' DEVICE_SOCKET_SCHEMA = vol.Schema({ vol.Required(CONF_DEVICE_TYPE): 'socket', @@ -71,7 +74,11 @@ vol.Required(CONF_ZONE_NAME): cv.string, vol.Optional(CONF_ZONE_TYPE, default=DEFAULT_ZONE_TYPE): vol.Any(DEVICE_CLASSES_SCHEMA), - vol.Optional(CONF_ZONE_RFID): cv.string}) + vol.Optional(CONF_ZONE_RFID): cv.string, + vol.Inclusive(CONF_RELAY_ADDR, 'relaylocation', + 'Relay address and channel must exist together'): cv.byte, + vol.Inclusive(CONF_RELAY_CHAN, 'relaylocation', + 'Relay address and channel must exist together'): cv.byte}) CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -153,6 +160,11 @@ def zone_restore_callback(sender, zone): hass.helpers.dispatcher.dispatcher_send( SIGNAL_ZONE_RESTORE, zone) + def handle_rel_message(sender, message): + """Handle relay message from AlarmDecoder.""" + hass.helpers.dispatcher.dispatcher_send( + SIGNAL_REL_MESSAGE, message) + controller = False if device_type == 'socket': host = device.get(CONF_DEVICE_HOST) @@ -171,6 +183,7 @@ def zone_restore_callback(sender, zone): controller.on_zone_fault += zone_fault_callback controller.on_zone_restore += zone_restore_callback controller.on_close += handle_closed_connection + controller.on_relay_changed += handle_rel_message hass.data[DATA_AD] = controller diff --git a/homeassistant/components/alert.py b/homeassistant/components/alert.py index 9d47e4bd322f11..80a02b3275d6d2 100644 --- a/homeassistant/components/alert.py +++ b/homeassistant/components/alert.py @@ -68,7 +68,7 @@ def turn_on(hass, entity_id): def async_turn_on(hass, entity_id): """Async reset the alert.""" data = {ATTR_ENTITY_ID: entity_id} - hass.async_add_job( + hass.async_create_task( hass.services.async_call(DOMAIN, SERVICE_TURN_ON, data)) @@ -81,7 +81,7 @@ def turn_off(hass, entity_id): def async_turn_off(hass, entity_id): """Async acknowledge the alert.""" data = {ATTR_ENTITY_ID: entity_id} - hass.async_add_job( + hass.async_create_task( hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, data)) @@ -94,7 +94,7 @@ def toggle(hass, entity_id): def async_toggle(hass, entity_id): """Async toggle acknowledgement of alert.""" data = {ATTR_ENTITY_ID: entity_id} - hass.async_add_job( + hass.async_create_task( hass.services.async_call(DOMAIN, SERVICE_TOGGLE, data)) @@ -217,7 +217,7 @@ def begin_alerting(self): else: yield from self._schedule_notify() - self.hass.async_add_job(self.async_update_ha_state) + self.async_schedule_update_ha_state() @asyncio.coroutine def end_alerting(self): @@ -228,7 +228,7 @@ def end_alerting(self): self._firing = False if self._done_message and self._send_done_message: yield from self._notify_done_message() - self.hass.async_add_job(self.async_update_ha_state) + self.async_schedule_update_ha_state() @asyncio.coroutine def _schedule_notify(self): diff --git a/homeassistant/components/alexa/intent.py b/homeassistant/components/alexa/intent.py index b6d406bd550f33..8d4520d74e83e1 100644 --- a/homeassistant/components/alexa/intent.py +++ b/homeassistant/components/alexa/intent.py @@ -210,7 +210,7 @@ def resolve_slot_synonyms(key, request): return resolved_value -class AlexaResponse(object): +class AlexaResponse: """Help generating the response for Alexa.""" def __init__(self, hass, intent_info): diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index c5c68f1af40fa7..042d878fceb023 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -55,7 +55,7 @@ ENTITY_ADAPTERS = Registry() -class _DisplayCategory(object): +class _DisplayCategory: """Possible display categories for Discovery response. https://developer.amazon.com/docs/device-apis/alexa-discovery.html#display-categories @@ -107,7 +107,6 @@ class _DisplayCategory(object): THERMOSTAT = "THERMOSTAT" # Indicates the endpoint is a television. - # pylint: disable=invalid-name TV = "TV" @@ -154,7 +153,7 @@ class _UnsupportedProperty(Exception): """This entity does not support the requested Smart Home API property.""" -class _AlexaEntity(object): +class _AlexaEntity: """An adaptation of an entity, expressed in Alexa's terms. The API handlers should manipulate entities only through this interface. @@ -209,7 +208,7 @@ def interfaces(self): raise NotImplementedError -class _AlexaInterface(object): +class _AlexaInterface: def __init__(self, entity): self.entity = entity @@ -271,11 +270,14 @@ def serialize_properties(self): """Return properties serialized for an API response.""" for prop in self.properties_supported(): prop_name = prop['name'] - yield { - 'name': prop_name, - 'namespace': self.name(), - 'value': self.get_property(prop_name), - } + # pylint: disable=assignment-from-no-return + prop_value = self.get_property(prop_name) + if prop_value is not None: + yield { + 'name': prop_name, + 'namespace': self.name(), + 'value': prop_value, + } class _AlexaPowerController(_AlexaInterface): @@ -313,7 +315,7 @@ def get_property(self, name): if self.entity.state == STATE_LOCKED: return 'LOCKED' - elif self.entity.state == STATE_UNLOCKED: + if self.entity.state == STATE_UNLOCKED: return 'UNLOCKED' return 'JAMMED' @@ -439,14 +441,17 @@ def get_property(self, name): unit = self.entity.attributes[CONF_UNIT_OF_MEASUREMENT] temp = None if name == 'targetSetpoint': - temp = self.entity.attributes.get(ATTR_TEMPERATURE) + temp = self.entity.attributes.get(climate.ATTR_TEMPERATURE) elif name == 'lowerSetpoint': temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_LOW) elif name == 'upperSetpoint': temp = self.entity.attributes.get(climate.ATTR_TARGET_TEMP_HIGH) - if temp is None: + else: raise _UnsupportedProperty(name) + if temp is None: + return None + return { 'value': float(temp), 'scale': API_TEMP_UNITS[unit], @@ -610,7 +615,7 @@ def interfaces(self): yield _AlexaTemperatureSensor(self.entity) -class _Cause(object): +class _Cause: """Possible causes for property changes. https://developer.amazon.com/docs/smarthome/state-reporting-for-a-smart-home-skill.html#cause-object @@ -1474,9 +1479,6 @@ async def async_api_set_thermostat_mode(hass, config, request, entity): mode = mode if isinstance(mode, str) else mode['value'] operation_list = entity.attributes.get(climate.ATTR_OPERATION_LIST) - # Work around a pylint false positive due to - # https://github.com/PyCQA/pylint/issues/1830 - # pylint: disable=stop-iteration-return ha_mode = next( (k for k, v in API_THERMOSTAT_MODES.items() if v == mode), None diff --git a/homeassistant/components/amcrest.py b/homeassistant/components/amcrest.py index d0e470e3f8ec44..bcd0c38c3bdff2 100644 --- a/homeassistant/components/amcrest.py +++ b/homeassistant/components/amcrest.py @@ -18,7 +18,7 @@ from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['amcrest==1.2.2'] +REQUIREMENTS = ['amcrest==1.2.3'] DEPENDENCIES = ['ffmpeg'] _LOGGER = logging.getLogger(__name__) @@ -164,7 +164,7 @@ def setup(hass, config): return True -class AmcrestDevice(object): +class AmcrestDevice: """Representation of a base Amcrest discovery device.""" def __init__(self, camera, name, authentication, ffmpeg_arguments, diff --git a/homeassistant/components/android_ip_webcam.py b/homeassistant/components/android_ip_webcam.py index 13fa64438d378d..5da117e74c382e 100644 --- a/homeassistant/components/android_ip_webcam.py +++ b/homeassistant/components/android_ip_webcam.py @@ -214,11 +214,11 @@ def async_update_data(now): CONF_PASSWORD: password }) - hass.async_add_job(discovery.async_load_platform( + hass.async_create_task(discovery.async_load_platform( hass, 'camera', 'mjpeg', mjpeg_camera, config)) if sensors: - hass.async_add_job(discovery.async_load_platform( + hass.async_create_task(discovery.async_load_platform( hass, 'sensor', DOMAIN, { CONF_NAME: name, CONF_HOST: host, @@ -226,7 +226,7 @@ def async_update_data(now): }, config)) if switches: - hass.async_add_job(discovery.async_load_platform( + hass.async_create_task(discovery.async_load_platform( hass, 'switch', DOMAIN, { CONF_NAME: name, CONF_HOST: host, @@ -234,7 +234,7 @@ def async_update_data(now): }, config)) if motion: - hass.async_add_job(discovery.async_load_platform( + hass.async_create_task(discovery.async_load_platform( hass, 'binary_sensor', DOMAIN, { CONF_HOST: host, CONF_NAME: name, diff --git a/homeassistant/components/apcupsd.py b/homeassistant/components/apcupsd.py index 7e2b4cda28f827..8808cee79a3beb 100644 --- a/homeassistant/components/apcupsd.py +++ b/homeassistant/components/apcupsd.py @@ -58,7 +58,7 @@ def setup(hass, config): return True -class APCUPSdData(object): +class APCUPSdData: """Stores the data retrieved from APCUPSd. For each entity to use, acts as the single point responsible for fetching diff --git a/homeassistant/components/api.py b/homeassistant/components/api.py index 83e05dae6417fc..de28eeff5ca506 100644 --- a/homeassistant/components/api.py +++ b/homeassistant/components/api.py @@ -2,7 +2,7 @@ Rest API for Home Assistant. For more details about the RESTful API, please refer to the documentation at -https://home-assistant.io/developers/api/ +https://developers.home-assistant.io/docs/en/external_api_rest.html """ import asyncio import json @@ -11,31 +11,34 @@ from aiohttp import web import async_timeout -import homeassistant.core as ha -import homeassistant.remote as rem from homeassistant.bootstrap import DATA_LOGGING +from homeassistant.components.http import HomeAssistantView from homeassistant.const import ( - EVENT_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED, - HTTP_BAD_REQUEST, HTTP_CREATED, HTTP_NOT_FOUND, - MATCH_ALL, URL_API, URL_API_COMPONENTS, - URL_API_CONFIG, URL_API_DISCOVERY_INFO, URL_API_ERROR_LOG, - URL_API_EVENTS, URL_API_SERVICES, - URL_API_STATES, URL_API_STATES_ENTITY, URL_API_STREAM, URL_API_TEMPLATE, - __version__) + EVENT_HOMEASSISTANT_STOP, EVENT_TIME_CHANGED, HTTP_BAD_REQUEST, + HTTP_CREATED, HTTP_NOT_FOUND, MATCH_ALL, URL_API, URL_API_COMPONENTS, + URL_API_CONFIG, URL_API_DISCOVERY_INFO, URL_API_ERROR_LOG, URL_API_EVENTS, + URL_API_SERVICES, URL_API_STATES, URL_API_STATES_ENTITY, URL_API_STREAM, + URL_API_TEMPLATE, __version__) +import homeassistant.core as ha from homeassistant.exceptions import TemplateError -from homeassistant.helpers.state import AsyncTrackStates -from homeassistant.helpers.service import async_get_all_descriptions from homeassistant.helpers import template -from homeassistant.components.http import HomeAssistantView +from homeassistant.helpers.service import async_get_all_descriptions +from homeassistant.helpers.state import AsyncTrackStates +import homeassistant.remote as rem + +_LOGGER = logging.getLogger(__name__) + +ATTR_BASE_URL = 'base_url' +ATTR_LOCATION_NAME = 'location_name' +ATTR_REQUIRES_API_PASSWORD = 'requires_api_password' +ATTR_VERSION = 'version' DOMAIN = 'api' DEPENDENCIES = ['http'] -STREAM_PING_PAYLOAD = "ping" +STREAM_PING_PAYLOAD = 'ping' STREAM_PING_INTERVAL = 50 # seconds -_LOGGER = logging.getLogger(__name__) - def setup(hass, config): """Register the API with the HTTP interface.""" @@ -62,23 +65,22 @@ class APIStatusView(HomeAssistantView): """View to handle Status requests.""" url = URL_API - name = "api:status" + name = 'api:status' @ha.callback def get(self, request): """Retrieve if API is running.""" - return self.json_message('API running.') + return self.json_message("API running.") class APIEventStream(HomeAssistantView): """View to handle EventStream requests.""" url = URL_API_STREAM - name = "api:stream" + name = 'api:stream' async def get(self, request): """Provide a streaming interface for the event bus.""" - # pylint: disable=no-self-use hass = request.app['hass'] stop_obj = object() to_write = asyncio.Queue(loop=hass.loop) @@ -95,7 +97,7 @@ async def forward_events(event): if restrict and event.event_type not in restrict: return - _LOGGER.debug('STREAM %s FORWARDING %s', id(stop_obj), event) + _LOGGER.debug("STREAM %s FORWARDING %s", id(stop_obj), event) if event.event_type == EVENT_HOMEASSISTANT_STOP: data = stop_obj @@ -111,7 +113,7 @@ async def forward_events(event): unsub_stream = hass.bus.async_listen(MATCH_ALL, forward_events) try: - _LOGGER.debug('STREAM %s ATTACHED', id(stop_obj)) + _LOGGER.debug("STREAM %s ATTACHED", id(stop_obj)) # Fire off one message so browsers fire open event right away await to_write.put(STREAM_PING_PAYLOAD) @@ -126,25 +128,25 @@ async def forward_events(event): break msg = "data: {}\n\n".format(payload) - _LOGGER.debug('STREAM %s WRITING %s', id(stop_obj), - msg.strip()) - await response.write(msg.encode("UTF-8")) + _LOGGER.debug( + "STREAM %s WRITING %s", id(stop_obj), msg.strip()) + await response.write(msg.encode('UTF-8')) except asyncio.TimeoutError: await to_write.put(STREAM_PING_PAYLOAD) except asyncio.CancelledError: - _LOGGER.debug('STREAM %s ABORT', id(stop_obj)) + _LOGGER.debug("STREAM %s ABORT", id(stop_obj)) finally: - _LOGGER.debug('STREAM %s RESPONSE CLOSED', id(stop_obj)) + _LOGGER.debug("STREAM %s RESPONSE CLOSED", id(stop_obj)) unsub_stream() class APIConfigView(HomeAssistantView): - """View to handle Config requests.""" + """View to handle Configuration requests.""" url = URL_API_CONFIG - name = "api:config" + name = 'api:config' @ha.callback def get(self, request): @@ -153,22 +155,22 @@ def get(self, request): class APIDiscoveryView(HomeAssistantView): - """View to provide discovery info.""" + """View to provide Discovery information.""" requires_auth = False url = URL_API_DISCOVERY_INFO - name = "api:discovery" + name = 'api:discovery' @ha.callback def get(self, request): - """Get discovery info.""" + """Get discovery information.""" hass = request.app['hass'] needs_auth = hass.config.api.api_password is not None return self.json({ - 'base_url': hass.config.api.base_url, - 'location_name': hass.config.location_name, - 'requires_api_password': needs_auth, - 'version': __version__ + ATTR_BASE_URL: hass.config.api.base_url, + ATTR_LOCATION_NAME: hass.config.location_name, + ATTR_REQUIRES_API_PASSWORD: needs_auth, + ATTR_VERSION: __version__, }) @@ -187,8 +189,8 @@ def get(self, request): class APIEntityStateView(HomeAssistantView): """View to handle EntityState requests.""" - url = "/api/states/{entity_id}" - name = "api:entity-state" + url = '/api/states/{entity_id}' + name = 'api:entity-state' @ha.callback def get(self, request, entity_id): @@ -196,7 +198,7 @@ def get(self, request, entity_id): state = request.app['hass'].states.get(entity_id) if state: return self.json(state) - return self.json_message('Entity not found', HTTP_NOT_FOUND) + return self.json_message("Entity not found.", HTTP_NOT_FOUND) async def post(self, request, entity_id): """Update state of entity.""" @@ -204,13 +206,13 @@ async def post(self, request, entity_id): try: data = await request.json() except ValueError: - return self.json_message('Invalid JSON specified', - HTTP_BAD_REQUEST) + return self.json_message( + "Invalid JSON specified.", HTTP_BAD_REQUEST) new_state = data.get('state') if new_state is None: - return self.json_message('No state specified', HTTP_BAD_REQUEST) + return self.json_message("No state specified.", HTTP_BAD_REQUEST) attributes = data.get('attributes') force_update = data.get('force_update', False) @@ -218,7 +220,8 @@ async def post(self, request, entity_id): is_new_state = hass.states.get(entity_id) is None # Write state - hass.states.async_set(entity_id, new_state, attributes, force_update) + hass.states.async_set(entity_id, new_state, attributes, force_update, + self.context(request)) # Read the state back for our response status_code = HTTP_CREATED if is_new_state else 200 @@ -232,15 +235,15 @@ async def post(self, request, entity_id): def delete(self, request, entity_id): """Remove entity.""" if request.app['hass'].states.async_remove(entity_id): - return self.json_message('Entity removed') - return self.json_message('Entity not found', HTTP_NOT_FOUND) + return self.json_message("Entity removed.") + return self.json_message("Entity not found.", HTTP_NOT_FOUND) class APIEventListenersView(HomeAssistantView): """View to handle EventListeners requests.""" url = URL_API_EVENTS - name = "api:event-listeners" + name = 'api:event-listeners' @ha.callback def get(self, request): @@ -252,7 +255,7 @@ class APIEventView(HomeAssistantView): """View to handle Event requests.""" url = '/api/events/{event_type}' - name = "api:event" + name = 'api:event' async def post(self, request, event_type): """Fire events.""" @@ -260,12 +263,12 @@ async def post(self, request, event_type): try: event_data = json.loads(body) if body else None except ValueError: - return self.json_message('Event data should be valid JSON', - HTTP_BAD_REQUEST) + return self.json_message( + "Event data should be valid JSON.", HTTP_BAD_REQUEST) if event_data is not None and not isinstance(event_data, dict): - return self.json_message('Event data should be a JSON object', - HTTP_BAD_REQUEST) + return self.json_message( + "Event data should be a JSON object", HTTP_BAD_REQUEST) # Special case handling for event STATE_CHANGED # We will try to convert state dicts back to State objects @@ -276,8 +279,9 @@ async def post(self, request, event_type): if state: event_data[key] = state - request.app['hass'].bus.async_fire(event_type, event_data, - ha.EventOrigin.remote) + request.app['hass'].bus.async_fire( + event_type, event_data, ha.EventOrigin.remote, + self.context(request)) return self.json_message("Event {} fired.".format(event_type)) @@ -286,7 +290,7 @@ class APIServicesView(HomeAssistantView): """View to handle Services requests.""" url = URL_API_SERVICES - name = "api:services" + name = 'api:services' async def get(self, request): """Get registered services.""" @@ -297,8 +301,8 @@ async def get(self, request): class APIDomainServicesView(HomeAssistantView): """View to handle DomainServices requests.""" - url = "/api/services/{domain}/{service}" - name = "api:domain-services" + url = '/api/services/{domain}/{service}' + name = 'api:domain-services' async def post(self, request, domain, service): """Call a service. @@ -310,11 +314,12 @@ async def post(self, request, domain, service): try: data = json.loads(body) if body else None except ValueError: - return self.json_message('Data should be valid JSON', - HTTP_BAD_REQUEST) + return self.json_message( + "Data should be valid JSON.", HTTP_BAD_REQUEST) with AsyncTrackStates(hass) as changed_states: - await hass.services.async_call(domain, service, data, True) + await hass.services.async_call( + domain, service, data, True, self.context(request)) return self.json(changed_states) @@ -323,7 +328,7 @@ class APIComponentsView(HomeAssistantView): """View to handle Components requests.""" url = URL_API_COMPONENTS - name = "api:components" + name = 'api:components' @ha.callback def get(self, request): @@ -332,10 +337,10 @@ def get(self, request): class APITemplateView(HomeAssistantView): - """View to handle requests.""" + """View to handle Template requests.""" url = URL_API_TEMPLATE - name = "api:template" + name = 'api:template' async def post(self, request): """Render a template.""" @@ -344,29 +349,29 @@ async def post(self, request): tpl = template.Template(data['template'], request.app['hass']) return tpl.async_render(data.get('variables')) except (ValueError, TemplateError) as ex: - return self.json_message('Error rendering template: {}'.format(ex), - HTTP_BAD_REQUEST) + return self.json_message( + "Error rendering template: {}".format(ex), HTTP_BAD_REQUEST) class APIErrorLog(HomeAssistantView): - """View to fetch the error log.""" + """View to fetch the API error log.""" url = URL_API_ERROR_LOG - name = "api:error_log" + name = 'api:error_log' async def get(self, request): """Retrieve API error log.""" - return await self.file(request, request.app['hass'].data[DATA_LOGGING]) + return web.FileResponse(request.app['hass'].data[DATA_LOGGING]) async def async_services_json(hass): """Generate services data to JSONify.""" descriptions = await async_get_all_descriptions(hass) - return [{"domain": key, "services": value} + return [{'domain': key, 'services': value} for key, value in descriptions.items()] def async_events_json(hass): """Generate event data to JSONify.""" - return [{"event": key, "listener_count": value} + return [{'event': key, 'listener_count': value} for key, value in hass.bus.async_listeners().items()] diff --git a/homeassistant/components/apple_tv.py b/homeassistant/components/apple_tv.py index a9bd5c9c8bcfc8..97fb2363024fb2 100644 --- a/homeassistant/components/apple_tv.py +++ b/homeassistant/components/apple_tv.py @@ -17,7 +17,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyatv==0.3.9'] +REQUIREMENTS = ['pyatv==0.3.10'] _LOGGER = logging.getLogger(__name__) @@ -45,7 +45,7 @@ NOTIFICATION_SCAN_ID = 'apple_tv_scan_notification' NOTIFICATION_SCAN_TITLE = 'Apple TV Scan' -T = TypeVar('T') +T = TypeVar('T') # pylint: disable=invalid-name # This version of ensure_list interprets an empty dict as no value @@ -218,10 +218,10 @@ def _setup_atv(hass, atv_config): ATTR_POWER: power } - hass.async_add_job(discovery.async_load_platform( + hass.async_create_task(discovery.async_load_platform( hass, 'media_player', DOMAIN, atv_config)) - hass.async_add_job(discovery.async_load_platform( + hass.async_create_task(discovery.async_load_platform( hass, 'remote', DOMAIN, atv_config)) diff --git a/homeassistant/components/arduino.py b/homeassistant/components/arduino.py index 8625685c057dfe..785f8c57f943ea 100644 --- a/homeassistant/components/arduino.py +++ b/homeassistant/components/arduino.py @@ -62,7 +62,7 @@ def start_arduino(event): return True -class ArduinoBoard(object): +class ArduinoBoard: """Representation of an Arduino board.""" def __init__(self, port): diff --git a/homeassistant/components/arlo.py b/homeassistant/components/arlo.py index 7e51ec8c045e17..c6a414b9d91205 100644 --- a/homeassistant/components/arlo.py +++ b/homeassistant/components/arlo.py @@ -5,14 +5,18 @@ https://home-assistant.io/components/arlo/ """ import logging +from datetime import timedelta import voluptuous as vol from requests.exceptions import HTTPError, ConnectTimeout from homeassistant.helpers import config_validation as cv -from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD, CONF_SCAN_INTERVAL) +from homeassistant.helpers.event import track_time_interval +from homeassistant.helpers.dispatcher import dispatcher_send -REQUIREMENTS = ['pyarlo==0.1.2'] +REQUIREMENTS = ['pyarlo==0.2.0'] _LOGGER = logging.getLogger(__name__) @@ -25,10 +29,16 @@ NOTIFICATION_ID = 'arlo_notification' NOTIFICATION_TITLE = 'Arlo Component Setup' +SCAN_INTERVAL = timedelta(seconds=60) + +SIGNAL_UPDATE_ARLO = "arlo_update" + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): + cv.time_period, }), }, extra=vol.ALLOW_EXTRA) @@ -38,6 +48,7 @@ def setup(hass, config): conf = config[DOMAIN] username = conf.get(CONF_USERNAME) password = conf.get(CONF_PASSWORD) + scan_interval = conf.get(CONF_SCAN_INTERVAL) try: from pyarlo import PyArlo @@ -45,7 +56,17 @@ def setup(hass, config): arlo = PyArlo(username, password, preload=False) if not arlo.is_connected: return False + + # assign refresh period to base station thread + arlo_base_station = next(( + station for station in arlo.base_stations), None) + + if arlo_base_station is None: + return False + + arlo_base_station.refresh_rate = scan_interval.total_seconds() hass.data[DATA_ARLO] = arlo + except (ConnectTimeout, HTTPError) as ex: _LOGGER.error("Unable to connect to Netgear Arlo: %s", str(ex)) hass.components.persistent_notification.create( @@ -55,4 +76,17 @@ def setup(hass, config): title=NOTIFICATION_TITLE, notification_id=NOTIFICATION_ID) return False + + def hub_refresh(event_time): + """Call ArloHub to refresh information.""" + _LOGGER.info("Updating Arlo Hub component") + hass.data[DATA_ARLO].update(update_cameras=True, + update_base_station=True) + dispatcher_send(hass, SIGNAL_UPDATE_ARLO) + + # register service + hass.services.register(DOMAIN, 'update', hub_refresh) + + # register scan interval for ArloHub + track_time_interval(hass, hub_refresh, scan_interval) return True diff --git a/homeassistant/components/asterisk_mbox.py b/homeassistant/components/asterisk_mbox.py index 0b5e7c1e1d7831..e273d7d6f6a54e 100644 --- a/homeassistant/components/asterisk_mbox.py +++ b/homeassistant/components/asterisk_mbox.py @@ -48,7 +48,7 @@ def setup(hass, config): return True -class AsteriskData(object): +class AsteriskData: """Store Asterisk mailbox data.""" def __init__(self, hass, host, port, password): diff --git a/homeassistant/components/august.py b/homeassistant/components/august.py index 2a7da86c6cf01d..5f268a95f5dcc9 100644 --- a/homeassistant/components/august.py +++ b/homeassistant/components/august.py @@ -21,7 +21,7 @@ _CONFIGURING = {} -REQUIREMENTS = ['py-august==0.4.0'] +REQUIREMENTS = ['py-august==0.6.0'] DEFAULT_TIMEOUT = 10 ACTIVITY_FETCH_LIMIT = 10 @@ -123,9 +123,9 @@ def setup_august(hass, config, api, authenticator): discovery.load_platform(hass, component, DOMAIN, {}, config) return True - elif state == AuthenticationState.BAD_PASSWORD: + if state == AuthenticationState.BAD_PASSWORD: return False - elif state == AuthenticationState.REQUIRES_VALIDATION: + if state == AuthenticationState.REQUIRES_VALIDATION: request_configuration(hass, config, api, authenticator) return True diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index d4b4b0f45911d8..102bfe58b55a69 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -1,62 +1,5 @@ """Component to allow users to login and get tokens. -All requests will require passing in a valid client ID and secret via HTTP -Basic Auth. - -# GET /auth/providers - -Return a list of auth providers. Example: - -[ - { - "name": "Local", - "id": null, - "type": "local_provider", - } -] - -# POST /auth/login_flow - -Create a login flow. Will return the first step of the flow. - -Pass in parameter 'handler' to specify the auth provider to use. Auth providers -are identified by type and id. - -{ - "handler": ["local_provider", null] -} - -Return value will be a step in a data entry flow. See the docs for data entry -flow for details. - -{ - "data_schema": [ - {"name": "username", "type": "string"}, - {"name": "password", "type": "string"} - ], - "errors": {}, - "flow_id": "8f7e42faab604bcab7ac43c44ca34d58", - "handler": ["insecure_example", null], - "step_id": "init", - "type": "form" -} - -# POST /auth/login_flow/{flow_id} - -Progress the flow. Most flows will be 1 page, but could optionally add extra -login challenges, like TFA. Once the flow has finished, the returned step will -have type "create_entry" and "result" key will contain an authorization code. - -{ - "flow_id": "8f7e42faab604bcab7ac43c44ca34d58", - "handler": ["insecure_example", null], - "result": "411ee2f916e648d691e937ae9344681e", - "source": "user", - "title": "Example", - "type": "create_entry", - "version": 1 -} - # POST /auth/token This is an OAuth2 endpoint for granting tokens. We currently support the grant @@ -104,21 +47,27 @@ """ import logging import uuid +from datetime import timedelta -import aiohttp.web import voluptuous as vol -from homeassistant import data_entry_flow -from homeassistant.core import callback -from homeassistant.helpers.data_entry_flow import ( - FlowManagerIndexView, FlowManagerResourceView) -from homeassistant.components.http.view import HomeAssistantView +from homeassistant.components import websocket_api +from homeassistant.components.http.ban import log_invalid_auth from homeassistant.components.http.data_validator import RequestDataValidator - -from .client import verify_client +from homeassistant.components.http.view import HomeAssistantView +from homeassistant.core import callback +from homeassistant.util import dt as dt_util +from . import indieauth +from . import login_flow DOMAIN = 'auth' DEPENDENCIES = ['http'] + +WS_TYPE_CURRENT_USER = 'auth/current_user' +SCHEMA_WS_CURRENT_USER = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_CURRENT_USER, +}) + _LOGGER = logging.getLogger(__name__) @@ -126,88 +75,17 @@ async def async_setup(hass, config): """Component to allow users to login.""" store_credentials, retrieve_credentials = _create_cred_store() - hass.http.register_view(AuthProvidersView) - hass.http.register_view(LoginFlowIndexView(hass.auth.login_flow)) - hass.http.register_view( - LoginFlowResourceView(hass.auth.login_flow, store_credentials)) hass.http.register_view(GrantTokenView(retrieve_credentials)) hass.http.register_view(LinkUserView(retrieve_credentials)) - return True - - -class AuthProvidersView(HomeAssistantView): - """View to get available auth providers.""" - - url = '/auth/providers' - name = 'api:auth:providers' - requires_auth = False - - @verify_client - async def get(self, request, client_id): - """Get available auth providers.""" - return self.json([{ - 'name': provider.name, - 'id': provider.id, - 'type': provider.type, - } for provider in request.app['hass'].auth.async_auth_providers]) - - -class LoginFlowIndexView(FlowManagerIndexView): - """View to create a config flow.""" - - url = '/auth/login_flow' - name = 'api:auth:login_flow' - requires_auth = False + hass.components.websocket_api.async_register_command( + WS_TYPE_CURRENT_USER, websocket_current_user, + SCHEMA_WS_CURRENT_USER + ) - async def get(self, request): - """Do not allow index of flows in progress.""" - return aiohttp.web.Response(status=405) + await login_flow.async_setup(hass, store_credentials) - # pylint: disable=arguments-differ - @verify_client - async def post(self, request, client_id): - """Create a new login flow.""" - # pylint: disable=no-value-for-parameter - return await super().post(request) - - -class LoginFlowResourceView(FlowManagerResourceView): - """View to interact with the flow manager.""" - - url = '/auth/login_flow/{flow_id}' - name = 'api:auth:login_flow:resource' - requires_auth = False - - def __init__(self, flow_mgr, store_credentials): - """Initialize the login flow resource view.""" - super().__init__(flow_mgr) - self._store_credentials = store_credentials - - # pylint: disable=arguments-differ - async def get(self, request): - """Do not allow getting status of a flow in progress.""" - return self.json_message('Invalid flow specified', 404) - - # pylint: disable=arguments-differ - @verify_client - @RequestDataValidator(vol.Schema(dict), allow_empty=True) - async def post(self, request, client_id, flow_id, data): - """Handle progressing a login flow request.""" - try: - result = await self._flow_mgr.async_configure(flow_id, data) - except data_entry_flow.UnknownFlow: - return self.json_message('Invalid flow specified', 404) - except vol.Invalid: - return self.json_message('User input malformed', 400) - - if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: - return self.json(self._prepare_result_json(result)) - - result.pop('data') - result['result'] = self._store_credentials(client_id, result['result']) - - return self.json(result) + return True class GrantTokenView(HomeAssistantView): @@ -216,32 +94,39 @@ class GrantTokenView(HomeAssistantView): url = '/auth/token' name = 'api:auth:token' requires_auth = False + cors_allowed = True def __init__(self, retrieve_credentials): """Initialize the grant token view.""" self._retrieve_credentials = retrieve_credentials - @verify_client - async def post(self, request, client_id): + @log_invalid_auth + async def post(self, request): """Grant a token.""" hass = request.app['hass'] data = await request.post() + grant_type = data.get('grant_type') if grant_type == 'authorization_code': - return await self._async_handle_auth_code( - hass, client_id, data) + return await self._async_handle_auth_code(hass, data) - elif grant_type == 'refresh_token': - return await self._async_handle_refresh_token( - hass, client_id, data) + if grant_type == 'refresh_token': + return await self._async_handle_refresh_token(hass, data) return self.json({ 'error': 'unsupported_grant_type', }, status_code=400) - async def _async_handle_auth_code(self, hass, client_id, data): + async def _async_handle_auth_code(self, hass, data): """Handle authorization code request.""" + client_id = data.get('client_id') + if client_id is None or not indieauth.verify_client_id(client_id): + return self.json({ + 'error': 'invalid_request', + 'error_description': 'Invalid client id', + }, status_code=400) + code = data.get('code') if code is None: @@ -254,23 +139,38 @@ async def _async_handle_auth_code(self, hass, client_id, data): if credentials is None: return self.json({ 'error': 'invalid_request', + 'error_description': 'Invalid code', }, status_code=400) user = await hass.auth.async_get_or_create_user(credentials) + + if not user.is_active: + return self.json({ + 'error': 'access_denied', + 'error_description': 'User is not active', + }, status_code=403) + refresh_token = await hass.auth.async_create_refresh_token(user, client_id) access_token = hass.auth.async_create_access_token(refresh_token) return self.json({ - 'access_token': access_token.token, + 'access_token': access_token, 'token_type': 'Bearer', 'refresh_token': refresh_token.token, 'expires_in': int(refresh_token.access_token_expiration.total_seconds()), }) - async def _async_handle_refresh_token(self, hass, client_id, data): + async def _async_handle_refresh_token(self, hass, data): """Handle authorization code request.""" + client_id = data.get('client_id') + if client_id is not None and not indieauth.verify_client_id(client_id): + return self.json({ + 'error': 'invalid_request', + 'error_description': 'Invalid client id', + }, status_code=400) + token = data.get('refresh_token') if token is None: @@ -278,17 +178,22 @@ async def _async_handle_refresh_token(self, hass, client_id, data): 'error': 'invalid_request', }, status_code=400) - refresh_token = await hass.auth.async_get_refresh_token(token) + refresh_token = await hass.auth.async_get_refresh_token_by_token(token) - if refresh_token is None or refresh_token.client_id != client_id: + if refresh_token is None: return self.json({ 'error': 'invalid_grant', }, status_code=400) + if refresh_token.client_id != client_id: + return self.json({ + 'error': 'invalid_request', + }, status_code=400) + access_token = hass.auth.async_create_access_token(refresh_token) return self.json({ - 'access_token': access_token.token, + 'access_token': access_token, 'token_type': 'Bearer', 'expires_in': int(refresh_token.access_token_expiration.total_seconds()), @@ -333,12 +238,46 @@ def _create_cred_store(): def store_credentials(client_id, credentials): """Store credentials and return a code to retrieve it.""" code = uuid.uuid4().hex - temp_credentials[(client_id, code)] = credentials + temp_credentials[(client_id, code)] = (dt_util.utcnow(), credentials) return code @callback def retrieve_credentials(client_id, code): """Retrieve credentials.""" - return temp_credentials.pop((client_id, code), None) + key = (client_id, code) + + if key not in temp_credentials: + return None + + created, credentials = temp_credentials.pop(key) + + # OAuth 4.2.1 + # The authorization code MUST expire shortly after it is issued to + # mitigate the risk of leaks. A maximum authorization code lifetime of + # 10 minutes is RECOMMENDED. + if dt_util.utcnow() - created < timedelta(minutes=10): + return credentials + + return None return store_credentials, retrieve_credentials + + +@callback +def websocket_current_user(hass, connection, msg): + """Return the current user.""" + user = connection.request.get('hass_user') + + if user is None: + connection.to_write.put_nowait(websocket_api.error_message( + msg['id'], 'no_user', 'Not authenticated as a user')) + return + + connection.to_write.put_nowait(websocket_api.result_message(msg['id'], { + 'id': user.id, + 'name': user.name, + 'is_owner': user.is_owner, + 'credentials': [{'auth_provider_type': c.auth_provider_type, + 'auth_provider_id': c.auth_provider_id} + for c in user.credentials] + })) diff --git a/homeassistant/components/auth/client.py b/homeassistant/components/auth/client.py deleted file mode 100644 index 28d72aefe0fada..00000000000000 --- a/homeassistant/components/auth/client.py +++ /dev/null @@ -1,63 +0,0 @@ -"""Helpers to resolve client ID/secret.""" -import base64 -from functools import wraps -import hmac - -import aiohttp.hdrs - - -def verify_client(method): - """Decorator to verify client id/secret on requests.""" - @wraps(method) - async def wrapper(view, request, *args, **kwargs): - """Verify client id/secret before doing request.""" - client_id = await _verify_client(request) - - if client_id is None: - return view.json({ - 'error': 'invalid_client', - }, status_code=401) - - return await method( - view, request, *args, client_id=client_id, **kwargs) - - return wrapper - - -async def _verify_client(request): - """Method to verify the client id/secret in consistent time. - - By using a consistent time for looking up client id and comparing the - secret, we prevent attacks by malicious actors trying different client ids - and are able to derive from the time it takes to process the request if - they guessed the client id correctly. - """ - if aiohttp.hdrs.AUTHORIZATION not in request.headers: - return None - - auth_type, auth_value = \ - request.headers.get(aiohttp.hdrs.AUTHORIZATION).split(' ', 1) - - if auth_type != 'Basic': - return None - - decoded = base64.b64decode(auth_value).decode('utf-8') - try: - client_id, client_secret = decoded.split(':', 1) - except ValueError: - # If no ':' in decoded - return None - - client = await request.app['hass'].auth.async_get_client(client_id) - - if client is None: - # Still do a compare so we run same time as if a client was found. - hmac.compare_digest(client_secret.encode('utf-8'), - client_secret.encode('utf-8')) - return None - - if hmac.compare_digest(client_secret.encode('utf-8'), - client.secret.encode('utf-8')): - return client_id - - return None diff --git a/homeassistant/components/auth/indieauth.py b/homeassistant/components/auth/indieauth.py new file mode 100644 index 00000000000000..48f7ab06ab4933 --- /dev/null +++ b/homeassistant/components/auth/indieauth.py @@ -0,0 +1,193 @@ +"""Helpers to resolve client ID/secret.""" +import asyncio +from html.parser import HTMLParser +from ipaddress import ip_address, ip_network +from urllib.parse import urlparse, urljoin + +from aiohttp.client_exceptions import ClientError + +# IP addresses of loopback interfaces +ALLOWED_IPS = ( + ip_address('127.0.0.1'), + ip_address('::1'), +) + +# RFC1918 - Address allocation for Private Internets +ALLOWED_NETWORKS = ( + ip_network('10.0.0.0/8'), + ip_network('172.16.0.0/12'), + ip_network('192.168.0.0/16'), +) + + +async def verify_redirect_uri(hass, client_id, redirect_uri): + """Verify that the client and redirect uri match.""" + try: + client_id_parts = _parse_client_id(client_id) + except ValueError: + return False + + redirect_parts = _parse_url(redirect_uri) + + # Verify redirect url and client url have same scheme and domain. + is_valid = ( + client_id_parts.scheme == redirect_parts.scheme and + client_id_parts.netloc == redirect_parts.netloc + ) + + if is_valid: + return True + + # IndieAuth 4.2.2 allows for redirect_uri to be on different domain + # but needs to be specified in link tag when fetching `client_id`. + redirect_uris = await fetch_redirect_uris(hass, client_id) + return redirect_uri in redirect_uris + + +class LinkTagParser(HTMLParser): + """Parser to find link tags.""" + + def __init__(self, rel): + """Initialize a link tag parser.""" + super().__init__() + self.rel = rel + self.found = [] + + def handle_starttag(self, tag, attrs): + """Handle finding a start tag.""" + if tag != 'link': + return + + attrs = dict(attrs) + + if attrs.get('rel') == self.rel: + self.found.append(attrs.get('href')) + + +async def fetch_redirect_uris(hass, url): + """Find link tag with redirect_uri values. + + IndieAuth 4.2.2 + + The client SHOULD publish one or more tags or Link HTTP headers with + a rel attribute of redirect_uri at the client_id URL. + + We limit to the first 10kB of the page. + + We do not implement extracting redirect uris from headers. + """ + session = hass.helpers.aiohttp_client.async_get_clientsession() + parser = LinkTagParser('redirect_uri') + chunks = 0 + try: + resp = await session.get(url, timeout=5) + + async for data in resp.content.iter_chunked(1024): + parser.feed(data.decode()) + chunks += 1 + + if chunks == 10: + break + + except (asyncio.TimeoutError, ClientError): + pass + + # Authorization endpoints verifying that a redirect_uri is allowed for use + # by a client MUST look for an exact match of the given redirect_uri in the + # request against the list of redirect_uris discovered after resolving any + # relative URLs. + return [urljoin(url, found) for found in parser.found] + + +def verify_client_id(client_id): + """Verify that the client id is valid.""" + try: + _parse_client_id(client_id) + return True + except ValueError: + return False + + +def _parse_url(url): + """Parse a url in parts and canonicalize according to IndieAuth.""" + parts = urlparse(url) + + # Canonicalize a url according to IndieAuth 3.2. + + # SHOULD convert the hostname to lowercase + parts = parts._replace(netloc=parts.netloc.lower()) + + # If a URL with no path component is ever encountered, + # it MUST be treated as if it had the path /. + if parts.path == '': + parts = parts._replace(path='/') + + return parts + + +def _parse_client_id(client_id): + """Test if client id is a valid URL according to IndieAuth section 3.2. + + https://indieauth.spec.indieweb.org/#client-identifier + """ + parts = _parse_url(client_id) + + # Client identifier URLs + # MUST have either an https or http scheme + if parts.scheme not in ('http', 'https'): + raise ValueError() + + # MUST contain a path component + # Handled by url canonicalization. + + # MUST NOT contain single-dot or double-dot path segments + if any(segment in ('.', '..') for segment in parts.path.split('/')): + raise ValueError( + 'Client ID cannot contain single-dot or double-dot path segments') + + # MUST NOT contain a fragment component + if parts.fragment != '': + raise ValueError('Client ID cannot contain a fragment') + + # MUST NOT contain a username or password component + if parts.username is not None: + raise ValueError('Client ID cannot contain username') + + if parts.password is not None: + raise ValueError('Client ID cannot contain password') + + # MAY contain a port + try: + # parts raises ValueError when port cannot be parsed as int + parts.port + except ValueError: + raise ValueError('Client ID contains invalid port') + + # Additionally, hostnames + # MUST be domain names or a loopback interface and + # MUST NOT be IPv4 or IPv6 addresses except for IPv4 127.0.0.1 + # or IPv6 [::1] + + # We are not goint to follow the spec here. We are going to allow + # any internal network IP to be used inside a client id. + + address = None + + try: + netloc = parts.netloc + + # Strip the [, ] from ipv6 addresses before parsing + if netloc[0] == '[' and netloc[-1] == ']': + netloc = netloc[1:-1] + + address = ip_address(netloc) + except ValueError: + # Not an ip address + pass + + if (address is None or + address in ALLOWED_IPS or + any(address in network for network in ALLOWED_NETWORKS)): + return parts + + raise ValueError('Hostname should be a domain name or local IP address') diff --git a/homeassistant/components/auth/login_flow.py b/homeassistant/components/auth/login_flow.py new file mode 100644 index 00000000000000..7b80e52a8d712d --- /dev/null +++ b/homeassistant/components/auth/login_flow.py @@ -0,0 +1,217 @@ +"""HTTP views handle login flow. + +# GET /auth/providers + +Return a list of auth providers. Example: + +[ + { + "name": "Local", + "id": null, + "type": "local_provider", + } +] + + +# POST /auth/login_flow + +Create a login flow. Will return the first step of the flow. + +Pass in parameter 'client_id' and 'redirect_url' validate by indieauth. + +Pass in parameter 'handler' to specify the auth provider to use. Auth providers +are identified by type and id. + +{ + "client_id": "https://hassbian.local:8123/", + "handler": ["local_provider", null], + "redirect_url": "https://hassbian.local:8123/" +} + +Return value will be a step in a data entry flow. See the docs for data entry +flow for details. + +{ + "data_schema": [ + {"name": "username", "type": "string"}, + {"name": "password", "type": "string"} + ], + "errors": {}, + "flow_id": "8f7e42faab604bcab7ac43c44ca34d58", + "handler": ["insecure_example", null], + "step_id": "init", + "type": "form" +} + + +# POST /auth/login_flow/{flow_id} + +Progress the flow. Most flows will be 1 page, but could optionally add extra +login challenges, like TFA. Once the flow has finished, the returned step will +have type "create_entry" and "result" key will contain an authorization code. + +{ + "flow_id": "8f7e42faab604bcab7ac43c44ca34d58", + "handler": ["insecure_example", null], + "result": "411ee2f916e648d691e937ae9344681e", + "title": "Example", + "type": "create_entry", + "version": 1 +} +""" +import aiohttp.web +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.components.http.ban import process_wrong_login, \ + log_invalid_auth +from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.components.http.view import HomeAssistantView +from . import indieauth + + +async def async_setup(hass, store_credentials): + """Component to allow users to login.""" + hass.http.register_view(AuthProvidersView) + hass.http.register_view(LoginFlowIndexView(hass.auth.login_flow)) + hass.http.register_view( + LoginFlowResourceView(hass.auth.login_flow, store_credentials)) + + +class AuthProvidersView(HomeAssistantView): + """View to get available auth providers.""" + + url = '/auth/providers' + name = 'api:auth:providers' + requires_auth = False + + async def get(self, request): + """Get available auth providers.""" + return self.json([{ + 'name': provider.name, + 'id': provider.id, + 'type': provider.type, + } for provider in request.app['hass'].auth.auth_providers]) + + +def _prepare_result_json(result): + """Convert result to JSON.""" + if result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + data = result.copy() + data.pop('result') + data.pop('data') + return data + + if result['type'] != data_entry_flow.RESULT_TYPE_FORM: + return result + + import voluptuous_serialize + + data = result.copy() + + schema = data['data_schema'] + if schema is None: + data['data_schema'] = [] + else: + data['data_schema'] = voluptuous_serialize.convert(schema) + + return data + + +class LoginFlowIndexView(HomeAssistantView): + """View to create a config flow.""" + + url = '/auth/login_flow' + name = 'api:auth:login_flow' + requires_auth = False + + def __init__(self, flow_mgr): + """Initialize the flow manager index view.""" + self._flow_mgr = flow_mgr + + async def get(self, request): + """Do not allow index of flows in progress.""" + return aiohttp.web.Response(status=405) + + @RequestDataValidator(vol.Schema({ + vol.Required('client_id'): str, + vol.Required('handler'): vol.Any(str, list), + vol.Required('redirect_uri'): str, + })) + @log_invalid_auth + async def post(self, request, data): + """Create a new login flow.""" + if not await indieauth.verify_redirect_uri( + request.app['hass'], data['client_id'], data['redirect_uri']): + return self.json_message('invalid client id or redirect uri', 400) + + if isinstance(data['handler'], list): + handler = tuple(data['handler']) + else: + handler = data['handler'] + + try: + result = await self._flow_mgr.async_init(handler, context={}) + except data_entry_flow.UnknownHandler: + return self.json_message('Invalid handler specified', 404) + except data_entry_flow.UnknownStep: + return self.json_message('Handler does not support init', 400) + + return self.json(_prepare_result_json(result)) + + +class LoginFlowResourceView(HomeAssistantView): + """View to interact with the flow manager.""" + + url = '/auth/login_flow/{flow_id}' + name = 'api:auth:login_flow:resource' + requires_auth = False + + def __init__(self, flow_mgr, store_credentials): + """Initialize the login flow resource view.""" + self._flow_mgr = flow_mgr + self._store_credentials = store_credentials + + async def get(self, request): + """Do not allow getting status of a flow in progress.""" + return self.json_message('Invalid flow specified', 404) + + @RequestDataValidator(vol.Schema({ + 'client_id': str + }, extra=vol.ALLOW_EXTRA)) + @log_invalid_auth + async def post(self, request, flow_id, data): + """Handle progressing a login flow request.""" + client_id = data.pop('client_id') + + if not indieauth.verify_client_id(client_id): + return self.json_message('Invalid client id', 400) + + try: + result = await self._flow_mgr.async_configure(flow_id, data) + except data_entry_flow.UnknownFlow: + return self.json_message('Invalid flow specified', 404) + except vol.Invalid: + return self.json_message('User input malformed', 400) + + if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + # @log_invalid_auth does not work here since it returns HTTP 200 + # need manually log failed login attempts + if result['errors'] is not None and \ + result['errors'].get('base') == 'invalid_auth': + await process_wrong_login(request) + return self.json(_prepare_result_json(result)) + + result.pop('data') + result['result'] = self._store_credentials(client_id, result['result']) + + return self.json(result) + + async def delete(self, request, flow_id): + """Cancel a flow in progress.""" + try: + self._flow_mgr.async_abort(flow_id) + except data_entry_flow.UnknownFlow: + return self.json_message('Invalid flow specified', 404) + + return self.json_message('Flow aborted') diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 2f510fd33d6db1..8b1cd3cad84596 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -98,7 +98,7 @@ def _platform_validator(config): }) TRIGGER_SERVICE_SCHEMA = vol.Schema({ - vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, vol.Optional(ATTR_VARIABLES, default={}): dict, }) @@ -297,7 +297,7 @@ def async_added_to_hass(self) -> None: return # HomeAssistant is starting up - elif self.hass.state == CoreState.not_running: + if self.hass.state == CoreState.not_running: @asyncio.coroutine def async_enable_automation(event): """Start automation on startup.""" diff --git a/homeassistant/components/automation/homeassistant.py b/homeassistant/components/automation/homeassistant.py index 6b8ee577a09d14..74cf195bc61bda 100644 --- a/homeassistant/components/automation/homeassistant.py +++ b/homeassistant/components/automation/homeassistant.py @@ -44,7 +44,7 @@ def hass_shutdown(event): # Automation are enabled while hass is starting up, fire right away # Check state because a config reload shouldn't trigger it. - elif hass.state == CoreState.starting: + if hass.state == CoreState.starting: hass.async_run_job(action, { 'trigger': { 'platform': 'homeassistant', diff --git a/homeassistant/components/automation/mqtt.py b/homeassistant/components/automation/mqtt.py index 172a368225d696..60c33ca9b0ef6e 100644 --- a/homeassistant/components/automation/mqtt.py +++ b/homeassistant/components/automation/mqtt.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.core import callback -import homeassistant.components.mqtt as mqtt +from homeassistant.components import mqtt from homeassistant.const import (CONF_PLATFORM, CONF_PAYLOAD) import homeassistant.helpers.config_validation as cv diff --git a/homeassistant/components/axis.py b/homeassistant/components/axis.py index fab7d98ed98399..71894364f91440 100644 --- a/homeassistant/components/axis.py +++ b/homeassistant/components/axis.py @@ -145,7 +145,7 @@ def configuration_callback(callback_data): def setup(hass, config): """Set up for Axis devices.""" - def _shutdown(call): # pylint: disable=unused-argument + def _shutdown(call): """Stop the event stream on shutdown.""" for serialnumber, device in AXIS_DEVICES.items(): _LOGGER.info("Stopping event stream for %s.", serialnumber) @@ -272,8 +272,7 @@ def __init__(self, event_config): def _update_callback(self): """Update the sensor's state, if needed.""" - self.update() - self.schedule_update_ha_state() + self.schedule_update_ha_state(True) @property def name(self): diff --git a/homeassistant/components/bbb_gpio.py b/homeassistant/components/bbb_gpio.py index 5d3954b4c87e21..e3f327f1d5cf1d 100644 --- a/homeassistant/components/bbb_gpio.py +++ b/homeassistant/components/bbb_gpio.py @@ -16,11 +16,10 @@ DOMAIN = 'bbb_gpio' -# pylint: disable=no-member def setup(hass, config): """Set up the BeagleBone Black GPIO component.""" # pylint: disable=import-error - import Adafruit_BBIO.GPIO as GPIO + from Adafruit_BBIO import GPIO def cleanup_gpio(event): """Stuff to do before stopping.""" @@ -34,41 +33,39 @@ def prepare_gpio(event): return True -# noqa: F821 - def setup_output(pin): """Set up a GPIO as output.""" - # pylint: disable=import-error,undefined-variable - import Adafruit_BBIO.GPIO as GPIO + # pylint: disable=import-error + from Adafruit_BBIO import GPIO GPIO.setup(pin, GPIO.OUT) def setup_input(pin, pull_mode): """Set up a GPIO as input.""" - # pylint: disable=import-error,undefined-variable - import Adafruit_BBIO.GPIO as GPIO - GPIO.setup(pin, GPIO.IN, # noqa: F821 - GPIO.PUD_DOWN if pull_mode == 'DOWN' # noqa: F821 - else GPIO.PUD_UP) # noqa: F821 + # pylint: disable=import-error + from Adafruit_BBIO import GPIO + GPIO.setup(pin, GPIO.IN, + GPIO.PUD_DOWN if pull_mode == 'DOWN' + else GPIO.PUD_UP) def write_output(pin, value): """Write a value to a GPIO.""" - # pylint: disable=import-error,undefined-variable - import Adafruit_BBIO.GPIO as GPIO + # pylint: disable=import-error + from Adafruit_BBIO import GPIO GPIO.output(pin, value) def read_input(pin): """Read a value from a GPIO.""" - # pylint: disable=import-error,undefined-variable - import Adafruit_BBIO.GPIO as GPIO + # pylint: disable=import-error + from Adafruit_BBIO import GPIO return GPIO.input(pin) is GPIO.HIGH def edge_detect(pin, event_callback, bounce): """Add detection for RISING and FALLING events.""" - # pylint: disable=import-error,undefined-variable - import Adafruit_BBIO.GPIO as GPIO + # pylint: disable=import-error + from Adafruit_BBIO import GPIO GPIO.add_event_detect( pin, GPIO.BOTH, callback=event_callback, bouncetime=bounce) diff --git a/homeassistant/components/binary_sensor/__init__.py b/homeassistant/components/binary_sensor/__init__.py index d72211d5ad1e1e..26878044fe28ad 100644 --- a/homeassistant/components/binary_sensor/__init__.py +++ b/homeassistant/components/binary_sensor/__init__.py @@ -67,7 +67,6 @@ async def async_unload_entry(hass, entry): return await hass.data[DOMAIN].async_unload_entry(entry) -# pylint: disable=no-self-use class BinarySensorDevice(Entity): """Represent a binary sensor.""" diff --git a/homeassistant/components/binary_sensor/alarmdecoder.py b/homeassistant/components/binary_sensor/alarmdecoder.py index f0c8ec2d97ce48..fcc77d474e1941 100644 --- a/homeassistant/components/binary_sensor/alarmdecoder.py +++ b/homeassistant/components/binary_sensor/alarmdecoder.py @@ -11,7 +11,8 @@ from homeassistant.components.alarmdecoder import ( ZONE_SCHEMA, CONF_ZONES, CONF_ZONE_NAME, CONF_ZONE_TYPE, CONF_ZONE_RFID, SIGNAL_ZONE_FAULT, SIGNAL_ZONE_RESTORE, - SIGNAL_RFX_MESSAGE) + SIGNAL_RFX_MESSAGE, SIGNAL_REL_MESSAGE, CONF_RELAY_ADDR, + CONF_RELAY_CHAN) DEPENDENCIES = ['alarmdecoder'] @@ -37,8 +38,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): zone_type = device_config_data[CONF_ZONE_TYPE] zone_name = device_config_data[CONF_ZONE_NAME] zone_rfid = device_config_data.get(CONF_ZONE_RFID) + relay_addr = device_config_data.get(CONF_RELAY_ADDR) + relay_chan = device_config_data.get(CONF_RELAY_CHAN) device = AlarmDecoderBinarySensor( - zone_num, zone_name, zone_type, zone_rfid) + zone_num, zone_name, zone_type, zone_rfid, relay_addr, relay_chan) devices.append(device) add_devices(devices) @@ -49,7 +52,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class AlarmDecoderBinarySensor(BinarySensorDevice): """Representation of an AlarmDecoder binary sensor.""" - def __init__(self, zone_number, zone_name, zone_type, zone_rfid): + def __init__(self, zone_number, zone_name, zone_type, zone_rfid, + relay_addr, relay_chan): """Initialize the binary_sensor.""" self._zone_number = zone_number self._zone_type = zone_type @@ -57,6 +61,8 @@ def __init__(self, zone_number, zone_name, zone_type, zone_rfid): self._name = zone_name self._rfid = zone_rfid self._rfstate = None + self._relay_addr = relay_addr + self._relay_chan = relay_chan @asyncio.coroutine def async_added_to_hass(self): @@ -70,6 +76,9 @@ def async_added_to_hass(self): self.hass.helpers.dispatcher.async_dispatcher_connect( SIGNAL_RFX_MESSAGE, self._rfx_message_callback) + self.hass.helpers.dispatcher.async_dispatcher_connect( + SIGNAL_REL_MESSAGE, self._rel_message_callback) + @property def name(self): """Return the name of the entity.""" @@ -122,3 +131,12 @@ def _rfx_message_callback(self, message): if self._rfid and message and message.serial_number == self._rfid: self._rfstate = message.value self.schedule_update_ha_state() + + def _rel_message_callback(self, message): + """Update relay state.""" + if (self._relay_addr == message.address and + self._relay_chan == message.channel): + _LOGGER.debug("Relay %d:%d value:%d", message.address, + message.channel, message.value) + self._state = message.value + self.schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/arest.py b/homeassistant/components/binary_sensor/arest.py index 73751ef14bb00a..0366f753ba6c49 100644 --- a/homeassistant/components/binary_sensor/arest.py +++ b/homeassistant/components/binary_sensor/arest.py @@ -89,7 +89,7 @@ def update(self): self.arest.update() -class ArestData(object): +class ArestData: """Class for handling the data retrieval for pins.""" def __init__(self, resource, pin): diff --git a/homeassistant/components/binary_sensor/aurora.py b/homeassistant/components/binary_sensor/aurora.py index 772792f5785a15..0c33877854fd98 100644 --- a/homeassistant/components/binary_sensor/aurora.py +++ b/homeassistant/components/binary_sensor/aurora.py @@ -99,7 +99,7 @@ def update(self): self.aurora_data.update() -class AuroraData(object): +class AuroraData: """Get aurora forecast.""" def __init__(self, latitude, longitude, threshold): diff --git a/homeassistant/components/binary_sensor/bayesian.py b/homeassistant/components/binary_sensor/bayesian.py index f3dbc912ade120..75906e8ac5d58c 100644 --- a/homeassistant/components/binary_sensor/bayesian.py +++ b/homeassistant/components/binary_sensor/bayesian.py @@ -122,7 +122,6 @@ def __init__(self, name, prior, observations, probability_threshold, def async_added_to_hass(self): """Call when entity about to be added.""" @callback - # pylint: disable=invalid-name def async_threshold_sensor_state_listener(entity, old_state, new_state): """Handle sensor state changes.""" @@ -217,4 +216,4 @@ def device_state_attributes(self): @asyncio.coroutine def async_update(self): """Get the latest data and update the states.""" - self._deviation = bool(self.probability > self._probability_threshold) + self._deviation = bool(self.probability >= self._probability_threshold) diff --git a/homeassistant/components/binary_sensor/bbb_gpio.py b/homeassistant/components/binary_sensor/bbb_gpio.py index 785b178969f23a..690d1651db9f17 100644 --- a/homeassistant/components/binary_sensor/bbb_gpio.py +++ b/homeassistant/components/binary_sensor/bbb_gpio.py @@ -8,7 +8,7 @@ import voluptuous as vol -import homeassistant.components.bbb_gpio as bbb_gpio +from homeassistant.components import bbb_gpio from homeassistant.components.binary_sensor import ( BinarySensorDevice, PLATFORM_SCHEMA) from homeassistant.const import (DEVICE_DEFAULT_NAME, CONF_NAME) diff --git a/homeassistant/components/binary_sensor/bmw_connected_drive.py b/homeassistant/components/binary_sensor/bmw_connected_drive.py index 0abf6eb1064345..308298d1bcd103 100644 --- a/homeassistant/components/binary_sensor/bmw_connected_drive.py +++ b/homeassistant/components/binary_sensor/bmw_connected_drive.py @@ -17,9 +17,19 @@ SENSOR_TYPES = { 'lids': ['Doors', 'opening'], 'windows': ['Windows', 'opening'], - 'door_lock_state': ['Door lock state', 'safety'] + 'door_lock_state': ['Door lock state', 'safety'], + 'lights_parking': ['Parking lights', 'light'], + 'condition_based_services': ['Condition based services', 'problem'], + 'check_control_messages': ['Control messages', 'problem'] } +SENSOR_TYPES_ELEC = { + 'charging_status': ['Charging status', 'power'], + 'connection_status': ['Connection status', 'plug'] +} + +SENSOR_TYPES_ELEC.update(SENSOR_TYPES) + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the BMW sensors.""" @@ -29,10 +39,18 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for account in accounts: for vehicle in account.account.vehicles: - for key, value in sorted(SENSOR_TYPES.items()): - device = BMWConnectedDriveSensor(account, vehicle, key, - value[0], value[1]) - devices.append(device) + if vehicle.has_hv_battery: + _LOGGER.debug('BMW with a high voltage battery') + for key, value in sorted(SENSOR_TYPES_ELEC.items()): + device = BMWConnectedDriveSensor(account, vehicle, key, + value[0], value[1]) + devices.append(device) + elif vehicle.has_internal_combustion_engine: + _LOGGER.debug('BMW with an internal combustion engine') + for key, value in sorted(SENSOR_TYPES.items()): + device = BMWConnectedDriveSensor(account, vehicle, key, + value[0], value[1]) + devices.append(device) add_devices(devices, True) @@ -92,12 +110,34 @@ def device_state_attributes(self): result[window.name] = window.state.value elif self._attribute == 'door_lock_state': result['door_lock_state'] = vehicle_state.door_lock_state.value - - return result + result['last_update_reason'] = vehicle_state.last_update_reason + elif self._attribute == 'lights_parking': + result['lights_parking'] = vehicle_state.parking_lights.value + elif self._attribute == 'condition_based_services': + for report in vehicle_state.condition_based_services: + result.update(self._format_cbs_report(report)) + elif self._attribute == 'check_control_messages': + check_control_messages = vehicle_state.check_control_messages + if not check_control_messages: + result['check_control_messages'] = 'OK' + else: + result['check_control_messages'] = check_control_messages + elif self._attribute == 'charging_status': + result['charging_status'] = vehicle_state.charging_status.value + # pylint: disable=protected-access + result['last_charging_end_result'] = \ + vehicle_state._attributes['lastChargingEndResult'] + if self._attribute == 'connection_status': + # pylint: disable=protected-access + result['connection_status'] = \ + vehicle_state._attributes['connectionStatus'] + + return sorted(result.items()) def update(self): """Read new state data from the library.""" from bimmer_connected.state import LockState + from bimmer_connected.state import ChargingState vehicle_state = self._vehicle.state # device class opening: On means open, Off means closed @@ -111,6 +151,37 @@ def update(self): # Possible values: LOCKED, SECURED, SELECTIVE_LOCKED, UNLOCKED self._state = vehicle_state.door_lock_state not in \ [LockState.LOCKED, LockState.SECURED] + # device class light: On means light detected, Off means no light + if self._attribute == 'lights_parking': + self._state = vehicle_state.are_parking_lights_on + # device class problem: On means problem detected, Off means no problem + if self._attribute == 'condition_based_services': + self._state = not vehicle_state.are_all_cbs_ok + if self._attribute == 'check_control_messages': + self._state = vehicle_state.has_check_control_messages + # device class power: On means power detected, Off means no power + if self._attribute == 'charging_status': + self._state = vehicle_state.charging_status in \ + [ChargingState.CHARGING] + # device class plug: On means device is plugged in, + # Off means device is unplugged + if self._attribute == 'connection_status': + # pylint: disable=protected-access + self._state = (vehicle_state._attributes['connectionStatus'] == + 'CONNECTED') + + @staticmethod + def _format_cbs_report(report): + result = {} + service_type = report.service_type.lower().replace('_', ' ') + result['{} status'.format(service_type)] = report.state.value + if report.due_date is not None: + result['{} date'.format(service_type)] = \ + report.due_date.strftime('%Y-%m-%d') + if report.due_distance is not None: + result['{} distance'.format(service_type)] = \ + '{} km'.format(report.due_distance) + return result def update_callback(self): """Schedule a state update.""" diff --git a/homeassistant/components/binary_sensor/command_line.py b/homeassistant/components/binary_sensor/command_line.py index 2289ad5d9064ec..c2045c2df5e035 100644 --- a/homeassistant/components/binary_sensor/command_line.py +++ b/homeassistant/components/binary_sensor/command_line.py @@ -25,6 +25,9 @@ SCAN_INTERVAL = timedelta(seconds=60) +CONF_COMMAND_TIMEOUT = 'command_timeout' +DEFAULT_TIMEOUT = 15 + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_COMMAND): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -32,10 +35,11 @@ vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, + vol.Optional( + CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Command line Binary Sensor.""" name = config.get(CONF_NAME) @@ -44,9 +48,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): payload_on = config.get(CONF_PAYLOAD_ON) device_class = config.get(CONF_DEVICE_CLASS) value_template = config.get(CONF_VALUE_TEMPLATE) + command_timeout = config.get(CONF_COMMAND_TIMEOUT) if value_template is not None: value_template.hass = hass - data = CommandSensorData(hass, command) + data = CommandSensorData(hass, command, command_timeout) add_devices([CommandBinarySensor( hass, data, name, device_class, payload_on, payload_off, diff --git a/homeassistant/components/binary_sensor/deconz.py b/homeassistant/components/binary_sensor/deconz.py index 9faa703d13c000..0a370d754eea4d 100644 --- a/homeassistant/components/binary_sensor/deconz.py +++ b/homeassistant/components/binary_sensor/deconz.py @@ -5,8 +5,9 @@ https://home-assistant.io/components/binary_sensor.deconz/ """ from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.deconz import ( - DOMAIN as DATA_DECONZ, DATA_DECONZ_ID, DATA_DECONZ_UNSUB) +from homeassistant.components.deconz.const import ( + ATTR_DARK, ATTR_ON, CONF_ALLOW_CLIP_SENSOR, DOMAIN as DATA_DECONZ, + DATA_DECONZ_ID, DATA_DECONZ_UNSUB) from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -27,10 +28,13 @@ def async_add_sensor(sensors): """Add binary sensor from deCONZ.""" from pydeconz.sensor import DECONZ_BINARY_SENSOR entities = [] + allow_clip_sensor = config_entry.data.get(CONF_ALLOW_CLIP_SENSOR, True) for sensor in sensors: - if sensor.type in DECONZ_BINARY_SENSOR: + if sensor.type in DECONZ_BINARY_SENSOR and \ + not (not allow_clip_sensor and sensor.type.startswith('CLIP')): entities.append(DeconzBinarySensor(sensor)) async_add_devices(entities, True) + hass.data[DATA_DECONZ_UNSUB].append( async_dispatcher_connect(hass, 'deconz_new_sensor', async_add_sensor)) @@ -58,7 +62,8 @@ def async_update_callback(self, reason): """ if reason['state'] or \ 'reachable' in reason['attr'] or \ - 'battery' in reason['attr']: + 'battery' in reason['attr'] or \ + 'on' in reason['attr']: self.async_schedule_update_ha_state() @property @@ -103,6 +108,8 @@ def device_state_attributes(self): attr = {} if self._sensor.battery: attr[ATTR_BATTERY_LEVEL] = self._sensor.battery - if self._sensor.type in PRESENCE and self._sensor.dark: - attr['dark'] = self._sensor.dark + if self._sensor.on is not None: + attr[ATTR_ON] = self._sensor.on + if self._sensor.type in PRESENCE and self._sensor.dark is not None: + attr[ATTR_DARK] = self._sensor.dark return attr diff --git a/homeassistant/components/binary_sensor/digital_ocean.py b/homeassistant/components/binary_sensor/digital_ocean.py index 140c84358c79d3..1eb86d4eb82bad 100644 --- a/homeassistant/components/binary_sensor/digital_ocean.py +++ b/homeassistant/components/binary_sensor/digital_ocean.py @@ -14,7 +14,8 @@ from homeassistant.components.digital_ocean import ( CONF_DROPLETS, ATTR_CREATED_AT, ATTR_DROPLET_ID, ATTR_DROPLET_NAME, ATTR_FEATURES, ATTR_IPV4_ADDRESS, ATTR_IPV6_ADDRESS, ATTR_MEMORY, - ATTR_REGION, ATTR_VCPUS, DATA_DIGITAL_OCEAN) + ATTR_REGION, ATTR_VCPUS, CONF_ATTRIBUTION, DATA_DIGITAL_OCEAN) +from homeassistant.const import ATTR_ATTRIBUTION _LOGGER = logging.getLogger(__name__) @@ -75,6 +76,7 @@ def device_class(self): def device_state_attributes(self): """Return the state attributes of the Digital Ocean droplet.""" return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, ATTR_CREATED_AT: self.data.created_at, ATTR_DROPLET_ID: self.data.id, ATTR_DROPLET_NAME: self.data.name, diff --git a/homeassistant/components/binary_sensor/eight_sleep.py b/homeassistant/components/binary_sensor/eight_sleep.py index a6d4476f047e20..40ca491e1f3cc0 100644 --- a/homeassistant/components/binary_sensor/eight_sleep.py +++ b/homeassistant/components/binary_sensor/eight_sleep.py @@ -5,7 +5,6 @@ https://home-assistant.io/components/binary_sensor.eight_sleep/ """ import logging -import asyncio from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.eight_sleep import ( @@ -16,8 +15,8 @@ DEPENDENCIES = ['eight_sleep'] -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the eight sleep binary sensor.""" if discovery_info is None: return @@ -63,7 +62,6 @@ def is_on(self): """Return true if the binary sensor is on.""" return self._state - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Retrieve latest state.""" self._state = self._usrobj.bed_presence diff --git a/homeassistant/components/binary_sensor/envisalink.py b/homeassistant/components/binary_sensor/envisalink.py index 0aadcc247ea1e0..f358f814dc5cf8 100644 --- a/homeassistant/components/binary_sensor/envisalink.py +++ b/homeassistant/components/binary_sensor/envisalink.py @@ -6,6 +6,7 @@ """ import asyncio import logging +import datetime from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -14,6 +15,7 @@ DATA_EVL, ZONE_SCHEMA, CONF_ZONENAME, CONF_ZONETYPE, EnvisalinkDevice, SIGNAL_ZONE_UPDATE) from homeassistant.const import ATTR_LAST_TRIP_TIME +from homeassistant.util import dt as dt_util _LOGGER = logging.getLogger(__name__) @@ -63,7 +65,25 @@ def async_added_to_hass(self): def device_state_attributes(self): """Return the state attributes.""" attr = {} - attr[ATTR_LAST_TRIP_TIME] = self._info['last_fault'] + + # The Envisalink library returns a "last_fault" value that's the + # number of seconds since the last fault, up to a maximum of 327680 + # seconds (65536 5-second ticks). + # + # We don't want the HA event log to fill up with a bunch of no-op + # "state changes" that are just that number ticking up once per poll + # interval, so we subtract it from the current second-accurate time + # unless it is already at the maximum value, in which case we set it + # to None since we can't determine the actual value. + seconds_ago = self._info['last_fault'] + if seconds_ago < 65536 * 5: + now = dt_util.now().replace(microsecond=0) + delta = datetime.timedelta(seconds=seconds_ago) + last_trip_time = (now - delta).isoformat() + else: + last_trip_time = None + + attr[ATTR_LAST_TRIP_TIME] = last_trip_time return attr @property diff --git a/homeassistant/components/binary_sensor/flic.py b/homeassistant/components/binary_sensor/flic.py index 170f1818a0eb47..baf1d469b28b8b 100644 --- a/homeassistant/components/binary_sensor/flic.py +++ b/homeassistant/components/binary_sensor/flic.py @@ -16,7 +16,7 @@ from homeassistant.components.binary_sensor import ( BinarySensorDevice, PLATFORM_SCHEMA) -REQUIREMENTS = ['https://github.com/soldag/pyflic/archive/0.4.zip#pyflic==0.4'] +REQUIREMENTS = ['pyflic-homeassistant==0.4.dev0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/binary_sensor/gc100.py b/homeassistant/components/binary_sensor/gc100.py index c17e6b50911401..515d7e7123d4ad 100644 --- a/homeassistant/components/binary_sensor/gc100.py +++ b/homeassistant/components/binary_sensor/gc100.py @@ -23,7 +23,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the GC100 devices.""" binary_sensors = [] @@ -40,7 +39,6 @@ class GC100BinarySensor(BinarySensorDevice): def __init__(self, name, port_addr, gc100): """Initialize the GC100 binary sensor.""" - # pylint: disable=no-member self._name = name or DEVICE_DEFAULT_NAME self._port_addr = port_addr self._gc100 = gc100 diff --git a/homeassistant/components/binary_sensor/hikvision.py b/homeassistant/components/binary_sensor/hikvision.py index f9ff4ac0a7a7ce..de6ad8223d729d 100644 --- a/homeassistant/components/binary_sensor/hikvision.py +++ b/homeassistant/components/binary_sensor/hikvision.py @@ -117,7 +117,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): add_entities(entities) -class HikvisionData(object): +class HikvisionData: """Hikvision device event stream object.""" def __init__(self, hass, url, port, name, username, password): diff --git a/homeassistant/components/binary_sensor/homematicip_cloud.py b/homeassistant/components/binary_sensor/homematicip_cloud.py new file mode 100644 index 00000000000000..962817827f0ac6 --- /dev/null +++ b/homeassistant/components/binary_sensor/homematicip_cloud.py @@ -0,0 +1,94 @@ +""" +Support for HomematicIP binary sensor. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.homematicip_cloud/ +""" + +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.homematicip_cloud import ( + HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN, + HMIPC_HAPID) + +DEPENDENCIES = ['homematicip_cloud'] + +_LOGGER = logging.getLogger(__name__) + +STATE_SMOKE_OFF = 'IDLE_OFF' + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the binary sensor devices.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the HomematicIP binary sensor from a config entry.""" + from homematicip.aio.device import ( + AsyncShutterContact, AsyncMotionDetectorIndoor, AsyncSmokeDetector) + + home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home + devices = [] + for device in home.devices: + if isinstance(device, AsyncShutterContact): + devices.append(HomematicipShutterContact(home, device)) + elif isinstance(device, AsyncMotionDetectorIndoor): + devices.append(HomematicipMotionDetector(home, device)) + elif isinstance(device, AsyncSmokeDetector): + devices.append(HomematicipSmokeDetector(home, device)) + + if devices: + async_add_devices(devices) + + +class HomematicipShutterContact(HomematicipGenericDevice, BinarySensorDevice): + """HomematicIP shutter contact.""" + + @property + def device_class(self): + """Return the class of this sensor.""" + return 'door' + + @property + def is_on(self): + """Return true if the shutter contact is on/open.""" + from homematicip.base.enums import WindowState + + if self._device.sabotage: + return True + if self._device.windowState is None: + return None + return self._device.windowState == WindowState.OPEN + + +class HomematicipMotionDetector(HomematicipGenericDevice, BinarySensorDevice): + """HomematicIP motion detector.""" + + @property + def device_class(self): + """Return the class of this sensor.""" + return 'motion' + + @property + def is_on(self): + """Return true if motion is detected.""" + if self._device.sabotage: + return True + return self._device.motionDetected + + +class HomematicipSmokeDetector(HomematicipGenericDevice, BinarySensorDevice): + """HomematicIP smoke detector.""" + + @property + def device_class(self): + """Return the class of this sensor.""" + return 'smoke' + + @property + def is_on(self): + """Return true if smoke is detected.""" + return self._device.smokeDetectorAlarmType != STATE_SMOKE_OFF diff --git a/homeassistant/components/binary_sensor/hydrawise.py b/homeassistant/components/binary_sensor/hydrawise.py new file mode 100644 index 00000000000000..a3e0ebd782db5e --- /dev/null +++ b/homeassistant/components/binary_sensor/hydrawise.py @@ -0,0 +1,81 @@ +""" +Support for Hydrawise sprinkler. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.hydrawise/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.hydrawise import ( + BINARY_SENSORS, DATA_HYDRAWISE, HydrawiseEntity, DEVICE_MAP, + DEVICE_MAP_INDEX) +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.const import CONF_MONITORED_CONDITIONS + +DEPENDENCIES = ['hydrawise'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=BINARY_SENSORS): + vol.All(cv.ensure_list, [vol.In(BINARY_SENSORS)]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up a sensor for a Hydrawise device.""" + hydrawise = hass.data[DATA_HYDRAWISE].data + + sensors = [] + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + if sensor_type in ['status', 'rain_sensor']: + sensors.append( + HydrawiseBinarySensor( + hydrawise.controller_status, sensor_type)) + + else: + # create a sensor for each zone + for zone in hydrawise.relays: + zone_data = zone + zone_data['running'] = \ + hydrawise.controller_status.get('running', False) + sensors.append(HydrawiseBinarySensor(zone_data, sensor_type)) + + add_devices(sensors, True) + + +class HydrawiseBinarySensor(HydrawiseEntity, BinarySensorDevice): + """A sensor implementation for Hydrawise device.""" + + @property + def is_on(self): + """Return true if the binary sensor is on.""" + return self._state + + def update(self): + """Get the latest data and updates the state.""" + _LOGGER.debug("Updating Hydrawise binary sensor: %s", self._name) + mydata = self.hass.data[DATA_HYDRAWISE].data + if self._sensor_type == 'status': + self._state = mydata.status == 'All good!' + elif self._sensor_type == 'rain_sensor': + for sensor in mydata.sensors: + if sensor['name'] == 'Rain': + self._state = sensor['active'] == 1 + elif self._sensor_type == 'is_watering': + if not mydata.running: + self._state = False + elif int(mydata.running[0]['relay']) == self.data['relay']: + self._state = True + else: + self._state = False + + @property + def device_class(self): + """Return the device class of the sensor type.""" + return DEVICE_MAP[self._sensor_type][ + DEVICE_MAP_INDEX.index('DEVICE_CLASS_INDEX')] diff --git a/homeassistant/components/binary_sensor/ihc.py b/homeassistant/components/binary_sensor/ihc.py index 96efa6e6c1969b..25435d373fd850 100644 --- a/homeassistant/components/binary_sensor/ihc.py +++ b/homeassistant/components/binary_sensor/ihc.py @@ -3,8 +3,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.ihc/ """ -from xml.etree.ElementTree import Element - import voluptuous as vol from homeassistant.components.binary_sensor import ( @@ -70,7 +68,7 @@ class IHCBinarySensor(IHCDevice, BinarySensorDevice): def __init__(self, ihc_controller, name, ihc_id: int, info: bool, sensor_type: str, inverting: bool, - product: Element = None) -> None: + product=None) -> None: """Initialize the IHC binary sensor.""" super().__init__(ihc_controller, name, ihc_id, info, product) self._state = None diff --git a/homeassistant/components/binary_sensor/insteon_plm.py b/homeassistant/components/binary_sensor/insteon_plm.py index 9cb87b317499a0..25fc3fb5d73f17 100644 --- a/homeassistant/components/binary_sensor/insteon_plm.py +++ b/homeassistant/components/binary_sensor/insteon_plm.py @@ -17,7 +17,9 @@ SENSOR_TYPES = {'openClosedSensor': 'opening', 'motionSensor': 'motion', 'doorSensor': 'door', - 'wetLeakSensor': 'moisture'} + 'wetLeakSensor': 'moisture', + 'lightSensor': 'light', + 'batterySensor': 'battery'} @asyncio.coroutine @@ -54,4 +56,9 @@ def device_class(self): @property def is_on(self): """Return the boolean response if the node is on.""" - return bool(self._insteon_device_state.value) + on_val = bool(self._insteon_device_state.value) + + if self._insteon_device_state.name == 'lightSensor': + return not on_val + + return on_val diff --git a/homeassistant/components/binary_sensor/iss.py b/homeassistant/components/binary_sensor/iss.py index d35c36a012e940..d0654317248eee 100644 --- a/homeassistant/components/binary_sensor/iss.py +++ b/homeassistant/components/binary_sensor/iss.py @@ -101,7 +101,7 @@ def update(self): self.iss_data.update() -class IssData(object): +class IssData: """Get data from the ISS API.""" def __init__(self, latitude, longitude): diff --git a/homeassistant/components/binary_sensor/isy994.py b/homeassistant/components/binary_sensor/isy994.py index fb86244acf3187..b6d582b7793736 100644 --- a/homeassistant/components/binary_sensor/isy994.py +++ b/homeassistant/components/binary_sensor/isy994.py @@ -8,7 +8,7 @@ import asyncio import logging from datetime import timedelta -from typing import Callable # noqa +from typing import Callable from homeassistant.core import callback from homeassistant.components.binary_sensor import BinarySensorDevice, DOMAIN @@ -28,7 +28,6 @@ } -# pylint: disable=unused-argument def setup_platform(hass, config: ConfigType, add_devices: Callable[[list], None], discovery_info=None): """Set up the ISY994 binary sensor platform.""" @@ -56,7 +55,7 @@ def setup_platform(hass, config: ConfigType, else: device_type = _detect_device_type(node) subnode_id = int(node.nid[-1]) - if (device_type == 'opening' or device_type == 'moisture'): + if device_type in ('opening', 'moisture'): # These sensors use an optional "negative" subnode 2 to snag # all state changes if subnode_id == 2: @@ -117,8 +116,10 @@ def __init__(self, node) -> None: # pylint: disable=protected-access if _is_val_unknown(self._node.status._val): self._computed_state = None + self._status_was_unknown = True else: self._computed_state = bool(self._node.status._val) + self._status_was_unknown = False @asyncio.coroutine def async_added_to_hass(self) -> None: @@ -156,9 +157,13 @@ def add_negative_node(self, child) -> None: # pylint: disable=protected-access if not _is_val_unknown(self._negative_node.status._val): # If the negative node has a value, it means the negative node is - # in use for this device. Therefore, we cannot determine the state - # of the sensor until we receive our first ON event. - self._computed_state = None + # in use for this device. Next we need to check to see if the + # negative and positive nodes disagree on the state (both ON or + # both OFF). + if self._negative_node.status._val == self._node.status._val: + # The states disagree, therefore we cannot determine the state + # of the sensor until we receive our first ON event. + self._computed_state = None def _negative_node_control_handler(self, event: object) -> None: """Handle an "On" control event from the "negative" node.""" @@ -189,14 +194,21 @@ def _positive_node_control_handler(self, event: object) -> None: self.schedule_update_ha_state() self._heartbeat() - # pylint: disable=unused-argument def on_update(self, event: object) -> None: - """Ignore primary node status updates. - - We listen directly to the Control events on all nodes for this - device. + """Primary node status updates. + + We MOSTLY ignore these updates, as we listen directly to the Control + events on all nodes for this device. However, there is one edge case: + If a leak sensor is unknown, due to a recent reboot of the ISY, the + status will get updated to dry upon the first heartbeat. This status + update is the only way that a leak sensor's status changes without + an accompanying Control event, so we need to watch for it. """ - pass + if self._status_was_unknown and self._computed_state is None: + self._computed_state = bool(int(self._node.status)) + self._status_was_unknown = False + self.schedule_update_ha_state() + self._heartbeat() @property def value(self) -> object: @@ -286,7 +298,6 @@ def _restart_timer(self): # No heartbeat timer is active pass - # pylint: disable=unused-argument @callback def timer_elapsed(now) -> None: """Heartbeat missed; set state to indicate dead battery.""" @@ -301,7 +312,6 @@ def timer_elapsed(now) -> None: self._heartbeat_timer = async_track_point_in_utc_time( self.hass, timer_elapsed, point_in_time) - # pylint: disable=unused-argument def on_update(self, event: object) -> None: """Ignore node status updates. diff --git a/homeassistant/components/binary_sensor/knx.py b/homeassistant/components/binary_sensor/knx.py index 834186b8b185e1..e6b28047cb8f2f 100644 --- a/homeassistant/components/binary_sensor/knx.py +++ b/homeassistant/components/binary_sensor/knx.py @@ -115,7 +115,6 @@ def async_register_callbacks(self): """Register callbacks to update hass after device was changed.""" async def after_update_callback(device): """Call after device was updated.""" - # pylint: disable=unused-argument await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) diff --git a/homeassistant/components/binary_sensor/konnected.py b/homeassistant/components/binary_sensor/konnected.py new file mode 100644 index 00000000000000..9a16ca5e1ab1d0 --- /dev/null +++ b/homeassistant/components/binary_sensor/konnected.py @@ -0,0 +1,82 @@ +""" +Support for wired binary sensors attached to a Konnected device. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.konnected/ +""" +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.konnected import ( + DOMAIN as KONNECTED_DOMAIN, PIN_TO_ZONE, SIGNAL_SENSOR_UPDATE) +from homeassistant.const import ( + CONF_DEVICES, CONF_TYPE, CONF_NAME, CONF_BINARY_SENSORS, ATTR_ENTITY_ID, + ATTR_STATE) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['konnected'] + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up binary sensors attached to a Konnected device.""" + if discovery_info is None: + return + + data = hass.data[KONNECTED_DOMAIN] + device_id = discovery_info['device_id'] + sensors = [KonnectedBinarySensor(device_id, pin_num, pin_data) + for pin_num, pin_data in + data[CONF_DEVICES][device_id][CONF_BINARY_SENSORS].items()] + async_add_devices(sensors) + + +class KonnectedBinarySensor(BinarySensorDevice): + """Representation of a Konnected binary sensor.""" + + def __init__(self, device_id, pin_num, data): + """Initialize the binary sensor.""" + self._data = data + self._device_id = device_id + self._pin_num = pin_num + self._state = self._data.get(ATTR_STATE) + self._device_class = self._data.get(CONF_TYPE) + self._name = self._data.get(CONF_NAME, 'Konnected {} Zone {}'.format( + device_id, PIN_TO_ZONE[pin_num])) + _LOGGER.debug('Created new Konnected sensor: %s', self._name) + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def is_on(self): + """Return the state of the sensor.""" + return self._state + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def device_class(self): + """Return the device class.""" + return self._device_class + + async def async_added_to_hass(self): + """Store entity_id and register state change callback.""" + self._data[ATTR_ENTITY_ID] = self.entity_id + async_dispatcher_connect( + self.hass, SIGNAL_SENSOR_UPDATE.format(self.entity_id), + self.async_set_state) + + @callback + def async_set_state(self, state): + """Update the sensor's state.""" + self._state = state + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/linode.py b/homeassistant/components/binary_sensor/linode.py index 8af0318373d5a0..d4fc60696cdafd 100644 --- a/homeassistant/components/binary_sensor/linode.py +++ b/homeassistant/components/binary_sensor/linode.py @@ -52,19 +52,18 @@ def __init__(self, li, node_id): self._node_id = node_id self._state = None self.data = None + self._attrs = {} + self._name = None @property def name(self): """Return the name of the sensor.""" - if self.data is not None: - return self.data.label + return self._name @property def is_on(self): """Return true if the binary sensor is on.""" - if self.data is not None: - return self.data.status == 'running' - return False + return self._state @property def device_class(self): @@ -74,8 +73,18 @@ def device_class(self): @property def device_state_attributes(self): """Return the state attributes of the Linode Node.""" - if self.data: - return { + return self._attrs + + def update(self): + """Update state of sensor.""" + self._linode.update() + if self._linode.data is not None: + for node in self._linode.data: + if node.id == self._node_id: + self.data = node + if self.data is not None: + self._state = self.data.status == 'running' + self._attrs = { ATTR_CREATED: self.data.created, ATTR_NODE_ID: self.data.id, ATTR_NODE_NAME: self.data.label, @@ -85,12 +94,4 @@ def device_state_attributes(self): ATTR_REGION: self.data.region.country, ATTR_VCPUS: self.data.specs.vcpus, } - return {} - - def update(self): - """Update state of sensor.""" - self._linode.update() - if self._linode.data is not None: - for node in self._linode.data: - if node.id == self._node_id: - self.data = node + self._name = self.data.label diff --git a/homeassistant/components/binary_sensor/modbus.py b/homeassistant/components/binary_sensor/modbus.py index 00dc588a4688a0..1a45235f15a84b 100644 --- a/homeassistant/components/binary_sensor/modbus.py +++ b/homeassistant/components/binary_sensor/modbus.py @@ -7,7 +7,7 @@ import logging import voluptuous as vol -import homeassistant.components.modbus as modbus +from homeassistant.components import modbus from homeassistant.const import CONF_NAME, CONF_SLAVE from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.helpers import config_validation as cv diff --git a/homeassistant/components/binary_sensor/mqtt.py b/homeassistant/components/binary_sensor/mqtt.py index e033355f655313..cb943ac3f188d0 100644 --- a/homeassistant/components/binary_sensor/mqtt.py +++ b/homeassistant/components/binary_sensor/mqtt.py @@ -6,11 +6,12 @@ """ import asyncio import logging +from typing import Optional import voluptuous as vol from homeassistant.core import callback -import homeassistant.components.mqtt as mqtt +from homeassistant.components import mqtt from homeassistant.components.binary_sensor import ( BinarySensorDevice, DEVICE_CLASSES_SCHEMA) from homeassistant.const import ( @@ -24,7 +25,7 @@ _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'MQTT Binary sensor' - +CONF_UNIQUE_ID = 'unique_id' DEFAULT_PAYLOAD_OFF = 'OFF' DEFAULT_PAYLOAD_ON = 'ON' DEFAULT_FORCE_UPDATE = False @@ -37,6 +38,9 @@ vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_FORCE_UPDATE, default=DEFAULT_FORCE_UPDATE): cv.boolean, + # Integrations shouldn't never expose unique_id through configuration + # this here is an exception because MQTT is a msg transport, not a protocol + vol.Optional(CONF_UNIQUE_ID): cv.string, }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @@ -61,7 +65,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_PAYLOAD_OFF), config.get(CONF_PAYLOAD_AVAILABLE), config.get(CONF_PAYLOAD_NOT_AVAILABLE), - value_template + value_template, + config.get(CONF_UNIQUE_ID), )]) @@ -70,7 +75,8 @@ class MqttBinarySensor(MqttAvailability, BinarySensorDevice): def __init__(self, name, state_topic, availability_topic, device_class, qos, force_update, payload_on, payload_off, payload_available, - payload_not_available, value_template): + payload_not_available, value_template, + unique_id: Optional[str]): """Initialize the MQTT binary sensor.""" super().__init__(availability_topic, qos, payload_available, payload_not_available) @@ -83,6 +89,7 @@ def __init__(self, name, state_topic, availability_topic, device_class, self._qos = qos self._force_update = force_update self._template = value_template + self._unique_id = unique_id @asyncio.coroutine def async_added_to_hass(self): @@ -134,3 +141,8 @@ def device_class(self): def force_update(self): """Force update.""" return self._force_update + + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id diff --git a/homeassistant/components/binary_sensor/mychevy.py b/homeassistant/components/binary_sensor/mychevy.py index a89395ed86f103..905e60c34d9ca7 100644 --- a/homeassistant/components/binary_sensor/mychevy.py +++ b/homeassistant/components/binary_sensor/mychevy.py @@ -31,7 +31,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): sensors = [] hub = hass.data[MYCHEVY_DOMAIN] for sconfig in SENSORS: - sensors.append(EVBinarySensor(hub, sconfig)) + for car in hub.cars: + sensors.append(EVBinarySensor(hub, sconfig, car.vid)) async_add_devices(sensors) @@ -45,16 +46,18 @@ class EVBinarySensor(BinarySensorDevice): """ - def __init__(self, connection, config): + def __init__(self, connection, config, car_vid): """Initialize sensor with car connection.""" self._conn = connection self._name = config.name self._attr = config.attr self._type = config.device_class self._is_on = None - + self._car_vid = car_vid self.entity_id = ENTITY_ID_FORMAT.format( - '{}_{}'.format(MYCHEVY_DOMAIN, slugify(self._name))) + '{}_{}_{}'.format(MYCHEVY_DOMAIN, + slugify(self._car.name), + slugify(self._name))) @property def name(self): @@ -66,6 +69,11 @@ def is_on(self): """Return if on.""" return self._is_on + @property + def _car(self): + """Return the car.""" + return self._conn.get_car(self._car_vid) + @asyncio.coroutine def async_added_to_hass(self): """Register callbacks.""" @@ -75,8 +83,8 @@ def async_added_to_hass(self): @callback def async_update_callback(self): """Update state.""" - if self._conn.car is not None: - self._is_on = getattr(self._conn.car, self._attr, None) + if self._car is not None: + self._is_on = getattr(self._car, self._attr, None) self.async_schedule_update_ha_state() @property diff --git a/homeassistant/components/binary_sensor/mysensors.py b/homeassistant/components/binary_sensor/mysensors.py index 214430211932e5..abb19129d5205d 100644 --- a/homeassistant/components/binary_sensor/mysensors.py +++ b/homeassistant/components/binary_sensor/mysensors.py @@ -29,7 +29,8 @@ async def async_setup_platform( async_add_devices=async_add_devices) -class MySensorsBinarySensor(mysensors.MySensorsEntity, BinarySensorDevice): +class MySensorsBinarySensor( + mysensors.device.MySensorsEntity, BinarySensorDevice): """Representation of a MySensors Binary Sensor child node.""" @property diff --git a/homeassistant/components/binary_sensor/mystrom.py b/homeassistant/components/binary_sensor/mystrom.py index 93d56a97c4273e..5c1d9a576a6852 100644 --- a/homeassistant/components/binary_sensor/mystrom.py +++ b/homeassistant/components/binary_sensor/mystrom.py @@ -29,6 +29,7 @@ class MyStromView(HomeAssistantView): url = '/api/mystrom' name = 'api:mystrom' + supported_actions = ['single', 'double', 'long', 'touch'] def __init__(self, add_devices): """Initialize the myStrom URL endpoint.""" @@ -44,16 +45,18 @@ def get(self, request): @asyncio.coroutine def _handle(self, hass, data): """Handle requests to the myStrom endpoint.""" - button_action = list(data.keys())[0] - button_id = data[button_action] - entity_id = '{}.{}_{}'.format(DOMAIN, button_id, button_action) + button_action = next(( + parameter for parameter in data + if parameter in self.supported_actions), None) - if button_action not in ['single', 'double', 'long', 'touch']: + if button_action is None: _LOGGER.error( "Received unidentified message from myStrom button: %s", data) return ("Received unidentified message: {}".format(data), HTTP_UNPROCESSABLE_ENTITY) + button_id = data[button_action] + entity_id = '{}.{}_{}'.format(DOMAIN, button_id, button_action) if entity_id not in self.buttons: _LOGGER.info("New myStrom button/action detected: %s/%s", button_id, button_action) diff --git a/homeassistant/components/binary_sensor/nest.py b/homeassistant/components/binary_sensor/nest.py index 4089f3a2eaf60f..31460c1eedca0d 100644 --- a/homeassistant/components/binary_sensor/nest.py +++ b/homeassistant/components/binary_sensor/nest.py @@ -7,27 +7,35 @@ from itertools import chain import logging -from homeassistant.components.binary_sensor import (BinarySensorDevice) -from homeassistant.components.sensor.nest import NestSensor +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.nest import ( + DATA_NEST, DATA_NEST_CONFIG, CONF_BINARY_SENSORS, NestSensorDevice) from homeassistant.const import CONF_MONITORED_CONDITIONS -from homeassistant.components.nest import DATA_NEST DEPENDENCIES = ['nest'] -BINARY_TYPES = ['online'] +BINARY_TYPES = {'online': 'connectivity'} -CLIMATE_BINARY_TYPES = [ - 'fan', - 'is_using_emergency_heat', - 'is_locked', - 'has_leaf', -] +CLIMATE_BINARY_TYPES = { + 'fan': None, + 'is_using_emergency_heat': 'heat', + 'is_locked': None, + 'has_leaf': None, +} -CAMERA_BINARY_TYPES = [ - 'motion_detected', - 'sound_detected', - 'person_detected', -] +CAMERA_BINARY_TYPES = { + 'motion_detected': 'motion', + 'sound_detected': 'sound', + 'person_detected': 'occupancy', +} + +STRUCTURE_BINARY_TYPES = { + 'away': None, +} + +STRUCTURE_BINARY_STATE_MAP = { + 'away': {'away': True, 'home': False}, +} _BINARY_TYPES_DEPRECATED = [ 'hvac_ac_state', @@ -40,19 +48,26 @@ 'hvac_emer_heat_state', ] -_VALID_BINARY_SENSOR_TYPES = BINARY_TYPES + CLIMATE_BINARY_TYPES \ - + CAMERA_BINARY_TYPES +_VALID_BINARY_SENSOR_TYPES = {**BINARY_TYPES, **CLIMATE_BINARY_TYPES, + **CAMERA_BINARY_TYPES, **STRUCTURE_BINARY_TYPES} _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Nest binary sensors.""" - if discovery_info is None: - return + """Set up the Nest binary sensors. + No longer used. + """ + + +async def async_setup_entry(hass, entry, async_add_devices): + """Set up a Nest binary sensor based on a config entry.""" nest = hass.data[DATA_NEST] + discovery_info = \ + hass.data.get(DATA_NEST_CONFIG, {}).get(CONF_BINARY_SENSORS, {}) + # Add all available binary sensors if no Nest binary sensor config is set if discovery_info == {}: conditions = _VALID_BINARY_SENSOR_TYPES @@ -67,32 +82,40 @@ def setup_platform(hass, config, add_devices, discovery_info=None): "for valid options.") _LOGGER.error(wstr) - sensors = [] - device_chain = chain(nest.thermostats(), - nest.smoke_co_alarms(), - nest.cameras()) - for structure, device in device_chain: - sensors += [NestBinarySensor(structure, device, variable) - for variable in conditions - if variable in BINARY_TYPES] - sensors += [NestBinarySensor(structure, device, variable) - for variable in conditions - if variable in CLIMATE_BINARY_TYPES - and device.is_thermostat] - - if device.is_camera: + def get_binary_sensors(): + """Get the Nest binary sensors.""" + sensors = [] + for structure in nest.structures(): + sensors += [NestBinarySensor(structure, None, variable) + for variable in conditions + if variable in STRUCTURE_BINARY_TYPES] + device_chain = chain(nest.thermostats(), + nest.smoke_co_alarms(), + nest.cameras()) + for structure, device in device_chain: + sensors += [NestBinarySensor(structure, device, variable) + for variable in conditions + if variable in BINARY_TYPES] sensors += [NestBinarySensor(structure, device, variable) for variable in conditions - if variable in CAMERA_BINARY_TYPES] - for activity_zone in device.activity_zones: - sensors += [NestActivityZoneSensor(structure, - device, - activity_zone)] + if variable in CLIMATE_BINARY_TYPES + and device.is_thermostat] + + if device.is_camera: + sensors += [NestBinarySensor(structure, device, variable) + for variable in conditions + if variable in CAMERA_BINARY_TYPES] + for activity_zone in device.activity_zones: + sensors += [NestActivityZoneSensor(structure, + device, + activity_zone)] - add_devices(sensors, True) + return sensors + async_add_devices(await hass.async_add_job(get_binary_sensors), True) -class NestBinarySensor(NestSensor, BinarySensorDevice): + +class NestBinarySensor(NestSensorDevice, BinarySensorDevice): """Represents a Nest binary sensor.""" @property @@ -100,9 +123,19 @@ def is_on(self): """Return true if the binary sensor is on.""" return self._state + @property + def device_class(self): + """Return the device class of the binary sensor.""" + return _VALID_BINARY_SENSOR_TYPES.get(self.variable) + def update(self): """Retrieve latest state.""" - self._state = bool(getattr(self.device, self.variable)) + value = getattr(self.device, self.variable) + if self.variable in STRUCTURE_BINARY_TYPES: + self._state = bool(STRUCTURE_BINARY_STATE_MAP + [self.variable].get(value)) + else: + self._state = bool(value) class NestActivityZoneSensor(NestBinarySensor): @@ -115,9 +148,9 @@ def __init__(self, structure, device, zone): self._name = "{} {} activity".format(self._name, self.zone.name) @property - def name(self): - """Return the name of the nest, if any.""" - return self._name + def device_class(self): + """Return the device class of the binary sensor.""" + return 'motion' def update(self): """Retrieve latest state.""" diff --git a/homeassistant/components/binary_sensor/netatmo.py b/homeassistant/components/binary_sensor/netatmo.py index fd0e30ccebc408..73a373a15ffa06 100644 --- a/homeassistant/components/binary_sensor/netatmo.py +++ b/homeassistant/components/binary_sensor/netatmo.py @@ -57,7 +57,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the access to Netatmo binary sensor.""" netatmo = hass.components.netatmo @@ -68,12 +67,12 @@ def setup_platform(hass, config, add_devices, discovery_info=None): module_name = None - import lnetatmo + import pyatmo try: data = CameraData(netatmo.NETATMO_AUTH, home) if not data.get_camera_names(): return None - except lnetatmo.NoDevice: + except pyatmo.NoDevice: return None welcome_sensors = config.get( @@ -143,7 +142,7 @@ def device_class(self): """Return the class of this sensor, from DEVICE_CLASSES.""" if self._cameratype == 'NACamera': return WELCOME_SENSOR_TYPES.get(self._sensor_name) - elif self._cameratype == 'NOC': + if self._cameratype == 'NOC': return PRESENCE_SENSOR_TYPES.get(self._sensor_name) return TAG_SENSOR_TYPES.get(self._sensor_name) diff --git a/homeassistant/components/binary_sensor/octoprint.py b/homeassistant/components/binary_sensor/octoprint.py index 265fcec66fa9c7..1a1967b9014a0b 100644 --- a/homeassistant/components/binary_sensor/octoprint.py +++ b/homeassistant/components/binary_sensor/octoprint.py @@ -33,7 +33,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the available OctoPrint binary sensors.""" octoprint_api = hass.data[DOMAIN]["api"] diff --git a/homeassistant/components/binary_sensor/openuv.py b/homeassistant/components/binary_sensor/openuv.py new file mode 100644 index 00000000000000..3a2732d3be037b --- /dev/null +++ b/homeassistant/components/binary_sensor/openuv.py @@ -0,0 +1,103 @@ +""" +This platform provides binary sensors for OpenUV data. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.openuv/ +""" +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.components.openuv import ( + BINARY_SENSORS, DATA_PROTECTION_WINDOW, DOMAIN, TOPIC_UPDATE, + TYPE_PROTECTION_WINDOW, OpenUvEntity) +from homeassistant.util.dt import as_local, parse_datetime, utcnow + +DEPENDENCIES = ['openuv'] +_LOGGER = logging.getLogger(__name__) + +ATTR_PROTECTION_WINDOW_STARTING_TIME = 'start_time' +ATTR_PROTECTION_WINDOW_STARTING_UV = 'start_uv' +ATTR_PROTECTION_WINDOW_ENDING_TIME = 'end_time' +ATTR_PROTECTION_WINDOW_ENDING_UV = 'end_uv' + + +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): + """Set up the OpenUV binary sensor platform.""" + if discovery_info is None: + return + + openuv = hass.data[DOMAIN] + + binary_sensors = [] + for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]: + name, icon = BINARY_SENSORS[sensor_type] + binary_sensors.append( + OpenUvBinarySensor(openuv, sensor_type, name, icon)) + + async_add_devices(binary_sensors, True) + + +class OpenUvBinarySensor(OpenUvEntity, BinarySensorDevice): + """Define a binary sensor for OpenUV.""" + + def __init__(self, openuv, sensor_type, name, icon): + """Initialize the sensor.""" + super().__init__(openuv) + + self._icon = icon + self._latitude = openuv.client.latitude + self._longitude = openuv.client.longitude + self._name = name + self._sensor_type = sensor_type + self._state = None + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + def is_on(self): + """Return the status of the sensor.""" + return self._state + + @property + def should_poll(self): + """Disable polling.""" + return False + + @property + def unique_id(self) -> str: + """Return a unique, HASS-friendly identifier for this entity.""" + return '{0}_{1}_{2}'.format( + self._latitude, self._longitude, self._sensor_type) + + @callback + def _update_data(self): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, TOPIC_UPDATE, self._update_data) + + async def async_update(self): + """Update the state.""" + data = self.openuv.data[DATA_PROTECTION_WINDOW]['result'] + if self._sensor_type == TYPE_PROTECTION_WINDOW: + self._state = parse_datetime( + data['from_time']) <= utcnow() <= parse_datetime( + data['to_time']) + self._attrs.update({ + ATTR_PROTECTION_WINDOW_ENDING_TIME: + as_local(parse_datetime(data['to_time'])), + ATTR_PROTECTION_WINDOW_ENDING_UV: data['to_uv'], + ATTR_PROTECTION_WINDOW_STARTING_UV: data['from_uv'], + ATTR_PROTECTION_WINDOW_STARTING_TIME: + as_local(parse_datetime(data['from_time'])), + }) diff --git a/homeassistant/components/binary_sensor/pilight.py b/homeassistant/components/binary_sensor/pilight.py index d2c46c795a8530..69dc3b834855c1 100644 --- a/homeassistant/components/binary_sensor/pilight.py +++ b/homeassistant/components/binary_sensor/pilight.py @@ -44,7 +44,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up Pilight Binary Sensor.""" disarm = config.get(CONF_DISARM_AFTER_TRIGGER) diff --git a/homeassistant/components/binary_sensor/ping.py b/homeassistant/components/binary_sensor/ping.py index 0830d86dc2ac18..bb597f208e667b 100644 --- a/homeassistant/components/binary_sensor/ping.py +++ b/homeassistant/components/binary_sensor/ping.py @@ -96,7 +96,7 @@ def update(self): self.ping.update() -class PingData(object): +class PingData: """The Class for handling the data retrieval.""" def __init__(self, host, count): diff --git a/homeassistant/components/binary_sensor/rachio.py b/homeassistant/components/binary_sensor/rachio.py new file mode 100644 index 00000000000000..59bf8a2106471d --- /dev/null +++ b/homeassistant/components/binary_sensor/rachio.py @@ -0,0 +1,126 @@ +""" +Integration with the Rachio Iro sprinkler system controller. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.rachio/ +""" +from abc import abstractmethod +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.rachio import (DOMAIN as DOMAIN_RACHIO, + KEY_DEVICE_ID, + KEY_STATUS, + KEY_SUBTYPE, + SIGNAL_RACHIO_CONTROLLER_UPDATE, + STATUS_OFFLINE, + STATUS_ONLINE, + SUBTYPE_OFFLINE, + SUBTYPE_ONLINE,) +from homeassistant.helpers.dispatcher import dispatcher_connect + +DEPENDENCIES = ['rachio'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Rachio binary sensors.""" + devices = [] + for controller in hass.data[DOMAIN_RACHIO].controllers: + devices.append(RachioControllerOnlineBinarySensor(hass, controller)) + + add_devices(devices) + _LOGGER.info("%d Rachio binary sensor(s) added", len(devices)) + + +class RachioControllerBinarySensor(BinarySensorDevice): + """Represent a binary sensor that reflects a Rachio state.""" + + def __init__(self, hass, controller, poll=True): + """Set up a new Rachio controller binary sensor.""" + self._controller = controller + + if poll: + self._state = self._poll_update() + else: + self._state = None + + dispatcher_connect(hass, SIGNAL_RACHIO_CONTROLLER_UPDATE, + self._handle_any_update) + + @property + def should_poll(self) -> bool: + """Declare that this entity pushes its state to HA.""" + return False + + @property + def is_on(self) -> bool: + """Return whether the sensor has a 'true' value.""" + return self._state + + def _handle_any_update(self, *args, **kwargs) -> None: + """Determine whether an update event applies to this device.""" + if args[0][KEY_DEVICE_ID] != self._controller.controller_id: + # For another device + return + + # For this device + self._handle_update() + + @abstractmethod + def _poll_update(self, data=None) -> bool: + """Request the state from the API.""" + pass + + @abstractmethod + def _handle_update(self, *args, **kwargs) -> None: + """Handle an update to the state of this sensor.""" + pass + + +class RachioControllerOnlineBinarySensor(RachioControllerBinarySensor): + """Represent a binary sensor that reflects if the controller is online.""" + + def __init__(self, hass, controller): + """Set up a new Rachio controller online binary sensor.""" + super().__init__(hass, controller, poll=False) + self._state = self._poll_update(controller.init_data) + + @property + def name(self) -> str: + """Return the name of this sensor including the controller name.""" + return "{} online".format(self._controller.name) + + @property + def device_class(self) -> str: + """Return the class of this device, from component DEVICE_CLASSES.""" + return 'connectivity' + + @property + def icon(self) -> str: + """Return the name of an icon for this sensor.""" + return 'mdi:wifi-strength-4' if self.is_on\ + else 'mdi:wifi-strength-off-outline' + + def _poll_update(self, data=None) -> bool: + """Request the state from the API.""" + if data is None: + data = self._controller.rachio.device.get( + self._controller.controller_id)[1] + + if data[KEY_STATUS] == STATUS_ONLINE: + return True + if data[KEY_STATUS] == STATUS_OFFLINE: + return False + _LOGGER.warning('"%s" reported in unknown state "%s"', self.name, + data[KEY_STATUS]) + + def _handle_update(self, *args, **kwargs) -> None: + """Handle an update to the state of this sensor.""" + if args[0][KEY_SUBTYPE] == SUBTYPE_ONLINE: + self._state = True + elif args[0][KEY_SUBTYPE] == SUBTYPE_OFFLINE: + self._state = False + + self.schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/raincloud.py b/homeassistant/components/binary_sensor/raincloud.py index 288b46c2370945..3cbd179154fd21 100644 --- a/homeassistant/components/binary_sensor/raincloud.py +++ b/homeassistant/components/binary_sensor/raincloud.py @@ -67,6 +67,6 @@ def icon(self): """Return the icon of this device.""" if self._sensor_type == 'is_watering': return 'mdi:water' if self.is_on else 'mdi:water-off' - elif self._sensor_type == 'status': + if self._sensor_type == 'status': return 'mdi:pipe' if self.is_on else 'mdi:pipe-disconnected' return ICON_MAP.get(self._sensor_type) diff --git a/homeassistant/components/binary_sensor/rainmachine.py b/homeassistant/components/binary_sensor/rainmachine.py new file mode 100644 index 00000000000000..b2f44696fbdc25 --- /dev/null +++ b/homeassistant/components/binary_sensor/rainmachine.py @@ -0,0 +1,103 @@ +""" +This platform provides binary sensors for key RainMachine data. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.rainmachine/ +""" +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.components.rainmachine import ( + BINARY_SENSORS, DATA_RAINMACHINE, SENSOR_UPDATE_TOPIC, TYPE_FREEZE, + TYPE_FREEZE_PROTECTION, TYPE_HOT_DAYS, TYPE_HOURLY, TYPE_MONTH, + TYPE_RAINDELAY, TYPE_RAINSENSOR, TYPE_WEEKDAY, RainMachineEntity) +from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +DEPENDENCIES = ['rainmachine'] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): + """Set up the RainMachine Switch platform.""" + if discovery_info is None: + return + + rainmachine = hass.data[DATA_RAINMACHINE] + + binary_sensors = [] + for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]: + name, icon = BINARY_SENSORS[sensor_type] + binary_sensors.append( + RainMachineBinarySensor(rainmachine, sensor_type, name, icon)) + + async_add_devices(binary_sensors, True) + + +class RainMachineBinarySensor(RainMachineEntity, BinarySensorDevice): + """A sensor implementation for raincloud device.""" + + def __init__(self, rainmachine, sensor_type, name, icon): + """Initialize the sensor.""" + super().__init__(rainmachine) + + self._icon = icon + self._name = name + self._sensor_type = sensor_type + self._state = None + + @property + def icon(self) -> str: + """Return the icon.""" + return self._icon + + @property + def is_on(self): + """Return the status of the sensor.""" + return self._state + + @property + def should_poll(self): + """Disable polling.""" + return False + + @property + def unique_id(self) -> str: + """Return a unique, HASS-friendly identifier for this entity.""" + return '{0}_{1}'.format( + self.rainmachine.device_mac.replace(':', ''), self._sensor_type) + + @callback + def _update_data(self): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SENSOR_UPDATE_TOPIC, self._update_data) + + async def async_update(self): + """Update the state.""" + if self._sensor_type == TYPE_FREEZE: + self._state = self.rainmachine.restrictions['current']['freeze'] + elif self._sensor_type == TYPE_FREEZE_PROTECTION: + self._state = self.rainmachine.restrictions['global'][ + 'freezeProtectEnabled'] + elif self._sensor_type == TYPE_HOT_DAYS: + self._state = self.rainmachine.restrictions['global'][ + 'hotDaysExtraWatering'] + elif self._sensor_type == TYPE_HOURLY: + self._state = self.rainmachine.restrictions['current']['hourly'] + elif self._sensor_type == TYPE_MONTH: + self._state = self.rainmachine.restrictions['current']['month'] + elif self._sensor_type == TYPE_RAINDELAY: + self._state = self.rainmachine.restrictions['current']['rainDelay'] + elif self._sensor_type == TYPE_RAINSENSOR: + self._state = self.rainmachine.restrictions['current'][ + 'rainSensor'] + elif self._sensor_type == TYPE_WEEKDAY: + self._state = self.rainmachine.restrictions['current']['weekDay'] diff --git a/homeassistant/components/binary_sensor/random.py b/homeassistant/components/binary_sensor/random.py index 162d0480389c05..ab6c1e5d479e68 100644 --- a/homeassistant/components/binary_sensor/random.py +++ b/homeassistant/components/binary_sensor/random.py @@ -4,7 +4,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.random/ """ -import asyncio import logging import voluptuous as vol @@ -24,8 +23,8 @@ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): """Set up the Random binary sensor.""" name = config.get(CONF_NAME) device_class = config.get(CONF_DEVICE_CLASS) @@ -57,8 +56,7 @@ def device_class(self): """Return the sensor class of the sensor.""" return self._device_class - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Get new state and update the sensor's state.""" from random import getrandbits self._state = bool(getrandbits(1)) diff --git a/homeassistant/components/binary_sensor/raspihats.py b/homeassistant/components/binary_sensor/raspihats.py index 9d489a59711a3f..9ab56a5a20da75 100644 --- a/homeassistant/components/binary_sensor/raspihats.py +++ b/homeassistant/components/binary_sensor/raspihats.py @@ -42,7 +42,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the raspihats binary_sensor devices.""" I2CHatBinarySensor.I2C_HATS_MANAGER = hass.data[I2C_HATS_MANAGER] diff --git a/homeassistant/components/binary_sensor/rfxtrx.py b/homeassistant/components/binary_sensor/rfxtrx.py index 8c026131fd3152..6ac604a4f1ebb4 100644 --- a/homeassistant/components/binary_sensor/rfxtrx.py +++ b/homeassistant/components/binary_sensor/rfxtrx.py @@ -10,7 +10,7 @@ from homeassistant.components import rfxtrx from homeassistant.components.binary_sensor import ( - PLATFORM_SCHEMA, DEVICE_CLASSES_SCHEMA, BinarySensorDevice) + DEVICE_CLASSES_SCHEMA, PLATFORM_SCHEMA, BinarySensorDevice) from homeassistant.components.rfxtrx import ( ATTR_NAME, CONF_AUTOMATIC_ADD, CONF_DATA_BITS, CONF_DEVICES, CONF_FIRE_EVENT, CONF_OFF_DELAY) @@ -29,8 +29,7 @@ vol.Optional(CONF_DEVICES, default={}): { cv.string: vol.Schema({ vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_DEVICE_CLASS): - DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_DEVICE_CLASS): DEVICE_CLASSES_SCHEMA, vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, vol.Optional(CONF_OFF_DELAY): vol.Any(cv.time_period, cv.positive_timedelta), diff --git a/homeassistant/components/binary_sensor/ring.py b/homeassistant/components/binary_sensor/ring.py index e84009301ab752..4f2ea408e7f46a 100644 --- a/homeassistant/components/binary_sensor/ring.py +++ b/homeassistant/components/binary_sensor/ring.py @@ -23,7 +23,7 @@ _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=5) +SCAN_INTERVAL = timedelta(seconds=10) # Sensor types: Name, category, device_class SENSOR_TYPES = { diff --git a/homeassistant/components/binary_sensor/rpi_gpio.py b/homeassistant/components/binary_sensor/rpi_gpio.py index 2322b1bf49845b..31a518dc1dc112 100644 --- a/homeassistant/components/binary_sensor/rpi_gpio.py +++ b/homeassistant/components/binary_sensor/rpi_gpio.py @@ -8,7 +8,7 @@ import voluptuous as vol -import homeassistant.components.rpi_gpio as rpi_gpio +from homeassistant.components import rpi_gpio from homeassistant.components.binary_sensor import ( BinarySensorDevice, PLATFORM_SCHEMA) from homeassistant.const import DEVICE_DEFAULT_NAME @@ -39,7 +39,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Raspberry PI GPIO devices.""" pull_mode = config.get(CONF_PULL_MODE) @@ -59,7 +58,6 @@ class RPiGPIOBinarySensor(BinarySensorDevice): def __init__(self, name, port, pull_mode, bouncetime, invert_logic): """Initialize the RPi binary sensor.""" - # pylint: disable=no-member self._name = name or DEVICE_DEFAULT_NAME self._port = port self._pull_mode = pull_mode diff --git a/homeassistant/components/binary_sensor/rpi_pfio.py b/homeassistant/components/binary_sensor/rpi_pfio.py index 1abfa25c82b506..a1126bdd2f90ee 100644 --- a/homeassistant/components/binary_sensor/rpi_pfio.py +++ b/homeassistant/components/binary_sensor/rpi_pfio.py @@ -10,7 +10,7 @@ from homeassistant.components.binary_sensor import ( PLATFORM_SCHEMA, BinarySensorDevice) -import homeassistant.components.rpi_pfio as rpi_pfio +from homeassistant.components import rpi_pfio from homeassistant.const import CONF_NAME, DEVICE_DEFAULT_NAME import homeassistant.helpers.config_validation as cv diff --git a/homeassistant/components/binary_sensor/skybell.py b/homeassistant/components/binary_sensor/skybell.py index 734f8e03375e5f..44cad11e3f0979 100644 --- a/homeassistant/components/binary_sensor/skybell.py +++ b/homeassistant/components/binary_sensor/skybell.py @@ -94,4 +94,4 @@ def update(self): self._state = bool(event and event.get('id') != self._event.get('id')) - self._event = event + self._event = event or {} diff --git a/homeassistant/components/binary_sensor/tahoma.py b/homeassistant/components/binary_sensor/tahoma.py new file mode 100644 index 00000000000000..efcfb629f39601 --- /dev/null +++ b/homeassistant/components/binary_sensor/tahoma.py @@ -0,0 +1,98 @@ +""" +Support for Tahoma binary sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.tahoma/ +""" + +import logging +from datetime import timedelta + +from homeassistant.components.binary_sensor import ( + BinarySensorDevice) +from homeassistant.components.tahoma import ( + DOMAIN as TAHOMA_DOMAIN, TahomaDevice) +from homeassistant.const import (STATE_OFF, STATE_ON, ATTR_BATTERY_LEVEL) + +DEPENDENCIES = ['tahoma'] + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(seconds=120) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Tahoma controller devices.""" + _LOGGER.debug("Setup Tahoma Binary sensor platform") + controller = hass.data[TAHOMA_DOMAIN]['controller'] + devices = [] + for device in hass.data[TAHOMA_DOMAIN]['devices']['smoke']: + devices.append(TahomaBinarySensor(device, controller)) + add_devices(devices, True) + + +class TahomaBinarySensor(TahomaDevice, BinarySensorDevice): + """Representation of a Tahoma Binary Sensor.""" + + def __init__(self, tahoma_device, controller): + """Initialize the sensor.""" + super().__init__(tahoma_device, controller) + + self._state = None + self._icon = None + self._battery = None + + @property + def is_on(self): + """Return the state of the sensor.""" + return bool(self._state == STATE_ON) + + @property + def device_class(self): + """Return the class of the device.""" + if self.tahoma_device.type == 'rtds:RTDSSmokeSensor': + return 'smoke' + return None + + @property + def icon(self): + """Icon for device by its type.""" + return self._icon + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attr = {} + super_attr = super().device_state_attributes + if super_attr is not None: + attr.update(super_attr) + + if self._battery is not None: + attr[ATTR_BATTERY_LEVEL] = self._battery + return attr + + def update(self): + """Update the state.""" + self.controller.get_states([self.tahoma_device]) + if self.tahoma_device.type == 'rtds:RTDSSmokeSensor': + if self.tahoma_device.active_states['core:SmokeState']\ + == 'notDetected': + self._state = STATE_OFF + else: + self._state = STATE_ON + + if 'core:SensorDefectState' in self.tahoma_device.active_states: + # Set to 'lowBattery' for low battery warning. + self._battery = self.tahoma_device.active_states[ + 'core:SensorDefectState'] + else: + self._battery = None + + if self._state == STATE_ON: + self._icon = "mdi:fire" + elif self._battery == 'lowBattery': + self._icon = "mdi:battery-alert" + else: + self._icon = None + + _LOGGER.debug("Update %s, state: %s", self._name, self._state) diff --git a/homeassistant/components/binary_sensor/tapsaff.py b/homeassistant/components/binary_sensor/tapsaff.py index c0f6ca3f112257..5b8e133b5f4233 100644 --- a/homeassistant/components/binary_sensor/tapsaff.py +++ b/homeassistant/components/binary_sensor/tapsaff.py @@ -63,7 +63,7 @@ def update(self): self.data.update() -class TapsAffData(object): +class TapsAffData: """Class for handling the data retrieval for pins.""" def __init__(self, location): diff --git a/homeassistant/components/binary_sensor/threshold.py b/homeassistant/components/binary_sensor/threshold.py index 79c36fb2ef2311..39681c894b3cc4 100644 --- a/homeassistant/components/binary_sensor/threshold.py +++ b/homeassistant/components/binary_sensor/threshold.py @@ -86,7 +86,6 @@ def __init__(self, hass, entity_id, name, lower, upper, hysteresis, self._state = False self.sensor_value = None - # pylint: disable=invalid-name @callback def async_threshold_sensor_state_listener( entity, old_state, new_state): @@ -129,9 +128,9 @@ def threshold_type(self): if self._threshold_lower is not None and \ self._threshold_upper is not None: return TYPE_RANGE - elif self._threshold_lower is not None: + if self._threshold_lower is not None: return TYPE_LOWER - elif self._threshold_upper is not None: + if self._threshold_upper is not None: return TYPE_UPPER @property diff --git a/homeassistant/components/binary_sensor/trend.py b/homeassistant/components/binary_sensor/trend.py index 5405a6a77ba57d..78f471d125bc0a 100644 --- a/homeassistant/components/binary_sensor/trend.py +++ b/homeassistant/components/binary_sensor/trend.py @@ -23,7 +23,7 @@ from homeassistant.helpers.event import async_track_state_change from homeassistant.util import utcnow -REQUIREMENTS = ['numpy==1.14.3'] +REQUIREMENTS = ['numpy==1.15.0'] _LOGGER = logging.getLogger(__name__) @@ -57,7 +57,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the trend sensors.""" sensors = [] diff --git a/homeassistant/components/binary_sensor/uptimerobot.py b/homeassistant/components/binary_sensor/uptimerobot.py new file mode 100644 index 00000000000000..9e72d188c99552 --- /dev/null +++ b/homeassistant/components/binary_sensor/uptimerobot.py @@ -0,0 +1,92 @@ +""" +A platform that to monitor Uptime Robot monitors. + +For more details about this platform, please refer to the documentation at +https://www.home-assistant.io/components/binary_sensor.uptimerobot/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.binary_sensor import ( + PLATFORM_SCHEMA, BinarySensorDevice) +from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pyuptimerobot==0.0.5'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_TARGET = 'target' + +CONF_ATTRIBUTION = "Data provided by Uptime Robot" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Uptime Robot binary_sensors.""" + from pyuptimerobot import UptimeRobot + + up_robot = UptimeRobot() + api_key = config.get(CONF_API_KEY) + monitors = up_robot.getMonitors(api_key) + + devices = [] + if not monitors or monitors.get('stat') != 'ok': + _LOGGER.error("Error connecting to Uptime Robot") + return + + for monitor in monitors['monitors']: + devices.append(UptimeRobotBinarySensor( + api_key, up_robot, monitor['id'], monitor['friendly_name'], + monitor['url'])) + + add_devices(devices, True) + + +class UptimeRobotBinarySensor(BinarySensorDevice): + """Representation of a Uptime Robot binary sensor.""" + + def __init__(self, api_key, up_robot, monitor_id, name, target): + """Initialize Uptime Robot the binary sensor.""" + self._api_key = api_key + self._monitor_id = str(monitor_id) + self._name = name + self._target = target + self._up_robot = up_robot + self._state = None + + @property + def name(self): + """Return the name of the binary sensor.""" + return self._name + + @property + def is_on(self): + """Return the state of the binary sensor.""" + return self._state + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return 'connectivity' + + @property + def device_state_attributes(self): + """Return the state attributes of the binary sensor.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_TARGET: self._target, + } + + def update(self): + """Get the latest state of the binary sensor.""" + monitor = self._up_robot.getMonitors(self._api_key, self._monitor_id) + if not monitor or monitor.get('stat') != 'ok': + _LOGGER.warning("Failed to get new state") + return + status = monitor['monitors'][0]['status'] + self._state = 1 if status == 2 else 0 diff --git a/homeassistant/components/binary_sensor/velbus.py b/homeassistant/components/binary_sensor/velbus.py index 214edcf9463856..8438be0d7845df 100644 --- a/homeassistant/components/binary_sensor/velbus.py +++ b/homeassistant/components/binary_sensor/velbus.py @@ -4,93 +4,34 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.velbus/ """ -import asyncio import logging - -import voluptuous as vol - -from homeassistant.const import CONF_NAME, CONF_DEVICES from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.binary_sensor import PLATFORM_SCHEMA -from homeassistant.components.velbus import DOMAIN -import homeassistant.helpers.config_validation as cv - - -DEPENDENCIES = ['velbus'] +from homeassistant.components.velbus import ( + DOMAIN as VELBUS_DOMAIN, VelbusEntity) _LOGGER = logging.getLogger(__name__) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, [ - { - vol.Required('module'): cv.positive_int, - vol.Required('channel'): cv.positive_int, - vol.Required(CONF_NAME): cv.string, - vol.Optional('is_pushbutton'): cv.boolean - } - ]) -}) +DEPENDENCIES = ['velbus'] -def setup_platform(hass, config, add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up Velbus binary sensors.""" - velbus = hass.data[DOMAIN] + if discovery_info is None: + return + sensors = [] + for sensor in discovery_info: + module = hass.data[VELBUS_DOMAIN].get_module(sensor[0]) + channel = sensor[1] + sensors.append(VelbusBinarySensor(module, channel)) + async_add_devices(sensors) - add_devices(VelbusBinarySensor(sensor, velbus) - for sensor in config[CONF_DEVICES]) - -class VelbusBinarySensor(BinarySensorDevice): +class VelbusBinarySensor(VelbusEntity, BinarySensorDevice): """Representation of a Velbus Binary Sensor.""" - def __init__(self, binary_sensor, velbus): - """Initialize a Velbus light.""" - self._velbus = velbus - self._name = binary_sensor[CONF_NAME] - self._module = binary_sensor['module'] - self._channel = binary_sensor['channel'] - self._is_pushbutton = 'is_pushbutton' in binary_sensor \ - and binary_sensor['is_pushbutton'] - self._state = False - - @asyncio.coroutine - def async_added_to_hass(self): - """Add listener for Velbus messages on bus.""" - yield from self.hass.async_add_job( - self._velbus.subscribe, self._on_message) - - def _on_message(self, message): - import velbus - if isinstance(message, velbus.PushButtonStatusMessage): - if message.address == self._module and \ - self._channel in message.get_channels(): - if self._is_pushbutton: - if self._channel in message.closed: - self._toggle() - else: - pass - else: - self._toggle() - - def _toggle(self): - if self._state is True: - self._state = False - else: - self._state = True - self.schedule_update_ha_state() - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def name(self): - """Return the display name of this sensor.""" - return self._name - @property def is_on(self): """Return true if the sensor is on.""" - return self._state + return self._module.is_closed(self._channel) diff --git a/homeassistant/components/binary_sensor/vera.py b/homeassistant/components/binary_sensor/vera.py index e87886376bc307..310e2289cbc9bf 100644 --- a/homeassistant/components/binary_sensor/vera.py +++ b/homeassistant/components/binary_sensor/vera.py @@ -19,8 +19,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Perform the setup for Vera controller devices.""" add_devices( - VeraBinarySensor(device, hass.data[VERA_CONTROLLER]) - for device in hass.data[VERA_DEVICES]['binary_sensor']) + [VeraBinarySensor(device, hass.data[VERA_CONTROLLER]) + for device in hass.data[VERA_DEVICES]['binary_sensor']], True) class VeraBinarySensor(VeraDevice, BinarySensorDevice): diff --git a/homeassistant/components/binary_sensor/verisure.py b/homeassistant/components/binary_sensor/verisure.py index 4a1b99f4b9bc9a..7068d51f6a3632 100644 --- a/homeassistant/components/binary_sensor/verisure.py +++ b/homeassistant/components/binary_sensor/verisure.py @@ -54,6 +54,7 @@ def available(self): "$.doorWindow.doorWindowDevice[?(@.deviceLabel=='%s')]", self._device_label) is not None + # pylint: disable=no-self-use def update(self): """Update the state of the sensor.""" hub.update_overview() diff --git a/homeassistant/components/binary_sensor/volvooncall.py b/homeassistant/components/binary_sensor/volvooncall.py index 39f520ddc6de18..402feefa99f0a2 100644 --- a/homeassistant/components/binary_sensor/volvooncall.py +++ b/homeassistant/components/binary_sensor/volvooncall.py @@ -28,7 +28,7 @@ def is_on(self): val = getattr(self.vehicle, self._attribute) if self._attribute == 'bulb_failures': return bool(val) - elif self._attribute in ['doors', 'windows']: + if self._attribute in ['doors', 'windows']: return any([val[key] for key in val if 'Open' in key]) return val != 'Normal' diff --git a/homeassistant/components/binary_sensor/wemo.py b/homeassistant/components/binary_sensor/wemo.py index 30a7e291401bc0..a589ab4e8c8999 100644 --- a/homeassistant/components/binary_sensor/wemo.py +++ b/homeassistant/components/binary_sensor/wemo.py @@ -13,10 +13,9 @@ _LOGGER = logging.getLogger(__name__) -# pylint: disable=unused-argument, too-many-function-args def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Register discovered WeMo binary sensors.""" - import pywemo.discovery as discovery + from pywemo import discovery if discovery_info is not None: location = discovery_info['ssdp_description'] diff --git a/homeassistant/components/binary_sensor/wirelesstag.py b/homeassistant/components/binary_sensor/wirelesstag.py new file mode 100644 index 00000000000000..bfc2d44fc6e8bd --- /dev/null +++ b/homeassistant/components/binary_sensor/wirelesstag.py @@ -0,0 +1,214 @@ +""" +Binary sensor support for Wireless Sensor Tags. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.wirelesstag/ +""" +import logging + +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.components.binary_sensor import ( + BinarySensorDevice, PLATFORM_SCHEMA) +from homeassistant.components.wirelesstag import ( + DOMAIN as WIRELESSTAG_DOMAIN, + WIRELESSTAG_TYPE_13BIT, WIRELESSTAG_TYPE_WATER, + WIRELESSTAG_TYPE_ALSPRO, + WIRELESSTAG_TYPE_WEMO_DEVICE, + SIGNAL_BINARY_EVENT_UPDATE, + WirelessTagBaseSensor) +from homeassistant.const import ( + CONF_MONITORED_CONDITIONS, STATE_ON, STATE_OFF) +import homeassistant.helpers.config_validation as cv + +DEPENDENCIES = ['wirelesstag'] + +_LOGGER = logging.getLogger(__name__) + +# On means in range, Off means out of range +SENSOR_PRESENCE = 'presence' + +# On means motion detected, Off means cear +SENSOR_MOTION = 'motion' + +# On means open, Off means closed +SENSOR_DOOR = 'door' + +# On means temperature become too cold, Off means normal +SENSOR_COLD = 'cold' + +# On means hot, Off means normal +SENSOR_HEAT = 'heat' + +# On means too dry (humidity), Off means normal +SENSOR_DRY = 'dry' + +# On means too wet (humidity), Off means normal +SENSOR_WET = 'wet' + +# On means light detected, Off means no light +SENSOR_LIGHT = 'light' + +# On means moisture detected (wet), Off means no moisture (dry) +SENSOR_MOISTURE = 'moisture' + +# On means tag battery is low, Off means normal +SENSOR_BATTERY = 'low_battery' + +# Sensor types: Name, device_class, push notification type representing 'on', +# attr to check +SENSOR_TYPES = { + SENSOR_PRESENCE: ['Presence', 'presence', 'is_in_range', { + "on": "oor", + "off": "back_in_range" + }, 2], + SENSOR_MOTION: ['Motion', 'motion', 'is_moved', { + "on": "motion_detected", + }, 5], + SENSOR_DOOR: ['Door', 'door', 'is_door_open', { + "on": "door_opened", + "off": "door_closed" + }, 5], + SENSOR_COLD: ['Cold', 'cold', 'is_cold', { + "on": "temp_toolow", + "off": "temp_normal" + }, 4], + SENSOR_HEAT: ['Heat', 'heat', 'is_heat', { + "on": "temp_toohigh", + "off": "temp_normal" + }, 4], + SENSOR_DRY: ['Too dry', 'dry', 'is_too_dry', { + "on": "too_dry", + "off": "cap_normal" + }, 2], + SENSOR_WET: ['Too wet', 'wet', 'is_too_humid', { + "on": "too_humid", + "off": "cap_normal" + }, 2], + SENSOR_LIGHT: ['Light', 'light', 'is_light_on', { + "on": "too_bright", + "off": "light_normal" + }, 1], + SENSOR_MOISTURE: ['Leak', 'moisture', 'is_leaking', { + "on": "water_detected", + "off": "water_dried", + }, 1], + SENSOR_BATTERY: ['Low Battery', 'battery', 'is_battery_low', { + "on": "low_battery" + }, 3] +} + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_MONITORED_CONDITIONS, default=[]): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the platform for a WirelessTags.""" + platform = hass.data.get(WIRELESSTAG_DOMAIN) + + sensors = [] + tags = platform.tags + for tag in tags.values(): + allowed_sensor_types = WirelessTagBinarySensor.allowed_sensors(tag) + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + if sensor_type in allowed_sensor_types: + sensors.append(WirelessTagBinarySensor(platform, tag, + sensor_type)) + + add_devices(sensors, True) + hass.add_job(platform.install_push_notifications, sensors) + + +class WirelessTagBinarySensor(WirelessTagBaseSensor, BinarySensorDevice): + """A binary sensor implementation for WirelessTags.""" + + @classmethod + def allowed_sensors(cls, tag): + """Return list of allowed sensor types for specific tag type.""" + sensors_map = { + # 13-bit tag - allows everything but not light and moisture + WIRELESSTAG_TYPE_13BIT: [ + SENSOR_PRESENCE, SENSOR_BATTERY, + SENSOR_MOTION, SENSOR_DOOR, + SENSOR_COLD, SENSOR_HEAT, + SENSOR_DRY, SENSOR_WET], + + # Moister/water sensor - temperature and moisture only + WIRELESSTAG_TYPE_WATER: [ + SENSOR_PRESENCE, SENSOR_BATTERY, + SENSOR_COLD, SENSOR_HEAT, + SENSOR_MOISTURE], + + # ALS Pro: allows everything, but not moisture + WIRELESSTAG_TYPE_ALSPRO: [ + SENSOR_PRESENCE, SENSOR_BATTERY, + SENSOR_MOTION, SENSOR_DOOR, + SENSOR_COLD, SENSOR_HEAT, + SENSOR_DRY, SENSOR_WET, + SENSOR_LIGHT], + + # Wemo are power switches. + WIRELESSTAG_TYPE_WEMO_DEVICE: [SENSOR_PRESENCE] + } + + # allow everything if tag type is unknown + # (i just dont have full catalog of them :)) + tag_type = tag.tag_type + fullset = SENSOR_TYPES.keys() + return sensors_map[tag_type] if tag_type in sensors_map else fullset + + def __init__(self, api, tag, sensor_type): + """Initialize a binary sensor for a Wireless Sensor Tags.""" + super().__init__(api, tag) + self._sensor_type = sensor_type + self._name = '{0} {1}'.format(self._tag.name, + SENSOR_TYPES[self._sensor_type][0]) + self._device_class = SENSOR_TYPES[self._sensor_type][1] + self._tag_attr = SENSOR_TYPES[self._sensor_type][2] + self.binary_spec = SENSOR_TYPES[self._sensor_type][3] + self.tag_id_index_template = SENSOR_TYPES[self._sensor_type][4] + + async def async_added_to_hass(self): + """Register callbacks.""" + tag_id = self.tag_id + event_type = self.device_class + async_dispatcher_connect( + self.hass, + SIGNAL_BINARY_EVENT_UPDATE.format(tag_id, event_type), + self._on_binary_event_callback) + + @property + def is_on(self): + """Return True if the binary sensor is on.""" + return self._state == STATE_ON + + @property + def device_class(self): + """Return the class of the binary sensor.""" + return self._device_class + + @property + def principal_value(self): + """Return value of tag. + + Subclasses need override based on type of sensor. + """ + return ( + STATE_ON if getattr(self._tag, self._tag_attr, False) + else STATE_OFF) + + def updated_state_value(self): + """Use raw princial value.""" + return self.principal_value + + @callback + def _on_binary_event_callback(self, event): + """Update state from arrive push notification.""" + # state should be 'on' or 'off' + self._state = event.data.get('state') + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/binary_sensor/workday.py b/homeassistant/components/binary_sensor/workday.py index b37be3f6cb6938..4a9809e9974f1f 100644 --- a/homeassistant/components/binary_sensor/workday.py +++ b/homeassistant/components/binary_sensor/workday.py @@ -17,7 +17,7 @@ _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['holidays==0.9.5'] +REQUIREMENTS = ['holidays==0.9.6'] # List of all countries currently supported by holidays # There seems to be no way to get the list out at runtime @@ -25,9 +25,9 @@ 'Belgium', 'BE', 'Canada', 'CA', 'Colombia', 'CO', 'Czech', 'CZ', 'Denmark', 'DK', 'England', 'EuropeanCentralBank', 'ECB', 'TAR', 'Finland', 'FI', 'France', 'FRA', 'Germany', - 'DE', 'Hungary', 'HU', 'Ireland', 'Isle of Man', 'Italy', - 'IT', 'Japan', 'JP', 'Mexico', 'MX', 'Netherlands', 'NL', - 'NewZealand', 'NZ', 'Northern Ireland', + 'DE', 'Hungary', 'HU', 'India', 'IND', 'Ireland', + 'Isle of Man', 'Italy', 'IT', 'Japan', 'JP', 'Mexico', 'MX', + 'Netherlands', 'NL', 'NewZealand', 'NZ', 'Northern Ireland', 'Norway', 'NO', 'Polish', 'PL', 'Portugal', 'PT', 'PortugalExt', 'PTE', 'Scotland', 'Slovenia', 'SI', 'Slovakia', 'SK', 'South Africa', 'ZA', 'Spain', 'ES', @@ -135,7 +135,7 @@ def is_include(self, day, now): """Check if given day is in the includes list.""" if day in self._workdays: return True - elif 'holiday' in self._workdays and now in self._obj_holidays: + if 'holiday' in self._workdays and now in self._obj_holidays: return True return False @@ -144,7 +144,7 @@ def is_exclude(self, day, now): """Check if given day is in the excludes list.""" if day in self._excludes: return True - elif 'holiday' in self._excludes and now in self._obj_holidays: + if 'holiday' in self._excludes and now in self._obj_holidays: return True return False diff --git a/homeassistant/components/binary_sensor/xiaomi_aqara.py b/homeassistant/components/binary_sensor/xiaomi_aqara.py index 49f716b9eb7b99..2a9746b4a01ad5 100644 --- a/homeassistant/components/binary_sensor/xiaomi_aqara.py +++ b/homeassistant/components/binary_sensor/xiaomi_aqara.py @@ -28,7 +28,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if model in ['motion', 'sensor_motion', 'sensor_motion.aq2']: devices.append(XiaomiMotionSensor(device, hass, gateway)) elif model in ['magnet', 'sensor_magnet', 'sensor_magnet.aq2']: - devices.append(XiaomiDoorSensor(device, gateway)) + if 'proto' not in device or int(device['proto'][0:1]) == 1: + data_key = 'status' + else: + data_key = 'window_status' + devices.append(XiaomiDoorSensor(device, data_key, gateway)) elif model == 'sensor_wleak.aq1': devices.append(XiaomiWaterLeakSensor(device, gateway)) elif model in ['smoke', 'sensor_smoke']: @@ -43,17 +47,17 @@ def setup_platform(hass, config, add_devices, discovery_info=None): data_key = 'channel_0' devices.append(XiaomiButton(device, 'Switch', data_key, hass, gateway)) - elif model in ['86sw1', 'sensor_86sw1.aq1']: + elif model in ['86sw1', 'sensor_86sw1', 'sensor_86sw1.aq1']: devices.append(XiaomiButton(device, 'Wall Switch', 'channel_0', hass, gateway)) - elif model in ['86sw2', 'sensor_86sw2.aq1']: + elif model in ['86sw2', 'sensor_86sw2', 'sensor_86sw2.aq1']: devices.append(XiaomiButton(device, 'Wall Switch (Left)', 'channel_0', hass, gateway)) devices.append(XiaomiButton(device, 'Wall Switch (Right)', 'channel_1', hass, gateway)) devices.append(XiaomiButton(device, 'Wall Switch (Both)', 'dual_channel', hass, gateway)) - elif model in ['cube', 'sensor_cube']: + elif model in ['cube', 'sensor_cube', 'sensor_cube.aqgl01']: devices.append(XiaomiCube(device, hass, gateway)) add_devices(devices) @@ -120,7 +124,7 @@ def parse_data(self, data, raw_data): return False self._state = True return True - elif value == '0': + if value == '0': if self._state: self._state = False return True @@ -180,7 +184,7 @@ def parse_data(self, data, raw_data): return False self._state = True return True - elif value == NO_MOTION: + if value == NO_MOTION: if not self._state: return False self._state = False @@ -190,11 +194,11 @@ def parse_data(self, data, raw_data): class XiaomiDoorSensor(XiaomiBinarySensor): """Representation of a XiaomiDoorSensor.""" - def __init__(self, device, xiaomi_hub): + def __init__(self, device, data_key, xiaomi_hub): """Initialize the XiaomiDoorSensor.""" self._open_since = 0 XiaomiBinarySensor.__init__(self, device, 'Door Window Sensor', - xiaomi_hub, 'status', 'opening') + xiaomi_hub, data_key, 'opening') @property def device_state_attributes(self): @@ -220,7 +224,7 @@ def parse_data(self, data, raw_data): return False self._state = True return True - elif value == 'close': + if value == 'close': self._open_since = 0 if self._state: self._state = False @@ -250,7 +254,7 @@ def parse_data(self, data, raw_data): return False self._state = True return True - elif value == 'no_leak': + if value == 'no_leak': if self._state: self._state = False return True @@ -286,7 +290,7 @@ def parse_data(self, data, raw_data): return False self._state = True return True - elif value == '0': + if value == '0': if self._state: self._state = False return True @@ -330,6 +334,8 @@ def parse_data(self, data, raw_data): click_type = 'both' elif value == 'shake': click_type = 'shake' + elif value in ['long_click', 'long_both_click']: + return False else: _LOGGER.warning("Unsupported click_type detected: %s", value) return False diff --git a/homeassistant/components/binary_sensor/zha.py b/homeassistant/components/binary_sensor/zha.py index 756323f41d961a..224d694e0f5b63 100644 --- a/homeassistant/components/binary_sensor/zha.py +++ b/homeassistant/components/binary_sensor/zha.py @@ -108,7 +108,7 @@ def should_poll(self) -> bool: @property def is_on(self) -> bool: """Return True if entity is on.""" - if self._state == 'unknown': + if self._state is None: return False return bool(self._state) @@ -133,7 +133,8 @@ async def async_update(self): from bellows.types.basic import uint16_t result = await zha.safe_read(self._endpoint.ias_zone, - ['zone_status']) + ['zone_status'], + allow_cache=False) state = result.get('zone_status', self._state) if isinstance(state, (int, uint16_t)): self._state = result.get('zone_status', self._state) & 3 @@ -186,8 +187,8 @@ def cluster_command(self, tsn, command_id, args): if args[0] == 0xff: rate = 10 # Should read default move rate self._entity.move_level(-rate if args[0] else rate) - elif command_id == 0x0002: # step - # Step (technically shouldn't change on/off) + elif command_id in (0x0002, 0x0006): # step, -with_on_off + # Step (technically may change on/off) self._entity.move_level(-args[1] if args[0] else args[1]) def attribute_update(self, attrid, value): @@ -202,14 +203,19 @@ def zdo_command(self, *args, **kwargs): def __init__(self, **kwargs): """Initialize Switch.""" super().__init__(**kwargs) - self._state = True - self._level = 255 + self._state = False + self._level = 0 from zigpy.zcl.clusters import general self._out_listeners = { general.OnOff.cluster_id: self.OnOffListener(self), general.LevelControl.cluster_id: self.LevelListener(self), } + @property + def should_poll(self) -> bool: + """Let zha handle polling.""" + return False + @property def is_on(self) -> bool: """Return true if the binary sensor is on.""" @@ -218,7 +224,10 @@ def is_on(self) -> bool: @property def device_state_attributes(self): """Return the device state attributes.""" - return {'level': self._state and self._level or 0} + self._device_state_attributes.update({ + 'level': self._state and self._level or 0 + }) + return self._device_state_attributes def move_level(self, change): """Increment the level, setting state if appropriate.""" diff --git a/homeassistant/components/binary_sensor/zwave.py b/homeassistant/components/binary_sensor/zwave.py index fc18648f907978..784a96d8615733 100644 --- a/homeassistant/components/binary_sensor/zwave.py +++ b/homeassistant/components/binary_sensor/zwave.py @@ -10,7 +10,7 @@ from homeassistant.helpers.event import track_point_in_time from homeassistant.components import zwave from homeassistant.components.zwave import workaround -from homeassistant.components.zwave import async_setup_platform # noqa # pylint: disable=unused-import +from homeassistant.components.zwave import async_setup_platform # noqa pylint: disable=unused-import from homeassistant.components.binary_sensor import ( DOMAIN, BinarySensorDevice) diff --git a/homeassistant/components/blink.py b/homeassistant/components/blink.py index a44f0163787b8f..e84643711ebb61 100644 --- a/homeassistant/components/blink.py +++ b/homeassistant/components/blink.py @@ -40,7 +40,7 @@ }) -class BlinkSystem(object): +class BlinkSystem: """Blink System class.""" def __init__(self, config_info): diff --git a/homeassistant/components/bloomsky.py b/homeassistant/components/bloomsky.py index f04e0af7be9aba..00377b3f12bd7c 100644 --- a/homeassistant/components/bloomsky.py +++ b/homeassistant/components/bloomsky.py @@ -34,7 +34,6 @@ }, extra=vol.ALLOW_EXTRA) -# pylint: disable=unused-argument def setup(hass, config): """Set up the BloomSky component.""" api_key = config[DOMAIN][CONF_API_KEY] @@ -51,7 +50,7 @@ def setup(hass, config): return True -class BloomSky(object): +class BloomSky: """Handle all communication with the BloomSky API.""" # API documentation at http://weatherlution.com/bloomsky-api/ diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index 347bab6f529405..061b09c1b3b52d 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers.event import track_utc_time_change import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['bimmer_connected==0.5.0'] +REQUIREMENTS = ['bimmer_connected==0.5.1'] _LOGGER = logging.getLogger(__name__) @@ -118,7 +118,7 @@ def execute_service(call): return cd_account -class BMWConnectedDriveAccount(object): +class BMWConnectedDriveAccount: """Representation of a BMW vehicle.""" def __init__(self, username: str, password: str, region_str: str, diff --git a/homeassistant/components/bmw_connected_drive/services.yaml b/homeassistant/components/bmw_connected_drive/services.yaml index 3c180271919259..b9605429a8efa0 100644 --- a/homeassistant/components/bmw_connected_drive/services.yaml +++ b/homeassistant/components/bmw_connected_drive/services.yaml @@ -27,7 +27,7 @@ activate_air_conditioning: description: > Start the air conditioning of the vehicle. What exactly is started here depends on the type of vehicle. It might range from just ventilation over - auxilary heating to real air conditioning. The vehicle is identified via + auxiliary heating to real air conditioning. The vehicle is identified via the vin (see below). fields: vin: @@ -39,4 +39,4 @@ update_state: description: > Fetch the last state of the vehicles of all your accounts from the BMW server. This does *not* trigger an update from the vehicle, it just gets - the data from the BMW servers. This service does not require any attributes. \ No newline at end of file + the data from the BMW servers. This service does not require any attributes. diff --git a/homeassistant/components/calendar/__init__.py b/homeassistant/components/calendar/__init__.py index 5198381b9767a3..9d105fb02d0ffe 100644 --- a/homeassistant/components/calendar/__init__.py +++ b/homeassistant/components/calendar/__init__.py @@ -4,11 +4,12 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/calendar/ """ -import asyncio import logging from datetime import timedelta import re +from aiohttp import web + from homeassistant.components.google import ( CONF_OFFSET, CONF_DEVICE_ID, CONF_NAME) from homeassistant.const import STATE_OFF, STATE_ON @@ -18,23 +19,33 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.template import DATE_STR_FORMAT from homeassistant.util import dt +from homeassistant.components import http + _LOGGER = logging.getLogger(__name__) DOMAIN = 'calendar' +DEPENDENCIES = ['http'] + ENTITY_ID_FORMAT = DOMAIN + '.{}' SCAN_INTERVAL = timedelta(seconds=60) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Track states and offer events for calendars.""" component = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL, DOMAIN) - yield from component.async_setup(config) + hass.http.register_view(CalendarListView(component)) + hass.http.register_view(CalendarEventView(component)) + + # Doesn't work in prod builds of the frontend: home-assistant-polymer#1289 + # await hass.components.frontend.async_register_built_in_panel( + # 'calendar', 'calendar', 'hass:calendar') + + await component.async_setup(config) return True @@ -42,7 +53,14 @@ def async_setup(hass, config): DEFAULT_CONF_OFFSET = '!!' -# pylint: disable=too-many-instance-attributes +def get_date(date): + """Get the dateTime from date or dateTime as a local.""" + if 'date' in date: + return dt.start_of_local_day(dt.dt.datetime.combine( + dt.parse_date(date['date']), dt.dt.time.min)) + return dt.as_local(dt.parse_datetime(date['dateTime'])) + + class CalendarEventDevice(Entity): """A calendar event device.""" @@ -50,7 +68,6 @@ class CalendarEventDevice(Entity): # with an update() method data = None - # pylint: disable=too-many-arguments def __init__(self, hass, data): """Create the Calendar Event Device.""" self._name = data.get(CONF_NAME) @@ -113,7 +130,7 @@ def state(self): now = dt.now() - if start <= now and end > now: + if start <= now < end: return STATE_ON if now >= end: @@ -144,15 +161,8 @@ def update(self): self.cleanup() return - def _get_date(date): - """Get the dateTime from date or dateTime as a local.""" - if 'date' in date: - return dt.start_of_local_day(dt.dt.datetime.combine( - dt.parse_date(date['date']), dt.dt.time.min)) - return dt.as_local(dt.parse_datetime(date['dateTime'])) - - start = _get_date(self.data.event['start']) - end = _get_date(self.data.event['end']) + start = get_date(self.data.event['start']) + end = get_date(self.data.event['end']) summary = self.data.event.get('summary', '') @@ -176,10 +186,61 @@ def _get_date(date): # cleanup the string so we don't have a bunch of double+ spaces self._cal_data['message'] = re.sub(' +', '', summary).strip() - self._cal_data['offset_time'] = offset_time self._cal_data['location'] = self.data.event.get('location', '') self._cal_data['description'] = self.data.event.get('description', '') self._cal_data['start'] = start self._cal_data['end'] = end self._cal_data['all_day'] = 'date' in self.data.event['start'] + + +class CalendarEventView(http.HomeAssistantView): + """View to retrieve calendar content.""" + + url = '/api/calendars/{entity_id}' + name = 'api:calendars:calendar' + + def __init__(self, component): + """Initialize calendar view.""" + self.component = component + + async def get(self, request, entity_id): + """Return calendar events.""" + entity = self.component.get_entity(entity_id) + start = request.query.get('start') + end = request.query.get('end') + if None in (start, end, entity): + return web.Response(status=400) + try: + start_date = dt.parse_datetime(start) + end_date = dt.parse_datetime(end) + except (ValueError, AttributeError): + return web.Response(status=400) + event_list = await entity.async_get_events( + request.app['hass'], start_date, end_date) + return self.json(event_list) + + +class CalendarListView(http.HomeAssistantView): + """View to retrieve calendar list.""" + + url = '/api/calendars' + name = "api:calendars" + + def __init__(self, component): + """Initialize calendar view.""" + self.component = component + + async def get(self, request): + """Retrieve calendar list.""" + get_state = request.app['hass'].states.get + calendar_list = [] + + for entity in self.component.entities: + state = get_state(entity.entity_id) + calendar_list.append({ + "name": state.name, + "entity_id": entity.entity_id, + }) + + return self.json(sorted(calendar_list, key=lambda x: x['name'])) diff --git a/homeassistant/components/calendar/caldav.py b/homeassistant/components/calendar/caldav.py index 6f92891c551d76..3db24790aaf4f4 100644 --- a/homeassistant/components/calendar/caldav.py +++ b/homeassistant/components/calendar/caldav.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.components.calendar import ( - PLATFORM_SCHEMA, CalendarEventDevice) + PLATFORM_SCHEMA, CalendarEventDevice, get_date) from homeassistant.const import ( CONF_NAME, CONF_PASSWORD, CONF_URL, CONF_USERNAME) import homeassistant.helpers.config_validation as cv @@ -92,7 +92,7 @@ def setup_platform(hass, config, add_devices, disc_info=None): if not config.get(CONF_CUSTOM_CALENDARS): device_data = { CONF_NAME: calendar.name, - CONF_DEVICE_ID: calendar.name + CONF_DEVICE_ID: calendar.name, } calendar_devices.append( WebDavCalendarEventDevice(hass, device_data, calendar) @@ -120,8 +120,12 @@ def device_state_attributes(self): attributes = super().device_state_attributes return attributes + async def async_get_events(self, hass, start_date, end_date): + """Get all events in a specific time frame.""" + return await self.data.async_get_events(hass, start_date, end_date) -class WebDavCalendarData(object): + +class WebDavCalendarData: """Class to utilize the calendar dav client object to get next event.""" def __init__(self, calendar, include_all_day, search): @@ -131,6 +135,33 @@ def __init__(self, calendar, include_all_day, search): self.search = search self.event = None + async def async_get_events(self, hass, start_date, end_date): + """Get all events in a specific time frame.""" + # Get event list from the current calendar + vevent_list = await hass.async_add_job(self.calendar.date_search, + start_date, end_date) + event_list = [] + for event in vevent_list: + vevent = event.instance.vevent + uid = None + if hasattr(vevent, 'uid'): + uid = vevent.uid.value + data = { + "uid": uid, + "title": vevent.summary.value, + "start": self.get_hass_date(vevent.dtstart.value), + "end": self.get_hass_date(self.get_end_date(vevent)), + "location": self.get_attr_value(vevent, "location"), + "description": self.get_attr_value(vevent, "description"), + } + + data['start'] = get_date(data['start']).isoformat() + data['end'] = get_date(data['end']).isoformat() + + event_list.append(data) + + return event_list + @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data.""" diff --git a/homeassistant/components/calendar/demo.py b/homeassistant/components/calendar/demo.py index 7823f03c85ecf4..0bf09f6f2c7646 100644 --- a/homeassistant/components/calendar/demo.py +++ b/homeassistant/components/calendar/demo.py @@ -4,8 +4,10 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ +import copy + import homeassistant.util.dt as dt_util -from homeassistant.components.calendar import CalendarEventDevice +from homeassistant.components.calendar import CalendarEventDevice, get_date from homeassistant.components.google import CONF_DEVICE_ID, CONF_NAME @@ -15,25 +17,35 @@ def setup_platform(hass, config, add_devices, discovery_info=None): calendar_data_current = DemoGoogleCalendarDataCurrent() add_devices([ DemoGoogleCalendar(hass, calendar_data_future, { - CONF_NAME: 'Future Event', - CONF_DEVICE_ID: 'future_event', + CONF_NAME: 'Calendar 1', + CONF_DEVICE_ID: 'calendar_1', }), DemoGoogleCalendar(hass, calendar_data_current, { - CONF_NAME: 'Current Event', - CONF_DEVICE_ID: 'current_event', + CONF_NAME: 'Calendar 2', + CONF_DEVICE_ID: 'calendar_2', }), ]) -class DemoGoogleCalendarData(object): +class DemoGoogleCalendarData: """Representation of a Demo Calendar element.""" + event = {} + # pylint: disable=no-self-use def update(self): """Return true so entity knows we have new data.""" return True + async def async_get_events(self, hass, start_date, end_date): + """Get all events in a specific time frame.""" + event = copy.copy(self.event) + event['title'] = event['summary'] + event['start'] = get_date(event['start']).isoformat() + event['end'] = get_date(event['end']).isoformat() + return [event] + class DemoGoogleCalendarDataFuture(DemoGoogleCalendarData): """Representation of a Demo Calendar for a future event.""" @@ -80,3 +92,7 @@ def __init__(self, hass, calendar_data, data): """Initialize Google Calendar but without the API calls.""" self.data = calendar_data super().__init__(hass, data) + + async def async_get_events(self, hass, start_date, end_date): + """Get all events in a specific time frame.""" + return await self.data.async_get_events(hass, start_date, end_date) diff --git a/homeassistant/components/calendar/google.py b/homeassistant/components/calendar/google.py index 6c26c65ebe77fd..925bbcacddf1dd 100644 --- a/homeassistant/components/calendar/google.py +++ b/homeassistant/components/calendar/google.py @@ -4,7 +4,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/binary_sensor.google_calendar/ """ -# pylint: disable=import-error import logging from datetime import timedelta @@ -51,8 +50,12 @@ def __init__(self, hass, calendar_service, calendar, data): super().__init__(hass, data) + async def async_get_events(self, hass, start_date, end_date): + """Get all events in a specific time frame.""" + return await self.data.async_get_events(hass, start_date, end_date) -class GoogleCalendarData(object): + +class GoogleCalendarData: """Class to utilize calendar service object to get next event.""" def __init__(self, calendar_service, calendar_id, search, @@ -64,9 +67,7 @@ def __init__(self, calendar_service, calendar_id, search, self.ignore_availability = ignore_availability self.event = None - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Get the latest data.""" + def _prepare_query(self): from httplib2 import ServerNotFoundError try: @@ -74,14 +75,40 @@ def update(self): except ServerNotFoundError: _LOGGER.warning("Unable to connect to Google, using cached data") return False - params = dict(DEFAULT_GOOGLE_SEARCH_PARAMS) - params['timeMin'] = dt.now().isoformat('T') params['calendarId'] = self.calendar_id if self.search: params['q'] = self.search - events = service.events() # pylint: disable=no-member + return service, params + + async def async_get_events(self, hass, start_date, end_date): + """Get all events in a specific time frame.""" + service, params = await hass.async_add_job(self._prepare_query) + params['timeMin'] = start_date.isoformat('T') + params['timeMax'] = end_date.isoformat('T') + + events = await hass.async_add_job(service.events) + result = await hass.async_add_job(events.list(**params).execute) + + items = result.get('items', []) + event_list = [] + for item in items: + if (not self.ignore_availability + and 'transparency' in item.keys()): + if item['transparency'] == 'opaque': + event_list.append(item) + else: + event_list.append(item) + return event_list + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Get the latest data.""" + service, params = self._prepare_query() + params['timeMin'] = dt.now().isoformat('T') + + events = service.events() result = events.list(**params).execute() items = result.get('items', []) diff --git a/homeassistant/components/calendar/todoist.py b/homeassistant/components/calendar/todoist.py index b70e44456db822..ba1f60027ba3cb 100644 --- a/homeassistant/components/calendar/todoist.py +++ b/homeassistant/components/calendar/todoist.py @@ -257,6 +257,10 @@ def cleanup(self): super().cleanup() self._cal_data[ALL_TASKS] = [] + async def async_get_events(self, hass, start_date, end_date): + """Get all events in a specific time frame.""" + return await self.data.async_get_events(hass, start_date, end_date) + @property def device_state_attributes(self): """Return the device state attributes.""" @@ -276,7 +280,7 @@ def device_state_attributes(self): return attributes -class TodoistProjectData(object): +class TodoistProjectData: """ Class used by the Task Device service object to hold all Todoist Tasks. @@ -485,6 +489,31 @@ def select_best_task(project_tasks): continue return event + async def async_get_events(self, hass, start_date, end_date): + """Get all tasks in a specific time frame.""" + if self._id is None: + project_task_data = [ + task for task in self._api.state[TASKS] + if not self._project_id_whitelist or + task[PROJECT_ID] in self._project_id_whitelist] + else: + project_task_data = self._api.projects.get_data(self._id)[TASKS] + + events = [] + time_format = '%a %d %b %Y %H:%M:%S %z' + for task in project_task_data: + due_date = datetime.strptime(task['due_date_utc'], time_format) + if start_date < due_date < end_date: + event = { + 'uid': task['id'], + 'title': task['content'], + 'start': due_date.isoformat(), + 'end': due_date.isoformat(), + 'allDay': True, + } + events.append(event) + return events + @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data.""" diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index c1f92965198f49..736bcec1e9ca39 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -1,4 +1,3 @@ -# pylint: disable=too-many-lines """ Component to interface with cameras. @@ -20,7 +19,8 @@ import voluptuous as vol from homeassistant.core import callback -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, SERVICE_TURN_OFF, \ + SERVICE_TURN_ON from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import bind_hass from homeassistant.helpers.entity import Entity @@ -48,6 +48,9 @@ STATE_STREAMING = 'streaming' STATE_IDLE = 'idle' +# Bitfield of features supported by the camera entity +SUPPORT_ON_OFF = 1 + DEFAULT_CONTENT_TYPE = 'image/jpeg' ENTITY_IMAGE_URL = '/api/camera_proxy/{0}?token={1}' @@ -67,8 +70,8 @@ WS_TYPE_CAMERA_THUMBNAIL = 'camera_thumbnail' SCHEMA_WS_CAMERA_THUMBNAIL = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - 'type': WS_TYPE_CAMERA_THUMBNAIL, - 'entity_id': cv.entity_id + vol.Required('type'): WS_TYPE_CAMERA_THUMBNAIL, + vol.Required('entity_id'): cv.entity_id }) @@ -80,6 +83,35 @@ class Image: content = attr.ib(type=bytes) +@bind_hass +def turn_off(hass, entity_id=None): + """Turn off camera.""" + hass.add_job(async_turn_off, hass, entity_id) + + +@bind_hass +async def async_turn_off(hass, entity_id=None): + """Turn off camera.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} + await hass.services.async_call(DOMAIN, SERVICE_TURN_OFF, data) + + +@bind_hass +def turn_on(hass, entity_id=None): + """Turn on camera.""" + hass.add_job(async_turn_on, hass, entity_id) + + +@bind_hass +async def async_turn_on(hass, entity_id=None): + """Turn on camera, and set operation mode.""" + data = {} + if entity_id is not None: + data[ATTR_ENTITY_ID] = entity_id + + await hass.services.async_call(DOMAIN, SERVICE_TURN_ON, data) + + @bind_hass def enable_motion_detection(hass, entity_id=None): """Enable Motion Detection.""" @@ -97,6 +129,7 @@ def disable_motion_detection(hass, entity_id=None): @bind_hass +@callback def async_snapshot(hass, filename, entity_id=None): """Make a snapshot from a camera.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else {} @@ -119,6 +152,9 @@ async def async_get_image(hass, entity_id, timeout=10): if camera is None: raise HomeAssistantError('Camera not found') + if not camera.is_on: + raise HomeAssistantError('Camera is off') + with suppress(asyncio.CancelledError, asyncio.TimeoutError): with async_timeout.timeout(timeout, loop=hass.loop): image = await camera.async_camera_image() @@ -129,8 +165,7 @@ async def async_get_image(hass, entity_id, timeout=10): raise HomeAssistantError('Unable to get image') -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up the camera component.""" component = hass.data[DOMAIN] = \ EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) @@ -142,7 +177,7 @@ def async_setup(hass, config): SCHEMA_WS_CAMERA_THUMBNAIL ) - yield from component.async_setup(config) + await component.async_setup(config) @callback def update_tokens(time): @@ -154,27 +189,31 @@ def update_tokens(time): hass.helpers.event.async_track_time_interval( update_tokens, TOKEN_CHANGE_INTERVAL) - @asyncio.coroutine - def async_handle_camera_service(service): + async def async_handle_camera_service(service): """Handle calls to the camera services.""" target_cameras = component.async_extract_from_service(service) update_tasks = [] for camera in target_cameras: if service.service == SERVICE_ENABLE_MOTION: - yield from camera.async_enable_motion_detection() + await camera.async_enable_motion_detection() elif service.service == SERVICE_DISABLE_MOTION: - yield from camera.async_disable_motion_detection() + await camera.async_disable_motion_detection() + elif service.service == SERVICE_TURN_OFF and \ + camera.supported_features & SUPPORT_ON_OFF: + await camera.async_turn_off() + elif service.service == SERVICE_TURN_ON and \ + camera.supported_features & SUPPORT_ON_OFF: + await camera.async_turn_on() if not camera.should_poll: continue update_tasks.append(camera.async_update_ha_state(True)) if update_tasks: - yield from asyncio.wait(update_tasks, loop=hass.loop) + await asyncio.wait(update_tasks, loop=hass.loop) - @asyncio.coroutine - def async_handle_snapshot_service(service): + async def async_handle_snapshot_service(service): """Handle snapshot services calls.""" target_cameras = component.async_extract_from_service(service) filename = service.data[ATTR_FILENAME] @@ -190,7 +229,7 @@ def async_handle_snapshot_service(service): "Can't write %s, no access to path!", snapshot_file) continue - image = yield from camera.async_camera_image() + image = await camera.async_camera_image() def _write_image(to_file, image_data): """Executor helper to write image.""" @@ -198,11 +237,17 @@ def _write_image(to_file, image_data): img_file.write(image_data) try: - yield from hass.async_add_job( + await hass.async_add_job( _write_image, snapshot_file, image) except OSError as err: _LOGGER.error("Can't write image to file: %s", err) + hass.services.async_register( + DOMAIN, SERVICE_TURN_OFF, async_handle_camera_service, + schema=CAMERA_SERVICE_SCHEMA) + hass.services.async_register( + DOMAIN, SERVICE_TURN_ON, async_handle_camera_service, + schema=CAMERA_SERVICE_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_ENABLE_MOTION, async_handle_camera_service, schema=CAMERA_SERVICE_SCHEMA) @@ -216,6 +261,16 @@ def _write_image(to_file, image_data): return True +async def async_setup_entry(hass, entry): + """Setup a config entry.""" + return await hass.data[DOMAIN].async_setup_entry(entry) + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + class Camera(Entity): """The base class for camera entities.""" @@ -236,6 +291,11 @@ def entity_picture(self): """Return a link to the camera feed as entity picture.""" return ENTITY_IMAGE_URL.format(self.entity_id, self.access_tokens[-1]) + @property + def supported_features(self): + """Flag supported features.""" + return 0 + @property def is_recording(self): """Return true if the device is recording.""" @@ -256,10 +316,16 @@ def model(self): """Return the camera model.""" return None + @property + def frame_interval(self): + """Return the interval between frames of the mjpeg stream.""" + return 0.5 + def camera_image(self): """Return bytes of camera image.""" raise NotImplementedError() + @callback def async_camera_image(self): """Return bytes of camera image. @@ -272,10 +338,6 @@ async def handle_async_still_stream(self, request, interval): This method must be run in the event loop. """ - if interval < MIN_STREAM_INTERVAL: - raise ValueError("Stream interval must be be > {}" - .format(MIN_STREAM_INTERVAL)) - response = web.StreamResponse() response.content_type = ('multipart/x-mixed-replace; ' 'boundary=--frameboundary') @@ -292,31 +354,23 @@ async def write_to_mjpeg_stream(img_bytes): last_image = None - try: - while True: - img_bytes = await self.async_camera_image() - if not img_bytes: - break - - if img_bytes and img_bytes != last_image: - await write_to_mjpeg_stream(img_bytes) - - # Chrome seems to always ignore first picture, - # print it twice. - if last_image is None: - await write_to_mjpeg_stream(img_bytes) + while True: + img_bytes = await self.async_camera_image() + if not img_bytes: + break - last_image = img_bytes + if img_bytes and img_bytes != last_image: + await write_to_mjpeg_stream(img_bytes) - await asyncio.sleep(interval) + # Chrome seems to always ignore first picture, + # print it twice. + if last_image is None: + await write_to_mjpeg_stream(img_bytes) + last_image = img_bytes - except asyncio.CancelledError: - _LOGGER.debug("Stream closed by frontend.") - response = None + await asyncio.sleep(interval) - finally: - if response is not None: - await response.write_eof() + return response async def handle_async_mjpeg_stream(self, request): """Serve an HTTP MJPEG stream from the camera. @@ -325,22 +379,45 @@ async def handle_async_mjpeg_stream(self, request): a direct stream from the camera. This method must be run in the event loop. """ - await self.handle_async_still_stream(request, - FALLBACK_STREAM_INTERVAL) + await self.handle_async_still_stream(request, self.frame_interval) @property def state(self): """Return the camera state.""" if self.is_recording: return STATE_RECORDING - elif self.is_streaming: + if self.is_streaming: return STATE_STREAMING return STATE_IDLE + @property + def is_on(self): + """Return true if on.""" + return True + + def turn_off(self): + """Turn off camera.""" + raise NotImplementedError() + + @callback + def async_turn_off(self): + """Turn off camera.""" + return self.hass.async_add_job(self.turn_off) + + def turn_on(self): + """Turn off camera.""" + raise NotImplementedError() + + @callback + def async_turn_on(self): + """Turn off camera.""" + return self.hass.async_add_job(self.turn_on) + def enable_motion_detection(self): """Enable motion detection in the camera.""" raise NotImplementedError() + @callback def async_enable_motion_detection(self): """Call the job and enable motion detection.""" return self.hass.async_add_job(self.enable_motion_detection) @@ -349,6 +426,7 @@ def disable_motion_detection(self): """Disable motion detection in camera.""" raise NotImplementedError() + @callback def async_disable_motion_detection(self): """Call the job and disable motion detection.""" return self.hass.async_add_job(self.disable_motion_detection) @@ -388,26 +466,26 @@ def __init__(self, component): """Initialize a basic camera view.""" self.component = component - @asyncio.coroutine - def get(self, request, entity_id): + async def get(self, request, entity_id): """Start a GET request.""" camera = self.component.get_entity(entity_id) if camera is None: - status = 404 if request[KEY_AUTHENTICATED] else 401 - return web.Response(status=status) + raise web.HTTPNotFound() authenticated = (request[KEY_AUTHENTICATED] or request.query.get('token') in camera.access_tokens) if not authenticated: - return web.Response(status=401) + raise web.HTTPUnauthorized() - response = yield from self.handle(request, camera) - return response + if not camera.is_on: + _LOGGER.debug('Camera is off.') + raise web.HTTPServiceUnavailable() - @asyncio.coroutine - def handle(self, request, camera): + return await self.handle(request, camera) + + async def handle(self, request, camera): """Handle the camera request.""" raise NotImplementedError() @@ -418,18 +496,17 @@ class CameraImageView(CameraView): url = '/api/camera_proxy/{entity_id}' name = 'api:camera:image' - @asyncio.coroutine - def handle(self, request, camera): + async def handle(self, request, camera): """Serve camera image.""" with suppress(asyncio.CancelledError, asyncio.TimeoutError): with async_timeout.timeout(10, loop=request.app['hass'].loop): - image = yield from camera.async_camera_image() + image = await camera.async_camera_image() if image: return web.Response(body=image, content_type=camera.content_type) - return web.Response(status=500) + raise web.HTTPInternalServerError() class CameraMjpegStream(CameraView): @@ -442,16 +519,17 @@ async def handle(self, request, camera): """Serve camera stream, possibly with interval.""" interval = request.query.get('interval') if interval is None: - await camera.handle_async_mjpeg_stream(request) - return + return await camera.handle_async_mjpeg_stream(request) try: # Compose camera stream from stills interval = float(request.query.get('interval')) - await camera.handle_async_still_stream(request, interval) - return + if interval < MIN_STREAM_INTERVAL: + raise ValueError("Stream interval must be be > {}" + .format(MIN_STREAM_INTERVAL)) + return await camera.handle_async_still_stream(request, interval) except ValueError: - return web.Response(status=400) + raise web.HTTPBadRequest() @callback diff --git a/homeassistant/components/camera/amcrest.py b/homeassistant/components/camera/amcrest.py index 3c63e56b3191fc..4cb218bc0197d4 100644 --- a/homeassistant/components/camera/amcrest.py +++ b/homeassistant/components/camera/amcrest.py @@ -64,7 +64,7 @@ def handle_async_mjpeg_stream(self, request): yield from super().handle_async_mjpeg_stream(request) return - elif self._stream_source == STREAM_SOURCE_LIST['mjpeg']: + if self._stream_source == STREAM_SOURCE_LIST['mjpeg']: # stream an MJPEG image stream directly from the camera websession = async_get_clientsession(self.hass) streaming_url = self._camera.mjpeg_url(typeno=self._resolution) diff --git a/homeassistant/components/camera/arlo.py b/homeassistant/components/camera/arlo.py index f3e70c2bdd74c9..1a98ade55183ea 100644 --- a/homeassistant/components/camera/arlo.py +++ b/homeassistant/components/camera/arlo.py @@ -4,23 +4,22 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/camera.arlo/ """ -import asyncio import logging -from datetime import timedelta import voluptuous as vol +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv -from homeassistant.components.arlo import DEFAULT_BRAND, DATA_ARLO +from homeassistant.components.arlo import ( + DEFAULT_BRAND, DATA_ARLO, SIGNAL_UPDATE_ARLO) from homeassistant.components.camera import Camera, PLATFORM_SCHEMA from homeassistant.components.ffmpeg import DATA_FFMPEG from homeassistant.const import ATTR_BATTERY_LEVEL from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream +from homeassistant.helpers.dispatcher import async_dispatcher_connect _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(seconds=90) - ARLO_MODE_ARMED = 'armed' ARLO_MODE_DISARMED = 'disarmed' @@ -44,22 +43,19 @@ } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_FFMPEG_ARGUMENTS): - cv.string, + vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string, }) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up an Arlo IP Camera.""" - arlo = hass.data.get(DATA_ARLO) - if not arlo: - return False + arlo = hass.data[DATA_ARLO] cameras = [] for camera in arlo.cameras: cameras.append(ArloCam(hass, camera, config)) - add_devices(cameras, True) + add_devices(cameras) class ArloCam(Camera): @@ -74,31 +70,41 @@ def __init__(self, hass, camera, device_info): self._ffmpeg = hass.data[DATA_FFMPEG] self._ffmpeg_arguments = device_info.get(CONF_FFMPEG_ARGUMENTS) self._last_refresh = None - if self._camera.base_station: - self._camera.base_station.refresh_rate = \ - SCAN_INTERVAL.total_seconds() self.attrs = {} def camera_image(self): """Return a still image response from the camera.""" - return self._camera.last_image + return self._camera.last_image_from_cache + + async def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ARLO, self._update_callback) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state() - @asyncio.coroutine - def handle_async_mjpeg_stream(self, request): + async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" from haffmpeg import CameraMjpeg video = self._camera.last_video if not video: + error_msg = \ + 'Video not found for {0}. Is it older than {1} days?'.format( + self.name, self._camera.min_days_vdo_cache) + _LOGGER.error(error_msg) return stream = CameraMjpeg(self._ffmpeg.binary, loop=self.hass.loop) - yield from stream.open_camera( + await stream.open_camera( video.video_url, extra_cmd=self._ffmpeg_arguments) - yield from async_aiohttp_proxy_stream( + await async_aiohttp_proxy_stream( self.hass, request, stream, 'multipart/x-mixed-replace;boundary=ffserver') - yield from stream.close() + await stream.close() @property def name(self): @@ -132,11 +138,6 @@ def brand(self): """Return the camera brand.""" return DEFAULT_BRAND - @property - def should_poll(self): - """Camera should poll periodically.""" - return True - @property def motion_detection_enabled(self): """Return the camera motion detection status.""" @@ -164,7 +165,3 @@ def disable_motion_detection(self): """Disable the motion detection in base station (Disarm).""" self._motion_status = False self.set_base_station_mode(ARLO_MODE_DISARMED) - - def update(self): - """Add an attribute-update task to the executor pool.""" - self._camera.update() diff --git a/homeassistant/components/camera/axis.py b/homeassistant/components/camera/axis.py index 51c3bc89b05369..5b39718939abf9 100644 --- a/homeassistant/components/camera/axis.py +++ b/homeassistant/components/camera/axis.py @@ -23,7 +23,7 @@ def _get_image_url(host, port, mode): """Set the URL to get the image.""" if mode == 'mjpeg': return 'http://{}:{}/axis-cgi/mjpg/video.cgi'.format(host, port) - elif mode == 'single': + if mode == 'single': return 'http://{}:{}/axis-cgi/jpg/image.cgi'.format(host, port) diff --git a/homeassistant/components/camera/bloomsky.py b/homeassistant/components/camera/bloomsky.py index ef70692215dfbd..775289926745e6 100644 --- a/homeassistant/components/camera/bloomsky.py +++ b/homeassistant/components/camera/bloomsky.py @@ -13,7 +13,6 @@ DEPENDENCIES = ['bloomsky'] -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up access to BloomSky cameras.""" bloomsky = hass.components.bloomsky diff --git a/homeassistant/components/camera/demo.py b/homeassistant/components/camera/demo.py index d009f156e9d225..0e77e6e95adccb 100644 --- a/homeassistant/components/camera/demo.py +++ b/homeassistant/components/camera/demo.py @@ -4,37 +4,41 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/demo/ """ -import os import logging -import homeassistant.util.dt as dt_util -from homeassistant.components.camera import Camera +import os + +from homeassistant.components.camera import Camera, SUPPORT_ON_OFF _LOGGER = logging.getLogger(__name__) -def setup_platform(hass, config, add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the Demo camera platform.""" - add_devices([ - DemoCamera(hass, config, 'Demo camera') + async_add_devices([ + DemoCamera('Demo camera') ]) class DemoCamera(Camera): """The representation of a Demo camera.""" - def __init__(self, hass, config, name): + def __init__(self, name): """Initialize demo camera component.""" super().__init__() - self._parent = hass self._name = name self._motion_status = False + self.is_streaming = True + self._images_index = 0 def camera_image(self): """Return a faked still image response.""" - now = dt_util.utcnow() + self._images_index = (self._images_index + 1) % 4 image_path = os.path.join( - os.path.dirname(__file__), 'demo_{}.jpg'.format(now.second % 4)) + os.path.dirname(__file__), + 'demo_{}.jpg'.format(self._images_index)) + _LOGGER.debug('Loading camera_image: %s', image_path) with open(image_path, 'rb') as file: return file.read() @@ -45,8 +49,21 @@ def name(self): @property def should_poll(self): - """Camera should poll periodically.""" - return True + """Demo camera doesn't need poll. + + Need explicitly call schedule_update_ha_state() after state changed. + """ + return False + + @property + def supported_features(self): + """Camera support turn on/off features.""" + return SUPPORT_ON_OFF + + @property + def is_on(self): + """Whether camera is on (streaming).""" + return self.is_streaming @property def motion_detection_enabled(self): @@ -56,7 +73,19 @@ def motion_detection_enabled(self): def enable_motion_detection(self): """Enable the Motion detection in base station (Arm).""" self._motion_status = True + self.schedule_update_ha_state() def disable_motion_detection(self): """Disable the motion detection in base station (Disarm).""" self._motion_status = False + self.schedule_update_ha_state() + + def turn_off(self): + """Turn off camera.""" + self.is_streaming = False + self.schedule_update_ha_state() + + def turn_on(self): + """Turn on camera.""" + self.is_streaming = True + self.schedule_update_ha_state() diff --git a/homeassistant/components/camera/doorbird.py b/homeassistant/components/camera/doorbird.py index 034ddc2fabbe0a..6680258d95d253 100644 --- a/homeassistant/components/camera/doorbird.py +++ b/homeassistant/components/camera/doorbird.py @@ -17,9 +17,9 @@ DEPENDENCIES = ['doorbird'] -_CAMERA_LAST_VISITOR = "DoorBird Last Ring" -_CAMERA_LAST_MOTION = "DoorBird Last Motion" -_CAMERA_LIVE = "DoorBird Live" +_CAMERA_LAST_VISITOR = "{} Last Ring" +_CAMERA_LAST_MOTION = "{} Last Motion" +_CAMERA_LIVE = "{} Live" _LAST_VISITOR_INTERVAL = datetime.timedelta(minutes=1) _LAST_MOTION_INTERVAL = datetime.timedelta(minutes=1) _LIVE_INTERVAL = datetime.timedelta(seconds=1) @@ -30,16 +30,22 @@ @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the DoorBird camera platform.""" - device = hass.data.get(DOORBIRD_DOMAIN) - async_add_devices([ - DoorBirdCamera(device.live_image_url, _CAMERA_LIVE, _LIVE_INTERVAL), - DoorBirdCamera( - device.history_image_url(1, 'doorbell'), _CAMERA_LAST_VISITOR, - _LAST_VISITOR_INTERVAL), - DoorBirdCamera( - device.history_image_url(1, 'motionsensor'), _CAMERA_LAST_MOTION, - _LAST_MOTION_INTERVAL), - ]) + for doorstation in hass.data[DOORBIRD_DOMAIN]: + device = doorstation.device + async_add_devices([ + DoorBirdCamera( + device.live_image_url, + _CAMERA_LIVE.format(doorstation.name), + _LIVE_INTERVAL), + DoorBirdCamera( + device.history_image_url(1, 'doorbell'), + _CAMERA_LAST_VISITOR.format(doorstation.name), + _LAST_VISITOR_INTERVAL), + DoorBirdCamera( + device.history_image_url(1, 'motionsensor'), + _CAMERA_LAST_MOTION.format(doorstation.name), + _LAST_MOTION_INTERVAL), + ]) class DoorBirdCamera(Camera): diff --git a/homeassistant/components/camera/familyhub.py b/homeassistant/components/camera/familyhub.py new file mode 100644 index 00000000000000..e78d341713b760 --- /dev/null +++ b/homeassistant/components/camera/familyhub.py @@ -0,0 +1,58 @@ +""" +Family Hub camera for Samsung Refrigerators. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/camera.familyhub/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.camera import Camera +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_IP_ADDRESS, CONF_NAME +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['python-family-hub-local==0.0.2'] + +DEFAULT_NAME = 'FamilyHub Camera' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): + """Set up the Family Hub Camera.""" + from pyfamilyhublocal import FamilyHubCam + address = config.get(CONF_IP_ADDRESS) + name = config.get(CONF_NAME) + + session = async_get_clientsession(hass) + family_hub_cam = FamilyHubCam(address, hass.loop, session) + + async_add_devices([FamilyHubCamera(name, family_hub_cam)], True) + + +class FamilyHubCamera(Camera): + """The representation of a Family Hub camera.""" + + def __init__(self, name, family_hub_cam): + """Initialize camera component.""" + super().__init__() + self._name = name + self.family_hub_cam = family_hub_cam + + async def async_camera_image(self): + """Return a still image response.""" + return await self.family_hub_cam.async_get_cam_image() + + @property + def name(self): + """Return the name of this camera.""" + return self._name diff --git a/homeassistant/components/camera/ffmpeg.py b/homeassistant/components/camera/ffmpeg.py index 1bbd263e585554..3da0f19fbf03de 100644 --- a/homeassistant/components/camera/ffmpeg.py +++ b/homeassistant/components/camera/ffmpeg.py @@ -29,8 +29,8 @@ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up a FFmpeg camera.""" if not hass.data[DATA_FFMPEG].async_run_test(config.get(CONF_INPUT)): return @@ -49,30 +49,30 @@ def __init__(self, hass, config): self._input = config.get(CONF_INPUT) self._extra_arguments = config.get(CONF_EXTRA_ARGUMENTS) - @asyncio.coroutine - def async_camera_image(self): + async def async_camera_image(self): """Return a still image response from the camera.""" from haffmpeg import ImageFrame, IMAGE_JPEG ffmpeg = ImageFrame(self._manager.binary, loop=self.hass.loop) - image = yield from asyncio.shield(ffmpeg.get_image( + image = await asyncio.shield(ffmpeg.get_image( self._input, output_format=IMAGE_JPEG, extra_cmd=self._extra_arguments), loop=self.hass.loop) return image - @asyncio.coroutine - def handle_async_mjpeg_stream(self, request): + async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" from haffmpeg import CameraMjpeg stream = CameraMjpeg(self._manager.binary, loop=self.hass.loop) - yield from stream.open_camera( + await stream.open_camera( self._input, extra_cmd=self._extra_arguments) - yield from async_aiohttp_proxy_stream( - self.hass, request, stream, - 'multipart/x-mixed-replace;boundary=ffserver') - yield from stream.close() + try: + return await async_aiohttp_proxy_stream( + self.hass, request, stream, + 'multipart/x-mixed-replace;boundary=ffserver') + finally: + await stream.close() @property def name(self): diff --git a/homeassistant/components/camera/foscam.py b/homeassistant/components/camera/foscam.py index 15db83d345a93e..4ea733139a90b6 100644 --- a/homeassistant/components/camera/foscam.py +++ b/homeassistant/components/camera/foscam.py @@ -33,7 +33,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a Foscam IP Camera.""" add_devices([FoscamCam(config)]) diff --git a/homeassistant/components/camera/generic.py b/homeassistant/components/camera/generic.py index 2f5d8d289794cd..911c14e72325b0 100644 --- a/homeassistant/components/camera/generic.py +++ b/homeassistant/components/camera/generic.py @@ -28,6 +28,7 @@ CONF_CONTENT_TYPE = 'content_type' CONF_LIMIT_REFETCH_TO_URL_CHANGE = 'limit_refetch_to_url_change' CONF_STILL_IMAGE_URL = 'still_image_url' +CONF_FRAMERATE = 'framerate' DEFAULT_NAME = 'Generic Camera' @@ -40,11 +41,11 @@ vol.Optional(CONF_PASSWORD): cv.string, vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_CONTENT_TYPE, default=DEFAULT_CONTENT_TYPE): cv.string, + vol.Optional(CONF_FRAMERATE, default=2): cv.positive_int, }) @asyncio.coroutine -# pylint: disable=unused-argument def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up a generic IP Camera.""" async_add_devices([GenericCamera(hass, config)]) @@ -62,6 +63,7 @@ def __init__(self, hass, device_info): self._still_image_url = device_info[CONF_STILL_IMAGE_URL] self._still_image_url.hass = hass self._limit_refetch = device_info[CONF_LIMIT_REFETCH_TO_URL_CHANGE] + self._frame_interval = 1 / device_info[CONF_FRAMERATE] self.content_type = device_info[CONF_CONTENT_TYPE] username = device_info.get(CONF_USERNAME) @@ -78,6 +80,11 @@ def __init__(self, hass, device_info): self._last_url = None self._last_image = None + @property + def frame_interval(self): + """Return the interval between frames of the mjpeg stream.""" + return self._frame_interval + def camera_image(self): """Return bytes of camera image.""" return run_coroutine_threadsafe( diff --git a/homeassistant/components/camera/mjpeg.py b/homeassistant/components/camera/mjpeg.py index 35d30104f6e66d..757a1b5fc09421 100644 --- a/homeassistant/components/camera/mjpeg.py +++ b/homeassistant/components/camera/mjpeg.py @@ -42,7 +42,6 @@ @asyncio.coroutine -# pylint: disable=unused-argument def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up a MJPEG IP Camera.""" if discovery_info: @@ -124,19 +123,18 @@ def camera_image(self): with closing(req) as response: return extract_image_from_mjpeg(response.iter_content(102400)) - @asyncio.coroutine - def handle_async_mjpeg_stream(self, request): + async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" # aiohttp don't support DigestAuth -> Fallback if self._authentication == HTTP_DIGEST_AUTHENTICATION: - yield from super().handle_async_mjpeg_stream(request) + await super().handle_async_mjpeg_stream(request) return # connect to stream websession = async_get_clientsession(self.hass) stream_coro = websession.get(self._mjpeg_url, auth=self._auth) - yield from async_aiohttp_proxy_web(self.hass, request, stream_coro) + return await async_aiohttp_proxy_web(self.hass, request, stream_coro) @property def name(self): diff --git a/homeassistant/components/camera/mqtt.py b/homeassistant/components/camera/mqtt.py index b2a27230a02d56..dc991644b8e5cb 100644 --- a/homeassistant/components/camera/mqtt.py +++ b/homeassistant/components/camera/mqtt.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.core import callback -import homeassistant.components.mqtt as mqtt +from homeassistant.components import mqtt from homeassistant.const import CONF_NAME from homeassistant.components.camera import Camera, PLATFORM_SCHEMA from homeassistant.helpers import config_validation as cv diff --git a/homeassistant/components/camera/neato.py b/homeassistant/components/camera/neato.py index 33bd00caa6bf68..3a8a137c1fe28e 100644 --- a/homeassistant/components/camera/neato.py +++ b/homeassistant/components/camera/neato.py @@ -10,12 +10,13 @@ from homeassistant.components.camera import Camera from homeassistant.components.neato import ( NEATO_MAP_DATA, NEATO_ROBOTS, NEATO_LOGIN) -from homeassistant.util import Throttle _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['neato'] +SCAN_INTERVAL = timedelta(minutes=10) + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Neato Camera.""" @@ -45,7 +46,6 @@ def camera_image(self): self.update() return self._image - @Throttle(timedelta(seconds=10)) def update(self): """Check the contents of the map list.""" self.neato.update_robots() diff --git a/homeassistant/components/camera/nest.py b/homeassistant/components/camera/nest.py index 6ffb7ef85619f8..bf6700371fd26f 100644 --- a/homeassistant/components/camera/nest.py +++ b/homeassistant/components/camera/nest.py @@ -9,8 +9,9 @@ import requests -import homeassistant.components.nest as nest -from homeassistant.components.camera import (PLATFORM_SCHEMA, Camera) +from homeassistant.components import nest +from homeassistant.components.camera import (PLATFORM_SCHEMA, Camera, + SUPPORT_ON_OFF) from homeassistant.util.dt import utcnow _LOGGER = logging.getLogger(__name__) @@ -23,14 +24,19 @@ def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up a Nest Cam.""" - if discovery_info is None: - return + """Set up a Nest Cam. - camera_devices = hass.data[nest.DATA_NEST].cameras() + No longer in use. + """ + + +async def async_setup_entry(hass, entry, async_add_devices): + """Set up a Nest sensor based on a config entry.""" + camera_devices = \ + await hass.async_add_job(hass.data[nest.DATA_NEST].cameras) cameras = [NestCamera(structure, device) for structure, device in camera_devices] - add_devices(cameras, True) + async_add_devices(cameras, True) class NestCamera(Camera): @@ -71,7 +77,36 @@ def brand(self): """Return the brand of the camera.""" return NEST_BRAND - # This doesn't seem to be getting called regularly, for some reason + @property + def supported_features(self): + """Nest Cam support turn on and off.""" + return SUPPORT_ON_OFF + + @property + def is_on(self): + """Return true if on.""" + return self._online and self._is_streaming + + def turn_off(self): + """Turn off camera.""" + _LOGGER.debug('Turn off camera %s', self._name) + # Calling Nest API in is_streaming setter. + # device.is_streaming would not immediately change until the process + # finished in Nest Cam. + self.device.is_streaming = False + + def turn_on(self): + """Turn on camera.""" + if not self._online: + _LOGGER.error('Camera %s is offline.', self._name) + return + + _LOGGER.debug('Turn on camera %s', self._name) + # Calling Nest API in is_streaming setter. + # device.is_streaming would not immediately change until the process + # finished in Nest Cam. + self.device.is_streaming = True + def update(self): """Cache value from Python-nest.""" self._location = self.device.where diff --git a/homeassistant/components/camera/netatmo.py b/homeassistant/components/camera/netatmo.py index bf2dfe39bd8b55..1c7dc4c7ce00c0 100644 --- a/homeassistant/components/camera/netatmo.py +++ b/homeassistant/components/camera/netatmo.py @@ -29,13 +29,12 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up access to Netatmo cameras.""" netatmo = hass.components.netatmo home = config.get(CONF_HOME) verify_ssl = config.get(CONF_VERIFY_SSL, True) - import lnetatmo + import pyatmo try: data = CameraData(netatmo.NETATMO_AUTH, home) for camera_name in data.get_camera_names(): @@ -46,7 +45,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): continue add_devices([NetatmoCamera(data, camera_name, home, camera_type, verify_ssl)]) - except lnetatmo.NoDevice: + except pyatmo.NoDevice: return None @@ -106,6 +105,6 @@ def model(self): """Return the camera model.""" if self._cameratype == "NOC": return "Presence" - elif self._cameratype == "NACamera": + if self._cameratype == "NACamera": return "Welcome" return None diff --git a/homeassistant/components/camera/onvif.py b/homeassistant/components/camera/onvif.py index 3ae47ba5dee9df..32f8e15748d7b1 100644 --- a/homeassistant/components/camera/onvif.py +++ b/homeassistant/components/camera/onvif.py @@ -25,9 +25,7 @@ REQUIREMENTS = ['onvif-py3==0.1.3', 'suds-py3==1.3.3.0', - 'http://github.com/tgaugry/suds-passworddigest-py3' - '/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip' - '#suds-passworddigest-py3==0.1.2a'] + 'suds-passworddigest-homeassistant==0.1.2a0.dev0'] DEPENDENCIES = ['ffmpeg'] DEFAULT_NAME = 'ONVIF Camera' DEFAULT_PORT = 5000 diff --git a/homeassistant/components/camera/proxy.py b/homeassistant/components/camera/proxy.py index 1984c21fadbb77..a695848d1fa91d 100644 --- a/homeassistant/components/camera/proxy.py +++ b/homeassistant/components/camera/proxy.py @@ -2,56 +2,53 @@ Proxy camera platform that enables image processing of camera data. For more details about this platform, please refer to the documentation -https://home-assistant.io/components/proxy +https://www.home-assistant.io/components/camera.proxy/ """ -import logging import asyncio +import logging + import aiohttp import async_timeout - import voluptuous as vol -from homeassistant.util.async_ import run_coroutine_threadsafe +from homeassistant.components.camera import PLATFORM_SCHEMA, Camera +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, HTTP_HEADER_HA_AUTH from homeassistant.helpers import config_validation as cv - -import homeassistant.util.dt as dt_util -from homeassistant.const import ( - CONF_NAME, CONF_ENTITY_ID, HTTP_HEADER_HA_AUTH) -from homeassistant.components.camera import ( - PLATFORM_SCHEMA, Camera) from homeassistant.helpers.aiohttp_client import ( - async_get_clientsession, async_aiohttp_proxy_web) + async_aiohttp_proxy_web, async_get_clientsession) +from homeassistant.util.async_ import run_coroutine_threadsafe +import homeassistant.util.dt as dt_util -REQUIREMENTS = ['pillow==5.0.0'] +REQUIREMENTS = ['pillow==5.2.0'] _LOGGER = logging.getLogger(__name__) -CONF_MAX_IMAGE_WIDTH = "max_image_width" -CONF_IMAGE_QUALITY = "image_quality" -CONF_IMAGE_REFRESH_RATE = "image_refresh_rate" -CONF_FORCE_RESIZE = "force_resize" -CONF_MAX_STREAM_WIDTH = "max_stream_width" -CONF_STREAM_QUALITY = "stream_quality" -CONF_CACHE_IMAGES = "cache_images" +CONF_CACHE_IMAGES = 'cache_images' +CONF_FORCE_RESIZE = 'force_resize' +CONF_IMAGE_QUALITY = 'image_quality' +CONF_IMAGE_REFRESH_RATE = 'image_refresh_rate' +CONF_MAX_IMAGE_WIDTH = 'max_image_width' +CONF_MAX_STREAM_WIDTH = 'max_stream_width' +CONF_STREAM_QUALITY = 'stream_quality' DEFAULT_BASENAME = "Camera Proxy" DEFAULT_QUALITY = 75 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ENTITY_ID): cv.entity_id, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_MAX_IMAGE_WIDTH): int, + vol.Optional(CONF_CACHE_IMAGES, False): cv.boolean, + vol.Optional(CONF_FORCE_RESIZE, False): cv.boolean, vol.Optional(CONF_IMAGE_QUALITY): int, vol.Optional(CONF_IMAGE_REFRESH_RATE): float, - vol.Optional(CONF_FORCE_RESIZE, False): cv.boolean, - vol.Optional(CONF_CACHE_IMAGES, False): cv.boolean, + vol.Optional(CONF_MAX_IMAGE_WIDTH): int, vol.Optional(CONF_MAX_STREAM_WIDTH): int, + vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_STREAM_QUALITY): int, }) -async def async_setup_platform(hass, config, async_add_devices, - discovery_info=None): +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): """Set up the Proxy camera platform.""" async_add_devices([ProxyCamera(hass, config)]) @@ -69,7 +66,7 @@ def _resize_image(image, opts): img = Image.open(io.BytesIO(image)) imgfmt = str(img.format) - if imgfmt != 'PNG' and imgfmt != 'JPEG': + if imgfmt not in ('PNG', 'JPEG'): _LOGGER.debug("Image is of unsupported type: %s", imgfmt) return image @@ -77,7 +74,7 @@ def _resize_image(image, opts): old_size = len(image) if old_width <= new_width: if opts.quality is None: - _LOGGER.debug("Image is smaller-than / equal-to requested width") + _LOGGER.debug("Image is smaller-than/equal-to requested width") return image new_width = old_width @@ -86,7 +83,7 @@ def _resize_image(image, opts): img = img.resize((new_width, new_height), Image.ANTIALIAS) imgbuf = io.BytesIO() - img.save(imgbuf, "JPEG", optimize=True, quality=quality) + img.save(imgbuf, 'JPEG', optimize=True, quality=quality) newimage = imgbuf.getvalue() if not opts.force_resize and len(newimage) >= old_size: _LOGGER.debug("Using original image(%d bytes) " @@ -94,11 +91,9 @@ def _resize_image(image, opts): old_size, len(newimage)) return image - _LOGGER.debug("Resized image " - "from (%dx%d - %d bytes) " - "to (%dx%d - %d bytes)", - old_width, old_height, old_size, - new_width, new_height, len(newimage)) + _LOGGER.debug( + "Resized image from (%dx%d - %d bytes) to (%dx%d - %d bytes)", + old_width, old_height, old_size, new_width, new_height, len(newimage)) return newimage @@ -112,7 +107,7 @@ def __init__(self, max_width, quality, force_resize): self.force_resize = force_resize def __bool__(self): - """Bool evalution rules.""" + """Bool evaluation rules.""" return bool(self.max_width or self.quality) @@ -133,8 +128,7 @@ def __init__(self, hass, config): config.get(CONF_FORCE_RESIZE)) self._stream_opts = ImageOpts( - config.get(CONF_MAX_STREAM_WIDTH), - config.get(CONF_STREAM_QUALITY), + config.get(CONF_MAX_STREAM_WIDTH), config.get(CONF_STREAM_QUALITY), True) self._image_refresh_rate = config.get(CONF_IMAGE_REFRESH_RATE) @@ -145,8 +139,7 @@ def __init__(self, hass, config): self._last_image = None self._headers = ( {HTTP_HEADER_HA_AUTH: self.hass.config.api.api_password} - if self.hass.config.api.api_password is not None - else None) + if self.hass.config.api.api_password is not None else None) def camera_image(self): """Return camera image.""" @@ -191,12 +184,12 @@ async def handle_async_mjpeg_stream(self, request): stream_coro = websession.get(url, headers=self._headers) if not self._stream_opts: - await async_aiohttp_proxy_web(self.hass, request, stream_coro) - return + return await async_aiohttp_proxy_web( + self.hass, request, stream_coro) response = aiohttp.web.StreamResponse() - response.content_type = ('multipart/x-mixed-replace; ' - 'boundary=--frameboundary') + response.content_type = ( + 'multipart/x-mixed-replace; boundary=--frameboundary') await response.prepare(request) async def write(img_bytes): @@ -229,14 +222,10 @@ async def write(img_bytes): _resize_image, image, self._stream_opts) await write(image) data = data[jpg_end + 2:] - except asyncio.CancelledError: - _LOGGER.debug("Stream closed by frontend.") + finally: req.close() - response = None - finally: - if response is not None: - await response.write_eof() + return response @property def name(self): diff --git a/homeassistant/components/camera/push.py b/homeassistant/components/camera/push.py new file mode 100644 index 00000000000000..def5c53dd3f06c --- /dev/null +++ b/homeassistant/components/camera/push.py @@ -0,0 +1,170 @@ +""" +Camera platform that receives images through HTTP POST. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/camera.push/ +""" +import logging + +from collections import deque +from datetime import timedelta +import voluptuous as vol + +from homeassistant.components.camera import Camera, PLATFORM_SCHEMA,\ + STATE_IDLE, STATE_RECORDING +from homeassistant.core import callback +from homeassistant.components.http.view import HomeAssistantView +from homeassistant.const import CONF_NAME, CONF_TIMEOUT, HTTP_BAD_REQUEST +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.event import async_track_point_in_utc_time +import homeassistant.util.dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +CONF_BUFFER_SIZE = 'buffer' +CONF_IMAGE_FIELD = 'field' + +DEFAULT_NAME = "Push Camera" + +ATTR_FILENAME = 'filename' +ATTR_LAST_TRIP = 'last_trip' + +PUSH_CAMERA_DATA = 'push_camera' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_BUFFER_SIZE, default=1): cv.positive_int, + vol.Optional(CONF_TIMEOUT, default=timedelta(seconds=5)): vol.All( + cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_IMAGE_FIELD, default='image'): cv.string, +}) + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the Push Camera platform.""" + if PUSH_CAMERA_DATA not in hass.data: + hass.data[PUSH_CAMERA_DATA] = {} + + cameras = [PushCamera(config[CONF_NAME], + config[CONF_BUFFER_SIZE], + config[CONF_TIMEOUT])] + + hass.http.register_view(CameraPushReceiver(hass, + config[CONF_IMAGE_FIELD])) + + async_add_devices(cameras) + + +class CameraPushReceiver(HomeAssistantView): + """Handle pushes from remote camera.""" + + url = "/api/camera_push/{entity_id}" + name = 'api:camera_push:camera_entity' + + def __init__(self, hass, image_field): + """Initialize CameraPushReceiver with camera entity.""" + self._cameras = hass.data[PUSH_CAMERA_DATA] + self._image = image_field + + async def post(self, request, entity_id): + """Accept the POST from Camera.""" + _camera = self._cameras.get(entity_id) + + if _camera is None: + _LOGGER.error("Unknown %s", entity_id) + return self.json_message('Unknown {}'.format(entity_id), + HTTP_BAD_REQUEST) + + try: + data = await request.post() + _LOGGER.debug("Received Camera push: %s", data[self._image]) + await _camera.update_image(data[self._image].file.read(), + data[self._image].filename) + except ValueError as value_error: + _LOGGER.error("Unknown value %s", value_error) + return self.json_message('Invalid POST', HTTP_BAD_REQUEST) + except KeyError as key_error: + _LOGGER.error('In your POST message %s', key_error) + return self.json_message('{} missing'.format(self._image), + HTTP_BAD_REQUEST) + + +class PushCamera(Camera): + """The representation of a Push camera.""" + + def __init__(self, name, buffer_size, timeout): + """Initialize push camera component.""" + super().__init__() + self._name = name + self._last_trip = None + self._filename = None + self._expired_listener = None + self._state = STATE_IDLE + self._timeout = timeout + self.queue = deque([], buffer_size) + self._current_image = None + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + self.hass.data[PUSH_CAMERA_DATA][self.entity_id] = self + + @property + def state(self): + """Current state of the camera.""" + return self._state + + async def update_image(self, image, filename): + """Update the camera image.""" + if self._state == STATE_IDLE: + self._state = STATE_RECORDING + self._last_trip = dt_util.utcnow() + self.queue.clear() + + self._filename = filename + self.queue.appendleft(image) + + @callback + def reset_state(now): + """Set state to idle after no new images for a period of time.""" + self._state = STATE_IDLE + self._expired_listener = None + _LOGGER.debug("Reset state") + self.async_schedule_update_ha_state() + + if self._expired_listener: + self._expired_listener() + + self._expired_listener = async_track_point_in_utc_time( + self.hass, reset_state, dt_util.utcnow() + self._timeout) + + self.async_schedule_update_ha_state() + + async def async_camera_image(self): + """Return a still image response.""" + if self.queue: + if self._state == STATE_IDLE: + self.queue.rotate(1) + self._current_image = self.queue[0] + + return self._current_image + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + @property + def motion_detection_enabled(self): + """Camera Motion Detection Status.""" + return False + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + name: value for name, value in ( + (ATTR_LAST_TRIP, self._last_trip), + (ATTR_FILENAME, self._filename), + ) if value is not None + } diff --git a/homeassistant/components/camera/services.yaml b/homeassistant/components/camera/services.yaml index 544fd0e6b8a4d5..b977fcd5c52f89 100644 --- a/homeassistant/components/camera/services.yaml +++ b/homeassistant/components/camera/services.yaml @@ -1,5 +1,19 @@ # Describes the format for available camera services +turn_off: + description: Turn off camera. + fields: + entity_id: + description: Entity id. + example: 'camera.living_room' + +turn_on: + description: Turn on camera. + fields: + entity_id: + description: Entity id. + example: 'camera.living_room' + enable_motion_detection: description: Enable the motion detection in a camera. fields: diff --git a/homeassistant/components/camera/uvc.py b/homeassistant/components/camera/uvc.py index 20dceb8a1c5da2..b5306c31c84bc9 100644 --- a/homeassistant/components/camera/uvc.py +++ b/homeassistant/components/camera/uvc.py @@ -13,6 +13,7 @@ from homeassistant.const import CONF_PORT from homeassistant.components.camera import Camera, PLATFORM_SCHEMA import homeassistant.helpers.config_validation as cv +from homeassistant.exceptions import PlatformNotReady REQUIREMENTS = ['uvcclient==0.10.1'] @@ -41,25 +42,26 @@ def setup_platform(hass, config, add_devices, discovery_info=None): port = config[CONF_PORT] from uvcclient import nvr - nvrconn = nvr.UVCRemote(addr, port, key) try: + # Exceptions may be raised in all method calls to the nvr library. + nvrconn = nvr.UVCRemote(addr, port, key) cameras = nvrconn.index() + + identifier = 'id' if nvrconn.server_version >= (3, 2, 0) else 'uuid' + # Filter out airCam models, which are not supported in the latest + # version of UnifiVideo and which are EOL by Ubiquiti + cameras = [ + camera for camera in cameras + if 'airCam' not in nvrconn.get_camera(camera[identifier])['model']] except nvr.NotAuthorized: _LOGGER.error("Authorization failure while connecting to NVR") return False - except nvr.NvrError: - _LOGGER.error("NVR refuses to talk to me") - return False + except nvr.NvrError as ex: + _LOGGER.error("NVR refuses to talk to me: %s", str(ex)) + raise PlatformNotReady except requests.exceptions.ConnectionError as ex: _LOGGER.error("Unable to connect to NVR: %s", str(ex)) - return False - - identifier = 'id' if nvrconn.server_version >= (3, 2, 0) else 'uuid' - # Filter out airCam models, which are not supported in the latest - # version of UnifiVideo and which are EOL by Ubiquiti - cameras = [ - camera for camera in cameras - if 'airCam' not in nvrconn.get_camera(camera[identifier])['model']] + raise PlatformNotReady add_devices([UnifiVideoCamera(nvrconn, camera[identifier], @@ -169,10 +171,9 @@ def _get_image(retry=True): if retry: self._login() return _get_image(retry=False) - else: - _LOGGER.error( - "Unable to log into camera, unable to get snapshot") - raise + _LOGGER.error( + "Unable to log into camera, unable to get snapshot") + raise return _get_image() diff --git a/homeassistant/components/camera/verisure.py b/homeassistant/components/camera/verisure.py index b637858303e755..554f877d0bd34b 100644 --- a/homeassistant/components/camera/verisure.py +++ b/homeassistant/components/camera/verisure.py @@ -66,8 +66,7 @@ def check_imagelist(self): if not image_ids: return new_image_id = image_ids[0] - if (new_image_id == '-1' or - self._image_id == new_image_id): + if new_image_id in ('-1', self._image_id): _LOGGER.debug("The image is the same, or loading image_id") return _LOGGER.debug("Download new image %s", new_image_id) diff --git a/homeassistant/components/camera/xeoma.py b/homeassistant/components/camera/xeoma.py index cec04b52047b8e..2a4d15268180c5 100644 --- a/homeassistant/components/camera/xeoma.py +++ b/homeassistant/components/camera/xeoma.py @@ -67,8 +67,6 @@ async def async_setup_platform(hass, config, async_add_devices, ] for cam in config.get(CONF_CAMERAS, []): - # https://github.com/PyCQA/pylint/issues/1830 - # pylint: disable=stop-iteration-return camera = next( (dc for dc in discovered_cameras if dc[CONF_IMAGE_NAME] == cam[CONF_IMAGE_NAME]), None) diff --git a/homeassistant/components/camera/xiaomi.py b/homeassistant/components/camera/xiaomi.py new file mode 100644 index 00000000000000..e80f4b7532acff --- /dev/null +++ b/homeassistant/components/camera/xiaomi.py @@ -0,0 +1,164 @@ +""" +This component provides support for Xiaomi Cameras. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/camera.xiaomi/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.components.camera import Camera, PLATFORM_SCHEMA +from homeassistant.components.ffmpeg import DATA_FFMPEG +from homeassistant.const import (CONF_HOST, CONF_NAME, CONF_PATH, + CONF_PASSWORD, CONF_PORT, CONF_USERNAME) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream + +DEPENDENCIES = ['ffmpeg'] +_LOGGER = logging.getLogger(__name__) + +DEFAULT_BRAND = 'Xiaomi Home Camera' +DEFAULT_PATH = '/media/mmcblk0p1/record' +DEFAULT_PORT = 21 +DEFAULT_USERNAME = 'root' + +CONF_FFMPEG_ARGUMENTS = 'ffmpeg_arguments' +CONF_MODEL = 'model' + +MODEL_YI = 'yi' +MODEL_XIAOFANG = 'xiaofang' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_MODEL): vol.Any(MODEL_YI, + MODEL_XIAOFANG), + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.string, + vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_FFMPEG_ARGUMENTS): cv.string +}) + + +async def async_setup_platform(hass, + config, + async_add_devices, + discovery_info=None): + """Set up a Xiaomi Camera.""" + _LOGGER.debug('Received configuration for model %s', config[CONF_MODEL]) + async_add_devices([XiaomiCamera(hass, config)]) + + +class XiaomiCamera(Camera): + """Define an implementation of a Xiaomi Camera.""" + + def __init__(self, hass, config): + """Initialize.""" + super().__init__() + self._extra_arguments = config.get(CONF_FFMPEG_ARGUMENTS) + self._last_image = None + self._last_url = None + self._manager = hass.data[DATA_FFMPEG] + self._name = config[CONF_NAME] + self.host = config[CONF_HOST] + self._model = config[CONF_MODEL] + self.port = config[CONF_PORT] + self.path = config[CONF_PATH] + self.user = config[CONF_USERNAME] + self.passwd = config[CONF_PASSWORD] + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + @property + def brand(self): + """Return the camera brand.""" + return DEFAULT_BRAND + + @property + def model(self): + """Return the camera model.""" + return self._model + + def get_latest_video_url(self): + """Retrieve the latest video file from the Xiaomi Camera FTP server.""" + from ftplib import FTP, error_perm + + ftp = FTP(self.host) + try: + ftp.login(self.user, self.passwd) + except error_perm as exc: + _LOGGER.error('Camera login failed: %s', exc) + return False + + try: + ftp.cwd(self.path) + except error_perm as exc: + _LOGGER.error('Unable to find path: %s - %s', self.path, exc) + return False + + dirs = [d for d in ftp.nlst() if '.' not in d] + if not dirs: + _LOGGER.warning("There don't appear to be any folders") + return False + + first_dir = dirs[-1] + try: + ftp.cwd(first_dir) + except error_perm as exc: + _LOGGER.error('Unable to find path: %s - %s', first_dir, exc) + return False + + if self._model == MODEL_XIAOFANG: + dirs = [d for d in ftp.nlst() if '.' not in d] + if not dirs: + _LOGGER.warning("There don't appear to be any uploaded videos") + return False + + latest_dir = dirs[-1] + ftp.cwd(latest_dir) + + videos = [v for v in ftp.nlst() if '.tmp' not in v] + if not videos: + _LOGGER.info('Video folder "%s" is empty; delaying', latest_dir) + return False + + if self._model == MODEL_XIAOFANG: + video = videos[-2] + else: + video = videos[-1] + + return 'ftp://{0}:{1}@{2}:{3}{4}/{5}'.format( + self.user, self.passwd, self.host, self.port, ftp.pwd(), video) + + async def async_camera_image(self): + """Return a still image response from the camera.""" + from haffmpeg import ImageFrame, IMAGE_JPEG + + url = await self.hass.async_add_job(self.get_latest_video_url) + if url != self._last_url: + ffmpeg = ImageFrame(self._manager.binary, loop=self.hass.loop) + self._last_image = await asyncio.shield(ffmpeg.get_image( + url, output_format=IMAGE_JPEG, + extra_cmd=self._extra_arguments), loop=self.hass.loop) + self._last_url = url + + return self._last_image + + async def handle_async_mjpeg_stream(self, request): + """Generate an HTTP MJPEG stream from the camera.""" + from haffmpeg import CameraMjpeg + + stream = CameraMjpeg(self._manager.binary, loop=self.hass.loop) + await stream.open_camera( + self._last_url, extra_cmd=self._extra_arguments) + + await async_aiohttp_proxy_stream( + self.hass, request, stream, + 'multipart/x-mixed-replace;boundary=ffserver') + await stream.close() diff --git a/homeassistant/components/camera/yi.py b/homeassistant/components/camera/yi.py index 41fe816c4799c6..4efc2c7d8ba9dc 100644 --- a/homeassistant/components/camera/yi.py +++ b/homeassistant/components/camera/yi.py @@ -11,11 +11,13 @@ from homeassistant.components.camera import Camera, PLATFORM_SCHEMA from homeassistant.components.ffmpeg import DATA_FFMPEG -from homeassistant.const import (CONF_HOST, CONF_NAME, CONF_PATH, - CONF_PASSWORD, CONF_PORT, CONF_USERNAME) +from homeassistant.const import ( + CONF_HOST, CONF_NAME, CONF_PATH, CONF_PASSWORD, CONF_PORT, CONF_USERNAME) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.aiohttp_client import async_aiohttp_proxy_stream +from homeassistant.exceptions import PlatformNotReady +REQUIREMENTS = ['aioftp==0.10.1'] DEPENDENCIES = ['ffmpeg'] _LOGGER = logging.getLogger(__name__) @@ -38,12 +40,9 @@ }) -async def async_setup_platform(hass, - config, - async_add_devices, - discovery_info=None): +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): """Set up a Yi Camera.""" - _LOGGER.debug('Received configuration: %s', config) async_add_devices([YiCamera(hass, config)], True) @@ -57,68 +56,79 @@ def __init__(self, hass, config): self._last_image = None self._last_url = None self._manager = hass.data[DATA_FFMPEG] - self._name = config.get(CONF_NAME) - self.host = config.get(CONF_HOST) - self.port = config.get(CONF_PORT) - self.path = config.get(CONF_PATH) - self.user = config.get(CONF_USERNAME) - self.passwd = config.get(CONF_PASSWORD) - - @property - def name(self): - """Return the name of this camera.""" - return self._name + self._name = config[CONF_NAME] + self._is_on = True + self.host = config[CONF_HOST] + self.port = config[CONF_PORT] + self.path = config[CONF_PATH] + self.user = config[CONF_USERNAME] + self.passwd = config[CONF_PASSWORD] @property def brand(self): """Camera brand.""" return DEFAULT_BRAND - def get_latest_video_url(self): + @property + def is_on(self): + """Determine whether the camera is on.""" + return self._is_on + + @property + def name(self): + """Return the name of this camera.""" + return self._name + + async def _get_latest_video_url(self): """Retrieve the latest video file from the customized Yi FTP server.""" - from ftplib import FTP, error_perm + from aioftp import Client, StatusCodeError - ftp = FTP(self.host) + ftp = Client(loop=self.hass.loop) try: - ftp.login(self.user, self.passwd) - except error_perm as exc: - _LOGGER.error('There was an error while logging into the camera') - _LOGGER.debug(exc) - return False + await ftp.connect(self.host) + await ftp.login(self.user, self.passwd) + except (ConnectionRefusedError, StatusCodeError) as err: + raise PlatformNotReady(err) try: - ftp.cwd(self.path) - except error_perm as exc: - _LOGGER.error('Unable to find path: %s', self.path) - _LOGGER.debug(exc) - return False - - dirs = [d for d in ftp.nlst() if '.' not in d] - if not dirs: - _LOGGER.warning("There don't appear to be any uploaded videos") - return False - - latest_dir = dirs[-1] - ftp.cwd(latest_dir) - videos = ftp.nlst() - if not videos: - _LOGGER.info('Video folder "%s" is empty; delaying', latest_dir) - return False - - return 'ftp://{0}:{1}@{2}:{3}{4}/{5}/{6}'.format( - self.user, self.passwd, self.host, self.port, self.path, - latest_dir, videos[-1]) + await ftp.change_directory(self.path) + dirs = [] + for path, attrs in await ftp.list(): + if attrs['type'] == 'dir' and '.' not in str(path): + dirs.append(path) + latest_dir = dirs[-1] + await ftp.change_directory(latest_dir) + + videos = [] + for path, _ in await ftp.list(): + videos.append(path) + if not videos: + _LOGGER.info('Video folder "%s" empty; delaying', latest_dir) + return None + + await ftp.quit() + self._is_on = True + return 'ftp://{0}:{1}@{2}:{3}{4}/{5}/{6}'.format( + self.user, self.passwd, self.host, self.port, self.path, + latest_dir, videos[-1]) + except (ConnectionRefusedError, StatusCodeError) as err: + _LOGGER.error('Error while fetching video: %s', err) + self._is_on = False + return None async def async_camera_image(self): """Return a still image response from the camera.""" from haffmpeg import ImageFrame, IMAGE_JPEG - url = await self.hass.async_add_job(self.get_latest_video_url) - if url != self._last_url: + url = await self._get_latest_video_url() + if url and url != self._last_url: ffmpeg = ImageFrame(self._manager.binary, loop=self.hass.loop) - self._last_image = await asyncio.shield(ffmpeg.get_image( - url, output_format=IMAGE_JPEG, - extra_cmd=self._extra_arguments), loop=self.hass.loop) + self._last_image = await asyncio.shield( + ffmpeg.get_image( + url, + output_format=IMAGE_JPEG, + extra_cmd=self._extra_arguments), + loop=self.hass.loop) self._last_url = url return self._last_image @@ -127,6 +137,9 @@ async def handle_async_mjpeg_stream(self, request): """Generate an HTTP MJPEG stream from the camera.""" from haffmpeg import CameraMjpeg + if not self._is_on: + return + stream = CameraMjpeg(self._manager.binary, loop=self.hass.loop) await stream.open_camera( self._last_url, extra_cmd=self._extra_arguments) diff --git a/homeassistant/components/camera/zoneminder.py b/homeassistant/components/camera/zoneminder.py index a98e3ef066fbee..be59a1c1f50e56 100644 --- a/homeassistant/components/camera/zoneminder.py +++ b/homeassistant/components/camera/zoneminder.py @@ -12,7 +12,7 @@ from homeassistant.components.camera.mjpeg import ( CONF_MJPEG_URL, CONF_STILL_IMAGE_URL, MjpegCamera) -import homeassistant.components.zoneminder as zoneminder +from homeassistant.components import zoneminder _LOGGER = logging.getLogger(__name__) @@ -49,7 +49,6 @@ def _get_image_url(hass, monitor, mode): @asyncio.coroutine -# pylint: disable=unused-argument def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the ZoneMinder cameras.""" cameras = [] diff --git a/homeassistant/components/canary.py b/homeassistant/components/canary.py index 4d0fbe617b2c04..04c33d83f3db88 100644 --- a/homeassistant/components/canary.py +++ b/homeassistant/components/canary.py @@ -65,7 +65,7 @@ def setup(hass, config): return True -class CanaryData(object): +class CanaryData: """Get the latest data and update the states.""" def __init__(self, username, password, timeout): diff --git a/homeassistant/components/cast/.translations/ca.json b/homeassistant/components/cast/.translations/ca.json new file mode 100644 index 00000000000000..e65e00f8624b69 --- /dev/null +++ b/homeassistant/components/cast/.translations/ca.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "No s'han trobat dispositius de Google Cast a la xarxa.", + "single_instance_allowed": "Nom\u00e9s cal una \u00fanica configuraci\u00f3 de Google Cast." + }, + "step": { + "confirm": { + "description": "Voleu configurar Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/cs.json b/homeassistant/components/cast/.translations/cs.json new file mode 100644 index 00000000000000..82f063b365f1ec --- /dev/null +++ b/homeassistant/components/cast/.translations/cs.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "V s\u00edti nebyly nalezena \u017e\u00e1dn\u00e1 za\u0159\u00edzen\u00ed Google Cast.", + "single_instance_allowed": "Pouze jedin\u00e1 konfigurace Google Cast je nezbytn\u00e1." + }, + "step": { + "confirm": { + "description": "Chcete nastavit Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/de.json b/homeassistant/components/cast/.translations/de.json new file mode 100644 index 00000000000000..a37dbd6f5b7fc1 --- /dev/null +++ b/homeassistant/components/cast/.translations/de.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Keine Google Cast Ger\u00e4te im Netzwerk gefunden.", + "single_instance_allowed": "Nur eine einzige Konfiguration von Google Cast ist notwendig." + }, + "step": { + "confirm": { + "description": "M\u00f6chten Sie Google Cast einrichten?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/en.json b/homeassistant/components/cast/.translations/en.json new file mode 100644 index 00000000000000..55d79a7d560a9b --- /dev/null +++ b/homeassistant/components/cast/.translations/en.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "No Google Cast devices found on the network.", + "single_instance_allowed": "Only a single configuration of Google Cast is necessary." + }, + "step": { + "confirm": { + "description": "Do you want to setup Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/es-419.json b/homeassistant/components/cast/.translations/es-419.json new file mode 100644 index 00000000000000..2f8d4982afdd0e --- /dev/null +++ b/homeassistant/components/cast/.translations/es-419.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "No se encontraron dispositivos Google Cast en la red.", + "single_instance_allowed": "S\u00f3lo es necesaria una \u00fanica configuraci\u00f3n de Google Cast." + }, + "step": { + "confirm": { + "description": "\u00bfDesea configurar Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/hu.json b/homeassistant/components/cast/.translations/hu.json new file mode 100644 index 00000000000000..f59a1b43ef1b1f --- /dev/null +++ b/homeassistant/components/cast/.translations/hu.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nem tal\u00e1lhat\u00f3k Google Cast eszk\u00f6z\u00f6k a h\u00e1l\u00f3zaton." + }, + "step": { + "confirm": { + "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a Google Cast szolg\u00e1ltat\u00e1st?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/it.json b/homeassistant/components/cast/.translations/it.json new file mode 100644 index 00000000000000..21c8e60518e2ad --- /dev/null +++ b/homeassistant/components/cast/.translations/it.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nessun dispositivo Google Cast trovato in rete.", + "single_instance_allowed": "\u00c8 necessaria una sola configurazione di Google Cast." + }, + "step": { + "confirm": { + "description": "Vuoi configurare Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/ja.json b/homeassistant/components/cast/.translations/ja.json new file mode 100644 index 00000000000000..25b9c10b2e7435 --- /dev/null +++ b/homeassistant/components/cast/.translations/ja.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u30cd\u30c3\u30c8\u30ef\u30fc\u30af\u4e0a\u306bGoogle Cast\u30c7\u30d0\u30a4\u30b9\u304c\u898b\u3064\u304b\u308a\u307e\u305b\u3093\u3067\u3057\u305f\u3002" + }, + "step": { + "confirm": { + "description": "Google Cast\u3092\u30bb\u30c3\u30c8\u30a2\u30c3\u30d7\u3057\u307e\u3059\u304b\uff1f", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/ko.json b/homeassistant/components/cast/.translations/ko.json new file mode 100644 index 00000000000000..e4472c88cd8e3a --- /dev/null +++ b/homeassistant/components/cast/.translations/ko.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Googgle Cast \uc7a5\uce58\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.", + "single_instance_allowed": "\ud558\ub098\uc758 Google Cast \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + }, + "step": { + "confirm": { + "description": "Google Cast\ub97c \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/lb.json b/homeassistant/components/cast/.translations/lb.json new file mode 100644 index 00000000000000..f1daff8306955c --- /dev/null +++ b/homeassistant/components/cast/.translations/lb.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Keng Google Cast Apparater am Netzwierk fonnt.", + "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun Google Cast ass n\u00e9ideg." + }, + "step": { + "confirm": { + "description": "Soll Google Cast konfigur\u00e9iert ginn?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/nl.json b/homeassistant/components/cast/.translations/nl.json new file mode 100644 index 00000000000000..91c428770f5fc8 --- /dev/null +++ b/homeassistant/components/cast/.translations/nl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Geen Google Cast-apparaten gevonden op het netwerk.", + "single_instance_allowed": "Er is slechts \u00e9\u00e9n configuratie van Google Cast nodig." + }, + "step": { + "confirm": { + "description": "Wilt u Google Cast instellen?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/no.json b/homeassistant/components/cast/.translations/no.json new file mode 100644 index 00000000000000..d36c929e7211b5 --- /dev/null +++ b/homeassistant/components/cast/.translations/no.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Ingen Google Cast enheter funnet p\u00e5 nettverket.", + "single_instance_allowed": "Kun en enkelt konfigurasjon av Google Cast er n\u00f8dvendig." + }, + "step": { + "confirm": { + "description": "\u00d8nsker du \u00e5 sette opp Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/pl.json b/homeassistant/components/cast/.translations/pl.json new file mode 100644 index 00000000000000..c4399f95defe81 --- /dev/null +++ b/homeassistant/components/cast/.translations/pl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nie znaleziono w sieci urz\u0105dze\u0144 Google Cast.", + "single_instance_allowed": "Wymagana jest tylko jedna konfiguracja Google Cast." + }, + "step": { + "confirm": { + "description": "Czy chcesz skonfigurowa\u0107 Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/pt-BR.json b/homeassistant/components/cast/.translations/pt-BR.json new file mode 100644 index 00000000000000..bd670d7c72f56e --- /dev/null +++ b/homeassistant/components/cast/.translations/pt-BR.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nenhum dispositivo Google Cast encontrado na rede.", + "single_instance_allowed": "Apenas uma \u00fanica configura\u00e7\u00e3o do Google Cast \u00e9 necess\u00e1ria." + }, + "step": { + "confirm": { + "description": "Deseja configurar o Google Cast?", + "title": "" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/pt.json b/homeassistant/components/cast/.translations/pt.json new file mode 100644 index 00000000000000..a6d28538396882 --- /dev/null +++ b/homeassistant/components/cast/.translations/pt.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nenhum dispositivo Google Cast descoberto na rede.", + "single_instance_allowed": "Apenas uma \u00fanica configura\u00e7\u00e3o do Google Cast \u00e9 necess\u00e1ria." + }, + "step": { + "confirm": { + "description": "Deseja configurar o Google Cast?", + "title": "" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/ru.json b/homeassistant/components/cast/.translations/ru.json new file mode 100644 index 00000000000000..9c9353da37e3da --- /dev/null +++ b/homeassistant/components/cast/.translations/ru.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Google Cast \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b.", + "single_instance_allowed": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u0430 \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f Google Cast." + }, + "step": { + "confirm": { + "description": "\u0412\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/sl.json b/homeassistant/components/cast/.translations/sl.json new file mode 100644 index 00000000000000..24a7215574dbd9 --- /dev/null +++ b/homeassistant/components/cast/.translations/sl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "V omre\u017eju niso najdene naprave Google Cast.", + "single_instance_allowed": "Potrebna je samo ena konfiguracija Google Cast-a." + }, + "step": { + "confirm": { + "description": "Ali \u017eelite nastaviti Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/sv.json b/homeassistant/components/cast/.translations/sv.json new file mode 100644 index 00000000000000..aea55058d108f7 --- /dev/null +++ b/homeassistant/components/cast/.translations/sv.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Inga Google Cast-enheter hittades i n\u00e4tverket.", + "single_instance_allowed": "Endast en enda konfiguration av Google Cast \u00e4r n\u00f6dv\u00e4ndig." + }, + "step": { + "confirm": { + "description": "Vill du konfigurera Google Cast?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/vi.json b/homeassistant/components/cast/.translations/vi.json new file mode 100644 index 00000000000000..2f2982293cfdac --- /dev/null +++ b/homeassistant/components/cast/.translations/vi.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Kh\u00f4ng t\u00ecm th\u1ea5y thi\u1ebft b\u1ecb Google Cast n\u00e0o tr\u00ean m\u1ea1ng.", + "single_instance_allowed": "Ch\u1ec9 c\u1ea7n m\u1ed9t c\u1ea5u h\u00ecnh duy nh\u1ea5t c\u1ee7a Google Cast l\u00e0 \u0111\u1ee7." + }, + "step": { + "confirm": { + "description": "B\u1ea1n c\u00f3 mu\u1ed1n thi\u1ebft l\u1eadp Google Cast kh\u00f4ng?", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/zh-Hans.json b/homeassistant/components/cast/.translations/zh-Hans.json new file mode 100644 index 00000000000000..d4f1cf4c1a5907 --- /dev/null +++ b/homeassistant/components/cast/.translations/zh-Hans.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u6ca1\u6709\u5728\u7f51\u7edc\u4e0a\u627e\u5230 Google Cast \u8bbe\u5907\u3002", + "single_instance_allowed": "Google Cast \u53ea\u9700\u8981\u914d\u7f6e\u4e00\u6b21\u3002" + }, + "step": { + "confirm": { + "description": "\u60a8\u60f3\u8981\u914d\u7f6e Google Cast \u5417\uff1f", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/.translations/zh-Hant.json b/homeassistant/components/cast/.translations/zh-Hant.json new file mode 100644 index 00000000000000..711ac3203978c6 --- /dev/null +++ b/homeassistant/components/cast/.translations/zh-Hant.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u5728\u7db2\u8def\u4e0a\u627e\u4e0d\u5230 Google Cast \u8a2d\u5099\u3002", + "single_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u6b21 Google Cast \u5373\u53ef\u3002" + }, + "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a Google Cast\uff1f", + "title": "Google Cast" + } + }, + "title": "Google Cast" + } +} \ No newline at end of file diff --git a/homeassistant/components/cast/__init__.py b/homeassistant/components/cast/__init__.py new file mode 100644 index 00000000000000..6885f24269a07e --- /dev/null +++ b/homeassistant/components/cast/__init__.py @@ -0,0 +1,38 @@ +"""Component to embed Google Cast.""" +from homeassistant import config_entries +from homeassistant.helpers import config_entry_flow + + +DOMAIN = 'cast' +REQUIREMENTS = ['pychromecast==2.1.0'] + + +async def async_setup(hass, config): + """Set up the Cast component.""" + conf = config.get(DOMAIN) + + hass.data[DOMAIN] = conf or {} + + if conf is not None: + hass.async_create_task(hass.config_entries.flow.async_init( + DOMAIN, context={'source': config_entries.SOURCE_IMPORT})) + + return True + + +async def async_setup_entry(hass, entry): + """Set up Cast from a config entry.""" + hass.async_create_task(hass.config_entries.async_forward_entry_setup( + entry, 'media_player')) + return True + + +async def _async_has_devices(hass): + """Return if there are devices that can be discovered.""" + from pychromecast.discovery import discover_chromecasts + + return await hass.async_add_executor_job(discover_chromecasts) + + +config_entry_flow.register_discovery_flow( + DOMAIN, 'Google Cast', _async_has_devices) diff --git a/homeassistant/components/cast/strings.json b/homeassistant/components/cast/strings.json new file mode 100644 index 00000000000000..7f480de0e8bea3 --- /dev/null +++ b/homeassistant/components/cast/strings.json @@ -0,0 +1,15 @@ +{ + "config": { + "title": "Google Cast", + "step": { + "confirm": { + "title": "Google Cast", + "description": "Do you want to setup Google Cast?" + } + }, + "abort": { + "single_instance_allowed": "Only a single configuration of Google Cast is necessary.", + "no_devices_found": "No Google Cast devices found on the network." + } + } +} diff --git a/homeassistant/components/climate/__init__.py b/homeassistant/components/climate/__init__.py index 550d4035ddd148..9584422e2b41c1 100644 --- a/homeassistant/components/climate/__init__.py +++ b/homeassistant/components/climate/__init__.py @@ -22,6 +22,12 @@ ATTR_ENTITY_ID, ATTR_TEMPERATURE, SERVICE_TURN_ON, SERVICE_TURN_OFF, STATE_ON, STATE_OFF, STATE_UNKNOWN, TEMP_CELSIUS, PRECISION_WHOLE, PRECISION_TENTHS, ) + +DEFAULT_MIN_TEMP = 7 +DEFAULT_MAX_TEMP = 35 +DEFAULT_MIN_HUMITIDY = 30 +DEFAULT_MAX_HUMIDITY = 99 + DOMAIN = 'climate' ENTITY_ID_FORMAT = DOMAIN + '.{}' @@ -240,7 +246,8 @@ def set_swing_mode(hass, swing_mode, entity_id=None): async def async_setup(hass, config): """Set up climate devices.""" - component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) + component = hass.data[DOMAIN] = \ + EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) await component.async_setup(config) async def async_away_mode_set_service(service): @@ -450,10 +457,19 @@ async def async_on_off_service(service): return True +async def async_setup_entry(hass, entry): + """Setup a config entry.""" + return await hass.data[DOMAIN].async_setup_entry(entry) + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + class ClimateDevice(Entity): """Representation of a climate device.""" - # pylint: disable=no-self-use @property def state(self): """Return the current state.""" @@ -778,19 +794,21 @@ def supported_features(self): @property def min_temp(self): """Return the minimum temperature.""" - return convert_temperature(7, TEMP_CELSIUS, self.temperature_unit) + return convert_temperature(DEFAULT_MIN_TEMP, TEMP_CELSIUS, + self.temperature_unit) @property def max_temp(self): """Return the maximum temperature.""" - return convert_temperature(35, TEMP_CELSIUS, self.temperature_unit) + return convert_temperature(DEFAULT_MAX_TEMP, TEMP_CELSIUS, + self.temperature_unit) @property def min_humidity(self): """Return the minimum humidity.""" - return 30 + return DEFAULT_MIN_HUMITIDY @property def max_humidity(self): """Return the maximum humidity.""" - return 99 + return DEFAULT_MAX_HUMIDITY diff --git a/homeassistant/components/climate/daikin.py b/homeassistant/components/climate/daikin.py index 2c49b25a39d9a3..50501025f0c2c4 100644 --- a/homeassistant/components/climate/daikin.py +++ b/homeassistant/components/climate/daikin.py @@ -145,7 +145,7 @@ def get(self, key): if value is None: _LOGGER.error("Invalid value requested for key %s", key) else: - if value == "-" or value == "--": + if value in ("-", "--"): value = None elif cast_to_float: try: diff --git a/homeassistant/components/climate/ecobee.py b/homeassistant/components/climate/ecobee.py index e64c2d5000e7f0..718788271535bb 100644 --- a/homeassistant/components/climate/ecobee.py +++ b/homeassistant/components/climate/ecobee.py @@ -177,7 +177,7 @@ def target_temperature(self): return None if self.current_operation == STATE_HEAT: return self.thermostat['runtime']['desiredHeat'] / 10.0 - elif self.current_operation == STATE_COOL: + if self.current_operation == STATE_COOL: return self.thermostat['runtime']['desiredCool'] / 10.0 return None @@ -217,15 +217,15 @@ def _current_hold_mode(self): return 'away' # A permanent hold from away climate return AWAY_MODE - elif event['holdClimateRef'] != "": + if event['holdClimateRef'] != "": # Any other hold based on climate return event['holdClimateRef'] # Any hold not based on a climate is a temp hold return TEMPERATURE_HOLD - elif event['type'].startswith('auto'): + if event['type'].startswith('auto'): # All auto modes are treated as holds return event['type'][4:].lower() - elif event['type'] == 'vacation': + if event['type'] == 'vacation': self.vacation = event['name'] return VACATION_HOLD return None @@ -317,7 +317,7 @@ def set_hold_mode(self, hold_mode): if hold == hold_mode: # no change, so no action required return - elif hold_mode == 'None' or hold_mode is None: + if hold_mode == 'None' or hold_mode is None: if hold == VACATION_HOLD: self.data.ecobee.delete_vacation( self.thermostat_index, self.vacation) diff --git a/homeassistant/components/climate/eq3btsmart.py b/homeassistant/components/climate/eq3btsmart.py index 820e715b00d118..10fd879e386296 100644 --- a/homeassistant/components/climate/eq3btsmart.py +++ b/homeassistant/components/climate/eq3btsmart.py @@ -53,7 +53,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices(devices) -# pylint: disable=import-error, no-name-in-module +# pylint: disable=import-error class EQ3BTSmartThermostat(ClimateDevice): """Representation of an eQ-3 Bluetooth Smart thermostat.""" diff --git a/homeassistant/components/climate/flexit.py b/homeassistant/components/climate/flexit.py index 565e913319f4c4..6c340e4a5f01f4 100644 --- a/homeassistant/components/climate/flexit.py +++ b/homeassistant/components/climate/flexit.py @@ -20,7 +20,7 @@ from homeassistant.components.climate import ( ClimateDevice, PLATFORM_SCHEMA, SUPPORT_TARGET_TEMPERATURE, SUPPORT_FAN_MODE) -import homeassistant.components.modbus as modbus +from homeassistant.components import modbus import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['pyflexit==0.3'] diff --git a/homeassistant/components/climate/fritzbox.py b/homeassistant/components/climate/fritzbox.py old mode 100755 new mode 100644 index 839da8c9d53331..fa3ca31c770725 --- a/homeassistant/components/climate/fritzbox.py +++ b/homeassistant/components/climate/fritzbox.py @@ -13,21 +13,27 @@ ATTR_STATE_DEVICE_LOCKED, ATTR_STATE_BATTERY_LOW, ATTR_STATE_LOCKED) from homeassistant.components.climate import ( ATTR_OPERATION_MODE, ClimateDevice, STATE_ECO, STATE_HEAT, STATE_MANUAL, - SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE) + STATE_OFF, STATE_ON, SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE) from homeassistant.const import ( ATTR_TEMPERATURE, PRECISION_HALVES, TEMP_CELSIUS) - DEPENDENCIES = ['fritzbox'] _LOGGER = logging.getLogger(__name__) SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE) -OPERATION_LIST = [STATE_HEAT, STATE_ECO] +OPERATION_LIST = [STATE_HEAT, STATE_ECO, STATE_OFF, STATE_ON] MIN_TEMPERATURE = 8 MAX_TEMPERATURE = 28 +# special temperatures for on/off in Fritz!Box API (modified by pyfritzhome) +ON_API_TEMPERATURE = 127.0 +OFF_API_TEMPERATURE = 126.5 +ON_REPORT_SET_TEMPERATURE = 30.0 +OFF_REPORT_SET_TEMPERATURE = 0.0 + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Fritzbox smarthome thermostat platform.""" @@ -88,6 +94,9 @@ def current_temperature(self): @property def target_temperature(self): """Return the temperature we try to reach.""" + if self._target_temperature in (ON_API_TEMPERATURE, + OFF_API_TEMPERATURE): + return None return self._target_temperature def set_temperature(self, **kwargs): @@ -102,9 +111,13 @@ def set_temperature(self, **kwargs): @property def current_operation(self): """Return the current operation mode.""" + if self._target_temperature == ON_API_TEMPERATURE: + return STATE_ON + if self._target_temperature == OFF_API_TEMPERATURE: + return STATE_OFF if self._target_temperature == self._comfort_temperature: return STATE_HEAT - elif self._target_temperature == self._eco_temperature: + if self._target_temperature == self._eco_temperature: return STATE_ECO return STATE_MANUAL @@ -119,6 +132,10 @@ def set_operation_mode(self, operation_mode): self.set_temperature(temperature=self._comfort_temperature) elif operation_mode == STATE_ECO: self.set_temperature(temperature=self._eco_temperature) + elif operation_mode == STATE_OFF: + self.set_temperature(temperature=OFF_REPORT_SET_TEMPERATURE) + elif operation_mode == STATE_ON: + self.set_temperature(temperature=ON_REPORT_SET_TEMPERATURE) @property def min_temp(self): diff --git a/homeassistant/components/climate/generic_thermostat.py b/homeassistant/components/climate/generic_thermostat.py index b5d3c3f7c253af..3f1d9a208ac5fd 100644 --- a/homeassistant/components/climate/generic_thermostat.py +++ b/homeassistant/components/climate/generic_thermostat.py @@ -263,22 +263,20 @@ def async_set_temperature(self, **kwargs): @property def min_temp(self): """Return the minimum temperature.""" - # pylint: disable=no-member if self._min_temp: return self._min_temp # get default temp from super class - return ClimateDevice.min_temp.fget(self) + return super().min_temp @property def max_temp(self): """Return the maximum temperature.""" - # pylint: disable=no-member if self._max_temp: return self._max_temp # Get default temp from super class - return ClimateDevice.max_temp.fget(self) + return super().max_temp @asyncio.coroutine def _async_sensor_changed(self, entity_id, old_state, new_state): diff --git a/homeassistant/components/climate/heatmiser.py b/homeassistant/components/climate/heatmiser.py index 19c033a319f5bf..12057e886472f9 100644 --- a/homeassistant/components/climate/heatmiser.py +++ b/homeassistant/components/climate/heatmiser.py @@ -34,7 +34,6 @@ }) -# pylint: disable=unused-variable def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the heatmiser thermostat.""" from heatmiserV3 import heatmiser, connection @@ -51,7 +50,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): HeatmiserV3Thermostat( heatmiser, tstat.get(CONF_ID), tstat.get(CONF_NAME), serport) ]) - return class HeatmiserV3Thermostat(ClimateDevice): diff --git a/homeassistant/components/climate/homekit_controller.py b/homeassistant/components/climate/homekit_controller.py new file mode 100644 index 00000000000000..f9178c2e0d55af --- /dev/null +++ b/homeassistant/components/climate/homekit_controller.py @@ -0,0 +1,130 @@ +""" +Support for Homekit climate devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.homekit_controller/ +""" +import logging + +from homeassistant.components.homekit_controller import ( + HomeKitEntity, KNOWN_ACCESSORIES) +from homeassistant.components.climate import ( + ClimateDevice, STATE_HEAT, STATE_COOL, STATE_IDLE, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE) +from homeassistant.const import TEMP_CELSIUS, STATE_OFF, ATTR_TEMPERATURE + +DEPENDENCIES = ['homekit_controller'] + +_LOGGER = logging.getLogger(__name__) + +# Map of Homekit operation modes to hass modes +MODE_HOMEKIT_TO_HASS = { + 0: STATE_OFF, + 1: STATE_HEAT, + 2: STATE_COOL, +} + +# Map of hass operation modes to homekit modes +MODE_HASS_TO_HOMEKIT = {v: k for k, v in MODE_HOMEKIT_TO_HASS.items()} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Homekit climate.""" + if discovery_info is not None: + accessory = hass.data[KNOWN_ACCESSORIES][discovery_info['serial']] + add_devices([HomeKitClimateDevice(accessory, discovery_info)], True) + + +class HomeKitClimateDevice(HomeKitEntity, ClimateDevice): + """Representation of a Homekit climate device.""" + + def __init__(self, *args): + """Initialise the device.""" + super().__init__(*args) + self._state = None + self._current_mode = None + self._valid_modes = [] + self._current_temp = None + self._target_temp = None + + def update_characteristics(self, characteristics): + """Synchronise device state with Home Assistant.""" + # pylint: disable=import-error + from homekit import CharacteristicsTypes as ctypes + + for characteristic in characteristics: + ctype = characteristic['type'] + if ctype == ctypes.HEATING_COOLING_CURRENT: + self._state = MODE_HOMEKIT_TO_HASS.get( + characteristic['value']) + if ctype == ctypes.HEATING_COOLING_TARGET: + self._chars['target_mode'] = characteristic['iid'] + self._features |= SUPPORT_OPERATION_MODE + self._current_mode = MODE_HOMEKIT_TO_HASS.get( + characteristic['value']) + self._valid_modes = [MODE_HOMEKIT_TO_HASS.get( + mode) for mode in characteristic['valid-values']] + elif ctype == ctypes.TEMPERATURE_CURRENT: + self._current_temp = characteristic['value'] + elif ctype == ctypes.TEMPERATURE_TARGET: + self._chars['target_temp'] = characteristic['iid'] + self._features |= SUPPORT_TARGET_TEMPERATURE + self._target_temp = characteristic['value'] + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + temp = kwargs.get(ATTR_TEMPERATURE) + + characteristics = [{'aid': self._aid, + 'iid': self._chars['target_temp'], + 'value': temp}] + self.put_characteristics(characteristics) + + def set_operation_mode(self, operation_mode): + """Set new target operation mode.""" + characteristics = [{'aid': self._aid, + 'iid': self._chars['target_mode'], + 'value': MODE_HASS_TO_HOMEKIT[operation_mode]}] + self.put_characteristics(characteristics) + + @property + def state(self): + """Return the current state.""" + # If the device reports its operating mode as off, it sometimes doesn't + # report a new state. + if self._current_mode == STATE_OFF: + return STATE_OFF + + if self._state == STATE_OFF and self._current_mode != STATE_OFF: + return STATE_IDLE + return self._state + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temp + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temp + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + return self._current_mode + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return self._valid_modes + + @property + def supported_features(self): + """Return the list of supported features.""" + return self._features + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS diff --git a/homeassistant/components/climate/homematic.py b/homeassistant/components/climate/homematic.py index b8fb7a984fa390..a2725f6f3aa5d3 100644 --- a/homeassistant/components/climate/homematic.py +++ b/homeassistant/components/climate/homematic.py @@ -87,7 +87,7 @@ def current_operation(self): # HM ip etrv 2 uses the set_point_mode to say if its # auto or manual - elif not set_point_mode == -1: + if not set_point_mode == -1: code = set_point_mode # Other devices use the control_mode else: diff --git a/homeassistant/components/climate/homematicip_cloud.py b/homeassistant/components/climate/homematicip_cloud.py new file mode 100644 index 00000000000000..8cf47159c103fd --- /dev/null +++ b/homeassistant/components/climate/homematicip_cloud.py @@ -0,0 +1,103 @@ +""" +Support for HomematicIP climate. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/climate.homematicip_cloud/ +""" + +import logging + +from homeassistant.components.climate import ( + ClimateDevice, SUPPORT_TARGET_TEMPERATURE, ATTR_TEMPERATURE, + STATE_AUTO, STATE_MANUAL) +from homeassistant.const import TEMP_CELSIUS +from homeassistant.components.homematicip_cloud import ( + HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN, + HMIPC_HAPID) + +_LOGGER = logging.getLogger(__name__) + +STATE_BOOST = 'Boost' + +HA_STATE_TO_HMIP = { + STATE_AUTO: 'AUTOMATIC', + STATE_MANUAL: 'MANUAL', +} + +HMIP_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_HMIP.items()} + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the HomematicIP climate devices.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the HomematicIP climate from a config entry.""" + from homematicip.group import HeatingGroup + + home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home + devices = [] + for device in home.groups: + if isinstance(device, HeatingGroup): + devices.append(HomematicipHeatingGroup(home, device)) + + if devices: + async_add_devices(devices) + + +class HomematicipHeatingGroup(HomematicipGenericDevice, ClimateDevice): + """Representation of a MomematicIP heating group.""" + + def __init__(self, home, device): + """Initialize heating group.""" + device.modelType = 'Group-Heating' + super().__init__(home, device) + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def supported_features(self): + """Return the list of supported features.""" + return SUPPORT_TARGET_TEMPERATURE + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._device.setPointTemperature + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._device.actualTemperature + + @property + def current_humidity(self): + """Return the current humidity.""" + return self._device.humidity + + @property + def current_operation(self): + """Return current operation ie. automatic or manual.""" + return HMIP_STATE_TO_HA.get(self._device.controlMode) + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self._device.minTemperature + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self._device.maxTemperature + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + await self._device.set_point_temperature(temperature) diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py index 11a507aded2d05..04d705d6b49bd6 100644 --- a/homeassistant/components/climate/honeywell.py +++ b/homeassistant/components/climate/honeywell.py @@ -165,7 +165,7 @@ def set_temperature(self, **kwargs): self.client.set_temperature(self._name, temperature) @property - def current_operation(self: ClimateDevice) -> str: + def current_operation(self) -> str: """Get the current operation of the system.""" return getattr(self.client, ATTR_SYSTEM_MODE, None) @@ -174,7 +174,7 @@ def is_away_mode_on(self): """Return true if away mode is on.""" return self._away - def set_operation_mode(self: ClimateDevice, operation_mode: str) -> None: + def set_operation_mode(self, operation_mode: str) -> None: """Set the HVAC mode for the thermostat.""" if hasattr(self.client, ATTR_SYSTEM_MODE): self.client.system_mode = operation_mode @@ -280,7 +280,7 @@ def target_temperature(self): return self._device.setpoint_heat @property - def current_operation(self: ClimateDevice) -> str: + def current_operation(self) -> str: """Return current operation ie. heat, cool, idle.""" oper = getattr(self._device, ATTR_CURRENT_OPERATION, None) if oper == "off": @@ -373,7 +373,7 @@ def turn_away_mode_off(self): except somecomfort.SomeComfortError: _LOGGER.error('Can not stop hold mode') - def set_operation_mode(self: ClimateDevice, operation_mode: str) -> None: + def set_operation_mode(self, operation_mode: str) -> None: """Set the system mode (Cool, Heat, etc).""" if hasattr(self._device, ATTR_SYSTEM_MODE): self._device.system_mode = operation_mode diff --git a/homeassistant/components/climate/knx.py b/homeassistant/components/climate/knx.py index 5ce6cc2fa7af0a..f53cf2491dc5c5 100644 --- a/homeassistant/components/climate/knx.py +++ b/homeassistant/components/climate/knx.py @@ -136,7 +136,6 @@ def async_register_callbacks(self): """Register callbacks to update hass after device was changed.""" async def after_update_callback(device): """Call after device was updated.""" - # pylint: disable=unused-argument await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) diff --git a/homeassistant/components/climate/melissa.py b/homeassistant/components/climate/melissa.py index 9c005b62dccf7f..a0adc12bfbfb98 100644 --- a/homeassistant/components/climate/melissa.py +++ b/homeassistant/components/climate/melissa.py @@ -192,9 +192,9 @@ def melissa_state_to_hass(self, state): """Translate Melissa states to hass states.""" if state == self._api.STATE_ON: return STATE_ON - elif state == self._api.STATE_OFF: + if state == self._api.STATE_OFF: return STATE_OFF - elif state == self._api.STATE_IDLE: + if state == self._api.STATE_IDLE: return STATE_IDLE return None @@ -202,11 +202,11 @@ def melissa_op_to_hass(self, mode): """Translate Melissa modes to hass states.""" if mode == self._api.MODE_HEAT: return STATE_HEAT - elif mode == self._api.MODE_COOL: + if mode == self._api.MODE_COOL: return STATE_COOL - elif mode == self._api.MODE_DRY: + if mode == self._api.MODE_DRY: return STATE_DRY - elif mode == self._api.MODE_FAN: + if mode == self._api.MODE_FAN: return STATE_FAN_ONLY _LOGGER.warning( "Operation mode %s could not be mapped to hass", mode) @@ -216,11 +216,11 @@ def melissa_fan_to_hass(self, fan): """Translate Melissa fan modes to hass modes.""" if fan == self._api.FAN_AUTO: return STATE_AUTO - elif fan == self._api.FAN_LOW: + if fan == self._api.FAN_LOW: return SPEED_LOW - elif fan == self._api.FAN_MEDIUM: + if fan == self._api.FAN_MEDIUM: return SPEED_MEDIUM - elif fan == self._api.FAN_HIGH: + if fan == self._api.FAN_HIGH: return SPEED_HIGH _LOGGER.warning("Fan mode %s could not be mapped to hass", fan) return None @@ -229,24 +229,22 @@ def hass_mode_to_melissa(self, mode): """Translate hass states to melissa modes.""" if mode == STATE_HEAT: return self._api.MODE_HEAT - elif mode == STATE_COOL: + if mode == STATE_COOL: return self._api.MODE_COOL - elif mode == STATE_DRY: + if mode == STATE_DRY: return self._api.MODE_DRY - elif mode == STATE_FAN_ONLY: + if mode == STATE_FAN_ONLY: return self._api.MODE_FAN - else: - _LOGGER.warning("Melissa have no setting for %s mode", mode) + _LOGGER.warning("Melissa have no setting for %s mode", mode) def hass_fan_to_melissa(self, fan): """Translate hass fan modes to melissa modes.""" if fan == STATE_AUTO: return self._api.FAN_AUTO - elif fan == SPEED_LOW: + if fan == SPEED_LOW: return self._api.FAN_LOW - elif fan == SPEED_MEDIUM: + if fan == SPEED_MEDIUM: return self._api.FAN_MEDIUM - elif fan == SPEED_HIGH: + if fan == SPEED_HIGH: return self._api.FAN_HIGH - else: - _LOGGER.warning("Melissa have no setting for %s fan mode", fan) + _LOGGER.warning("Melissa have no setting for %s fan mode", fan) diff --git a/homeassistant/components/climate/modbus.py b/homeassistant/components/climate/modbus.py index 7d392e5a40f6a3..e567340efc9f82 100644 --- a/homeassistant/components/climate/modbus.py +++ b/homeassistant/components/climate/modbus.py @@ -18,7 +18,7 @@ from homeassistant.components.climate import ( ClimateDevice, PLATFORM_SCHEMA, SUPPORT_TARGET_TEMPERATURE) -import homeassistant.components.modbus as modbus +from homeassistant.components import modbus import homeassistant.helpers.config_validation as cv DEPENDENCIES = ['modbus'] diff --git a/homeassistant/components/climate/mqtt.py b/homeassistant/components/climate/mqtt.py index 1d98a5733f7054..1426ff31af90e5 100644 --- a/homeassistant/components/climate/mqtt.py +++ b/homeassistant/components/climate/mqtt.py @@ -10,14 +10,14 @@ import voluptuous as vol from homeassistant.core import callback -import homeassistant.components.mqtt as mqtt +from homeassistant.components import mqtt from homeassistant.components.climate import ( STATE_HEAT, STATE_COOL, STATE_DRY, STATE_FAN_ONLY, ClimateDevice, PLATFORM_SCHEMA as CLIMATE_PLATFORM_SCHEMA, STATE_AUTO, ATTR_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, SUPPORT_SWING_MODE, SUPPORT_FAN_MODE, SUPPORT_AWAY_MODE, SUPPORT_HOLD_MODE, - SUPPORT_AUX_HEAT) + SUPPORT_AUX_HEAT, DEFAULT_MIN_TEMP, DEFAULT_MAX_TEMP) from homeassistant.const import ( STATE_ON, STATE_OFF, ATTR_TEMPERATURE, CONF_NAME, CONF_VALUE_TEMPLATE) from homeassistant.components.mqtt import ( @@ -70,6 +70,9 @@ CONF_INITIAL = 'initial' CONF_SEND_IF_OFF = 'send_if_off' +CONF_MIN_TEMP = 'min_temp' +CONF_MAX_TEMP = 'max_temp' + SCHEMA_BASE = CLIMATE_PLATFORM_SCHEMA.extend(MQTT_BASE_PLATFORM_SCHEMA.schema) PLATFORM_SCHEMA = SCHEMA_BASE.extend({ vol.Optional(CONF_POWER_COMMAND_TOPIC): mqtt.valid_publish_topic, @@ -116,12 +119,19 @@ vol.Optional(CONF_SEND_IF_OFF, default=True): cv.boolean, vol.Optional(CONF_PAYLOAD_ON, default="ON"): cv.string, vol.Optional(CONF_PAYLOAD_OFF, default="OFF"): cv.string, + + vol.Optional(CONF_MIN_TEMP, default=DEFAULT_MIN_TEMP): vol.Coerce(float), + vol.Optional(CONF_MAX_TEMP, default=DEFAULT_MAX_TEMP): vol.Coerce(float) + }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the MQTT climate devices.""" + if discovery_info is not None: + config = PLATFORM_SCHEMA(discovery_info) + template_keys = ( CONF_POWER_STATE_TEMPLATE, CONF_MODE_STATE_TEMPLATE, @@ -181,19 +191,22 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): config.get(CONF_PAYLOAD_OFF), config.get(CONF_AVAILABILITY_TOPIC), config.get(CONF_PAYLOAD_AVAILABLE), - config.get(CONF_PAYLOAD_NOT_AVAILABLE)) + config.get(CONF_PAYLOAD_NOT_AVAILABLE), + config.get(CONF_MIN_TEMP), + config.get(CONF_MAX_TEMP)) ]) class MqttClimate(MqttAvailability, ClimateDevice): - """Representation of a demo climate device.""" + """Representation of an MQTT climate device.""" def __init__(self, hass, name, topic, value_templates, qos, retain, mode_list, fan_mode_list, swing_mode_list, target_temperature, away, hold, current_fan_mode, current_swing_mode, current_operation, aux, send_if_off, payload_on, payload_off, availability_topic, - payload_available, payload_not_available): + payload_available, payload_not_available, + min_temp, max_temp): """Initialize the climate device.""" super().__init__(availability_topic, qos, payload_available, payload_not_available) @@ -219,6 +232,8 @@ def __init__(self, hass, name, topic, value_templates, qos, retain, self._send_if_off = send_if_off self._payload_on = payload_on self._payload_off = payload_off + self._min_temp = min_temp + self._max_temp = max_temp @asyncio.coroutine def async_added_to_hass(self): @@ -619,3 +634,13 @@ def supported_features(self): support |= SUPPORT_AUX_HEAT return support + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self._min_temp + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self._max_temp diff --git a/homeassistant/components/climate/mysensors.py b/homeassistant/components/climate/mysensors.py index 2545094ceecd17..a2043c2434bfbb 100644 --- a/homeassistant/components/climate/mysensors.py +++ b/homeassistant/components/climate/mysensors.py @@ -26,9 +26,8 @@ 'Off': STATE_OFF, } -SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_TARGET_TEMPERATURE_HIGH | - SUPPORT_TARGET_TEMPERATURE_LOW | SUPPORT_FAN_MODE | - SUPPORT_OPERATION_MODE) +FAN_LIST = ['Auto', 'Min', 'Normal', 'Max'] +OPERATION_LIST = [STATE_OFF, STATE_AUTO, STATE_COOL, STATE_HEAT] async def async_setup_platform( @@ -39,13 +38,24 @@ async def async_setup_platform( async_add_devices=async_add_devices) -class MySensorsHVAC(mysensors.MySensorsEntity, ClimateDevice): +class MySensorsHVAC(mysensors.device.MySensorsEntity, ClimateDevice): """Representation of a MySensors HVAC.""" @property def supported_features(self): """Return the list of supported features.""" - return SUPPORT_FLAGS + features = SUPPORT_OPERATION_MODE + set_req = self.gateway.const.SetReq + if set_req.V_HVAC_SPEED in self._values: + features = features | SUPPORT_FAN_MODE + if (set_req.V_HVAC_SETPOINT_COOL in self._values and + set_req.V_HVAC_SETPOINT_HEAT in self._values): + features = ( + features | SUPPORT_TARGET_TEMPERATURE_HIGH | + SUPPORT_TARGET_TEMPERATURE_LOW) + else: + features = features | SUPPORT_TARGET_TEMPERATURE + return features @property def assumed_state(self): @@ -103,7 +113,7 @@ def current_operation(self): @property def operation_list(self): """List of available operation modes.""" - return [STATE_OFF, STATE_AUTO, STATE_COOL, STATE_HEAT] + return OPERATION_LIST @property def current_fan_mode(self): @@ -113,9 +123,9 @@ def current_fan_mode(self): @property def fan_list(self): """List of available fan modes.""" - return ['Auto', 'Min', 'Normal', 'Max'] + return FAN_LIST - def set_temperature(self, **kwargs): + async def async_set_temperature(self, **kwargs): """Set new target temperature.""" set_req = self.gateway.const.SetReq temp = kwargs.get(ATTR_TEMPERATURE) @@ -143,9 +153,9 @@ def set_temperature(self, **kwargs): if self.gateway.optimistic: # Optimistically assume that device has changed state self._values[value_type] = value - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() - def set_fan_mode(self, fan_mode): + async def async_set_fan_mode(self, fan_mode): """Set new target temperature.""" set_req = self.gateway.const.SetReq self.gateway.set_child_value( @@ -153,9 +163,9 @@ def set_fan_mode(self, fan_mode): if self.gateway.optimistic: # Optimistically assume that device has changed state self._values[set_req.V_HVAC_SPEED] = fan_mode - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() - def set_operation_mode(self, operation_mode): + async def async_set_operation_mode(self, operation_mode): """Set new target temperature.""" self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, @@ -163,7 +173,7 @@ def set_operation_mode(self, operation_mode): if self.gateway.optimistic: # Optimistically assume that device has changed state self._values[self.value_type] = operation_mode - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() async def async_update(self): """Update the controller with the latest value from a sensor.""" diff --git a/homeassistant/components/climate/nest.py b/homeassistant/components/climate/nest.py index 0a5344fdf9899e..fa3943c3e276fd 100644 --- a/homeassistant/components/climate/nest.py +++ b/homeassistant/components/climate/nest.py @@ -8,7 +8,7 @@ import voluptuous as vol -from homeassistant.components.nest import DATA_NEST +from homeassistant.components.nest import DATA_NEST, SIGNAL_NEST_UPDATE from homeassistant.components.climate import ( STATE_AUTO, STATE_COOL, STATE_HEAT, STATE_ECO, ClimateDevice, PLATFORM_SCHEMA, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, @@ -18,6 +18,7 @@ from homeassistant.const import ( TEMP_CELSIUS, TEMP_FAHRENHEIT, CONF_SCAN_INTERVAL, STATE_ON, STATE_OFF, STATE_UNKNOWN) +from homeassistant.helpers.dispatcher import async_dispatcher_connect DEPENDENCIES = ['nest'] _LOGGER = logging.getLogger(__name__) @@ -31,17 +32,22 @@ def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Nest thermostat.""" - if discovery_info is None: - return + """Set up the Nest thermostat. + No longer in use. + """ + + +async def async_setup_entry(hass, entry, async_add_devices): + """Set up the Nest climate device based on a config entry.""" temp_unit = hass.config.units.temperature_unit - add_devices( - [NestThermostat(structure, device, temp_unit) - for structure, device in hass.data[DATA_NEST].thermostats()], - True - ) + thermostats = await hass.async_add_job(hass.data[DATA_NEST].thermostats) + + all_devices = [NestThermostat(structure, device, temp_unit) + for structure, device in thermostats] + + async_add_devices(all_devices, True) class NestThermostat(ClimateDevice): @@ -97,6 +103,20 @@ def __init__(self, structure, device, temp_unit): self._min_temperature = None self._max_temperature = None + @property + def should_poll(self): + """Do not need poll thanks using Nest streaming API.""" + return False + + async def async_added_to_hass(self): + """Register update signal handler.""" + async def async_update_state(): + """Update device state.""" + await self.async_update_ha_state(True) + + async_dispatcher_connect(self.hass, SIGNAL_NEST_UPDATE, + async_update_state) + @property def supported_features(self): """Return the list of supported features.""" @@ -127,14 +147,16 @@ def current_operation(self): """Return current operation ie. heat, cool, idle.""" if self._mode in [STATE_HEAT, STATE_COOL, STATE_OFF, STATE_ECO]: return self._mode - elif self._mode == NEST_MODE_HEAT_COOL: + if self._mode == NEST_MODE_HEAT_COOL: return STATE_AUTO return STATE_UNKNOWN @property def target_temperature(self): """Return the temperature we try to reach.""" - if self._mode != NEST_MODE_HEAT_COOL and not self.is_away_mode_on: + if self._mode != NEST_MODE_HEAT_COOL and \ + self._mode != STATE_ECO and \ + not self.is_away_mode_on: return self._target_temperature return None @@ -168,18 +190,24 @@ def is_away_mode_on(self): def set_temperature(self, **kwargs): """Set new target temperature.""" import nest + temp = None target_temp_low = kwargs.get(ATTR_TARGET_TEMP_LOW) target_temp_high = kwargs.get(ATTR_TARGET_TEMP_HIGH) if self._mode == NEST_MODE_HEAT_COOL: if target_temp_low is not None and target_temp_high is not None: temp = (target_temp_low, target_temp_high) + _LOGGER.debug("Nest set_temperature-output-value=%s", temp) else: temp = kwargs.get(ATTR_TEMPERATURE) - _LOGGER.debug("Nest set_temperature-output-value=%s", temp) + _LOGGER.debug("Nest set_temperature-output-value=%s", temp) try: - self.device.target = temp - except nest.nest.APIError: - _LOGGER.error("An error occurred while setting the temperature") + if temp is not None: + self.device.target = temp + except nest.nest.APIError as api_error: + _LOGGER.error("An error occurred while setting temperature: %s", + api_error) + # restore target temperature + self.schedule_update_ha_state(True) def set_operation_mode(self, operation_mode): """Set operation mode.""" diff --git a/homeassistant/components/climate/netatmo.py b/homeassistant/components/climate/netatmo.py index 49452662fc43e1..b4bed3678785ba 100644 --- a/homeassistant/components/climate/netatmo.py +++ b/homeassistant/components/climate/netatmo.py @@ -44,7 +44,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): netatmo = hass.components.netatmo device = config.get(CONF_RELAY) - import lnetatmo + import pyatmo try: data = ThermostatData(netatmo.NETATMO_AUTH, device) for module_name in data.get_module_names(): @@ -53,7 +53,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): module_name not in config[CONF_THERMOSTAT]: continue add_devices([NetatmoThermostat(data, module_name)], True) - except lnetatmo.NoDevice: + except pyatmo.NoDevice: return None @@ -99,7 +99,7 @@ def current_operation(self): state = self._data.thermostatdata.relay_cmd if state == 0: return STATE_IDLE - elif state == 100: + if state == 100: return STATE_HEAT @property @@ -140,7 +140,7 @@ def update(self): self._away = self._data.setpoint_mode == 'away' -class ThermostatData(object): +class ThermostatData: """Get the latest data from Netatmo.""" def __init__(self, auth, device=None): @@ -168,8 +168,8 @@ def get_module_names(self): @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Call the NetAtmo API to update the data.""" - import lnetatmo - self.thermostatdata = lnetatmo.ThermostatData(self.auth) + import pyatmo + self.thermostatdata = pyatmo.ThermostatData(self.auth) self.target_temperature = self.thermostatdata.setpoint_temp self.setpoint_mode = self.thermostatdata.setpoint_mode self.current_temperature = self.thermostatdata.temp diff --git a/homeassistant/components/climate/proliphix.py b/homeassistant/components/climate/proliphix.py index 34fcfd667b6a79..9338c219fe5e22 100644 --- a/homeassistant/components/climate/proliphix.py +++ b/homeassistant/components/climate/proliphix.py @@ -102,9 +102,9 @@ def current_operation(self): state = self._pdp.hvac_state if state in (1, 2): return STATE_IDLE - elif state == 3: + if state == 3: return STATE_HEAT - elif state == 6: + if state == 6: return STATE_COOL def set_temperature(self, **kwargs): diff --git a/homeassistant/components/climate/radiotherm.py b/homeassistant/components/climate/radiotherm.py index 032d85637ef506..c8441a9f7af0ea 100644 --- a/homeassistant/components/climate/radiotherm.py +++ b/homeassistant/components/climate/radiotherm.py @@ -18,7 +18,7 @@ CONF_HOST, TEMP_FAHRENHEIT, ATTR_TEMPERATURE, PRECISION_HALVES) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['radiotherm==1.3'] +REQUIREMENTS = ['radiotherm==1.4.1'] _LOGGER = logging.getLogger(__name__) @@ -308,7 +308,7 @@ def set_time(self): def set_operation_mode(self, operation_mode): """Set operation mode (auto, cool, heat, off).""" - if operation_mode == STATE_OFF or operation_mode == STATE_AUTO: + if operation_mode in (STATE_OFF, STATE_AUTO): self.device.tmode = TEMP_MODE_TO_CODE[operation_mode] # Setting t_cool or t_heat automatically changes tmode. diff --git a/homeassistant/components/climate/sensibo.py b/homeassistant/components/climate/sensibo.py index e2a455aefc7b8c..363653608e86ca 100644 --- a/homeassistant/components/climate/sensibo.py +++ b/homeassistant/components/climate/sensibo.py @@ -25,7 +25,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.util.temperature import convert as convert_temperature -REQUIREMENTS = ['pysensibo==1.0.2'] +REQUIREMENTS = ['pysensibo==1.0.3'] _LOGGER = logging.getLogger(__name__) @@ -154,7 +154,8 @@ def state(self): @property def device_state_attributes(self): """Return the state attributes.""" - return {ATTR_CURRENT_HUMIDITY: self.current_humidity} + return {ATTR_CURRENT_HUMIDITY: self.current_humidity, + 'battery': self.current_battery} @property def temperature_unit(self): @@ -191,6 +192,11 @@ def current_humidity(self): """Return the current humidity.""" return self._measurements['humidity'] + @property + def current_battery(self): + """Return the current battery voltage.""" + return self._measurements.get('batteryVoltage') + @property def current_temperature(self): """Return the current temperature.""" diff --git a/homeassistant/components/climate/spider.py b/homeassistant/components/climate/spider.py new file mode 100644 index 00000000000000..a6916b22a25aa6 --- /dev/null +++ b/homeassistant/components/climate/spider.py @@ -0,0 +1,127 @@ +""" +Support for Spider thermostats. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.spider/ +""" + +import logging + +from homeassistant.components.climate import ( + ATTR_TEMPERATURE, STATE_COOL, STATE_HEAT, STATE_IDLE, + SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, ClimateDevice) +from homeassistant.components.spider import DOMAIN as SPIDER_DOMAIN +from homeassistant.const import TEMP_CELSIUS + +DEPENDENCIES = ['spider'] + +OPERATION_LIST = [ + STATE_HEAT, + STATE_COOL, +] + +HA_STATE_TO_SPIDER = { + STATE_COOL: 'Cool', + STATE_HEAT: 'Heat', + STATE_IDLE: 'Idle' +} + +SPIDER_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_SPIDER.items()} + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Spider thermostat.""" + if discovery_info is None: + return + + devices = [SpiderThermostat(hass.data[SPIDER_DOMAIN]['controller'], device) + for device in hass.data[SPIDER_DOMAIN]['thermostats']] + add_devices(devices, True) + + +class SpiderThermostat(ClimateDevice): + """Representation of a thermostat.""" + + def __init__(self, api, thermostat): + """Initialize the thermostat.""" + self.api = api + self.thermostat = thermostat + + @property + def supported_features(self): + """Return the list of supported features.""" + supports = SUPPORT_TARGET_TEMPERATURE + + if self.thermostat.has_operation_mode: + supports = supports | SUPPORT_OPERATION_MODE + + return supports + + @property + def unique_id(self): + """Return the id of the thermostat, if any.""" + return self.thermostat.id + + @property + def name(self): + """Return the name of the thermostat, if any.""" + return self.thermostat.name + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def current_temperature(self): + """Return the current temperature.""" + return self.thermostat.current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self.thermostat.target_temperature + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return self.thermostat.temperature_steps + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self.thermostat.minimum_temperature + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self.thermostat.maximum_temperature + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + return SPIDER_STATE_TO_HA[self.thermostat.operation_mode] + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return OPERATION_LIST + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is None: + return + + self.thermostat.set_temperature(temperature) + + def set_operation_mode(self, operation_mode): + """Set new target operation mode.""" + self.thermostat.set_operation_mode( + HA_STATE_TO_SPIDER.get(operation_mode)) + + def update(self): + """Get the latest data.""" + self.thermostat = self.api.get_thermostat(self.unique_id) diff --git a/homeassistant/components/climate/tado.py b/homeassistant/components/climate/tado.py index 437c8ec3371bf5..b3734e020e00e2 100644 --- a/homeassistant/components/climate/tado.py +++ b/homeassistant/components/climate/tado.py @@ -9,6 +9,7 @@ from homeassistant.const import (PRECISION_TENTHS, TEMP_CELSIUS) from homeassistant.components.climate import ( ClimateDevice, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE) +from homeassistant.util.temperature import convert as convert_temperature from homeassistant.const import ATTR_TEMPERATURE from homeassistant.components.tado import DATA_TADO @@ -230,18 +231,14 @@ def set_operation_mode(self, readable_operation_mode): @property def min_temp(self): """Return the minimum temperature.""" - if self._min_temp: - return self._min_temp - # get default temp from super class - return super().min_temp + return convert_temperature(self._min_temp, self._unit, + self.hass.config.units.temperature_unit) @property def max_temp(self): """Return the maximum temperature.""" - if self._max_temp: - return self._max_temp - # Get default temp from super class - return super().max_temp + return convert_temperature(self._max_temp, self._unit, + self.hass.config.units.temperature_unit) def update(self): """Update the state of this climate device.""" diff --git a/homeassistant/components/climate/tuya.py b/homeassistant/components/climate/tuya.py new file mode 100644 index 00000000000000..19267d693a04bc --- /dev/null +++ b/homeassistant/components/climate/tuya.py @@ -0,0 +1,173 @@ +""" +Support for the Tuya climate devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.tuya/ +""" + +from homeassistant.components.climate import ( + ATTR_TEMPERATURE, ENTITY_ID_FORMAT, STATE_AUTO, STATE_COOL, STATE_ECO, + STATE_ELECTRIC, STATE_FAN_ONLY, STATE_GAS, STATE_HEAT, STATE_HEAT_PUMP, + STATE_HIGH_DEMAND, STATE_PERFORMANCE, SUPPORT_FAN_MODE, SUPPORT_ON_OFF, + SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, ClimateDevice) +from homeassistant.components.fan import SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH +from homeassistant.components.tuya import DATA_TUYA, TuyaDevice + +from homeassistant.const import ( + PRECISION_WHOLE, TEMP_CELSIUS, TEMP_FAHRENHEIT) + +DEPENDENCIES = ['tuya'] +DEVICE_TYPE = 'climate' + +HA_STATE_TO_TUYA = { + STATE_AUTO: 'auto', + STATE_COOL: 'cold', + STATE_ECO: 'eco', + STATE_ELECTRIC: 'electric', + STATE_FAN_ONLY: 'wind', + STATE_GAS: 'gas', + STATE_HEAT: 'hot', + STATE_HEAT_PUMP: 'heat_pump', + STATE_HIGH_DEMAND: 'high_demand', + STATE_PERFORMANCE: 'performance', +} + +TUYA_STATE_TO_HA = {value: key for key, value in HA_STATE_TO_TUYA.items()} + +FAN_MODES = {SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH} + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Tuya Climate devices.""" + if discovery_info is None: + return + tuya = hass.data[DATA_TUYA] + dev_ids = discovery_info.get('dev_ids') + devices = [] + for dev_id in dev_ids: + device = tuya.get_device_by_id(dev_id) + if device is None: + continue + devices.append(TuyaClimateDevice(device)) + add_devices(devices) + + +class TuyaClimateDevice(TuyaDevice, ClimateDevice): + """Tuya climate devices,include air conditioner,heater.""" + + def __init__(self, tuya): + """Init climate device.""" + super().__init__(tuya) + self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) + self.operations = [] + + async def async_added_to_hass(self): + """Create operation list when add to hass.""" + await super().async_added_to_hass() + modes = self.tuya.operation_list() + if modes is None: + return + for mode in modes: + if mode in TUYA_STATE_TO_HA: + self.operations.append(TUYA_STATE_TO_HA[mode]) + + @property + def is_on(self): + """Return true if climate is on.""" + return self.tuya.state() + + @property + def precision(self): + """Return the precision of the system.""" + return PRECISION_WHOLE + + @property + def temperature_unit(self): + """Return the unit of measurement used by the platform.""" + unit = self.tuya.temperature_unit() + if unit == 'CELSIUS': + return TEMP_CELSIUS + if unit == 'FAHRENHEIT': + return TEMP_FAHRENHEIT + return TEMP_CELSIUS + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + mode = self.tuya.current_operation() + if mode is None: + return None + return TUYA_STATE_TO_HA.get(mode) + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return self.operations + + @property + def current_temperature(self): + """Return the current temperature.""" + return self.tuya.current_temperature() + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self.tuya.target_temperature() + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return self.tuya.target_temperature_step() + + @property + def current_fan_mode(self): + """Return the fan setting.""" + return self.tuya.current_fan_mode() + + @property + def fan_list(self): + """Return the list of available fan modes.""" + return self.tuya.fan_list() + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + if ATTR_TEMPERATURE in kwargs: + self.tuya.set_temperature(kwargs[ATTR_TEMPERATURE]) + + def set_fan_mode(self, fan_mode): + """Set new target fan mode.""" + self.tuya.set_fan_mode(fan_mode) + + def set_operation_mode(self, operation_mode): + """Set new target operation mode.""" + self.tuya.set_operation_mode(HA_STATE_TO_TUYA.get(operation_mode)) + + def turn_on(self): + """Turn device on.""" + self.tuya.turn_on() + + def turn_off(self): + """Turn device off.""" + self.tuya.turn_off() + + @property + def supported_features(self): + """Return the list of supported features.""" + supports = SUPPORT_ON_OFF + if self.tuya.support_target_temperature(): + supports = supports | SUPPORT_TARGET_TEMPERATURE + if self.tuya.support_mode(): + supports = supports | SUPPORT_OPERATION_MODE + if self.tuya.support_wind_speed(): + supports = supports | SUPPORT_FAN_MODE + return supports + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self.tuya.min_temp() + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self.tuya.max_temp() diff --git a/homeassistant/components/climate/venstar.py b/homeassistant/components/climate/venstar.py index 6e63cc4092b833..4bacf64cf9e3bd 100644 --- a/homeassistant/components/climate/venstar.py +++ b/homeassistant/components/climate/venstar.py @@ -11,9 +11,11 @@ from homeassistant.components.climate import ( ATTR_OPERATION_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, PLATFORM_SCHEMA, STATE_AUTO, STATE_COOL, STATE_HEAT, SUPPORT_FAN_MODE, - SUPPORT_OPERATION_MODE, SUPPORT_TARGET_HUMIDITY, - SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_HIGH, - SUPPORT_TARGET_TEMPERATURE_LOW, ClimateDevice) + SUPPORT_OPERATION_MODE, SUPPORT_TARGET_HUMIDITY, SUPPORT_AWAY_MODE, + SUPPORT_TARGET_HUMIDITY_HIGH, SUPPORT_TARGET_HUMIDITY_LOW, + SUPPORT_HOLD_MODE, SUPPORT_TARGET_TEMPERATURE, + SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW, + ClimateDevice) from homeassistant.const import ( ATTR_TEMPERATURE, CONF_HOST, CONF_PASSWORD, CONF_SSL, CONF_TIMEOUT, CONF_USERNAME, PRECISION_WHOLE, STATE_OFF, STATE_ON, TEMP_CELSIUS, @@ -27,14 +29,20 @@ ATTR_FAN_STATE = 'fan_state' ATTR_HVAC_STATE = 'hvac_state' +CONF_HUMIDIFIER = 'humidifier' + DEFAULT_SSL = False VALID_FAN_STATES = [STATE_ON, STATE_AUTO] VALID_THERMOSTAT_MODES = [STATE_HEAT, STATE_COOL, STATE_OFF, STATE_AUTO] +HOLD_MODE_OFF = 'off' +HOLD_MODE_TEMPERATURE = 'temperature' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PASSWORD): cv.string, + vol.Optional(CONF_HUMIDIFIER, default=True): cv.boolean, vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, vol.Optional(CONF_TIMEOUT, default=5): vol.All(vol.Coerce(int), vol.Range(min=1)), @@ -50,6 +58,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): password = config.get(CONF_PASSWORD) host = config.get(CONF_HOST) timeout = config.get(CONF_TIMEOUT) + humidifier = config.get(CONF_HUMIDIFIER) if config.get(CONF_SSL): proto = 'https' @@ -60,15 +69,16 @@ def setup_platform(hass, config, add_devices, discovery_info=None): addr=host, timeout=timeout, user=username, password=password, proto=proto) - add_devices([VenstarThermostat(client)], True) + add_devices([VenstarThermostat(client, humidifier)], True) class VenstarThermostat(ClimateDevice): """Representation of a Venstar thermostat.""" - def __init__(self, client): + def __init__(self, client, humidifier): """Initialize the thermostat.""" self._client = client + self._humidifier = humidifier def update(self): """Update the data from the thermostat.""" @@ -81,14 +91,18 @@ def update(self): def supported_features(self): """Return the list of supported features.""" features = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE | - SUPPORT_OPERATION_MODE) + SUPPORT_OPERATION_MODE | SUPPORT_AWAY_MODE | + SUPPORT_HOLD_MODE) if self._client.mode == self._client.MODE_AUTO: features |= (SUPPORT_TARGET_TEMPERATURE_HIGH | SUPPORT_TARGET_TEMPERATURE_LOW) - if self._client.hum_active == 1: - features |= SUPPORT_TARGET_HUMIDITY + if (self._humidifier and + hasattr(self._client, 'hum_active')): + features |= (SUPPORT_TARGET_HUMIDITY | + SUPPORT_TARGET_HUMIDITY_HIGH | + SUPPORT_TARGET_HUMIDITY_LOW) return features @@ -138,9 +152,9 @@ def current_operation(self): """Return current operation ie. heat, cool, idle.""" if self._client.mode == self._client.MODE_HEAT: return STATE_HEAT - elif self._client.mode == self._client.MODE_COOL: + if self._client.mode == self._client.MODE_COOL: return STATE_COOL - elif self._client.mode == self._client.MODE_AUTO: + if self._client.mode == self._client.MODE_AUTO: return STATE_AUTO return STATE_OFF @@ -164,7 +178,7 @@ def target_temperature(self): """Return the target temperature we try to reach.""" if self._client.mode == self._client.MODE_HEAT: return self._client.heattemp - elif self._client.mode == self._client.MODE_COOL: + if self._client.mode == self._client.MODE_COOL: return self._client.cooltemp return None @@ -197,6 +211,18 @@ def max_humidity(self): """Return the maximum humidity. Hardcoded to 60 in API.""" return 60 + @property + def is_away_mode_on(self): + """Return the status of away mode.""" + return self._client.away == self._client.AWAY_AWAY + + @property + def current_hold_mode(self): + """Return the status of hold mode.""" + if self._client.schedule == 0: + return HOLD_MODE_TEMPERATURE + return HOLD_MODE_OFF + def _set_operation_mode(self, operation_mode): """Change the operation mode (internal).""" if operation_mode == STATE_HEAT: @@ -259,3 +285,30 @@ def set_humidity(self, humidity): if not success: _LOGGER.error("Failed to change the target humidity level") + + def set_hold_mode(self, hold_mode): + """Set the hold mode.""" + if hold_mode == HOLD_MODE_TEMPERATURE: + success = self._client.set_schedule(0) + elif hold_mode == HOLD_MODE_OFF: + success = self._client.set_schedule(1) + else: + _LOGGER.error("Unknown hold mode: %s", hold_mode) + success = False + + if not success: + _LOGGER.error("Failed to change the schedule/hold state") + + def turn_away_mode_on(self): + """Activate away mode.""" + success = self._client.set_away(self._client.AWAY_AWAY) + + if not success: + _LOGGER.error("Failed to activate away mode") + + def turn_away_mode_off(self): + """Deactivate away mode.""" + success = self._client.set_away(self._client.AWAY_HOME) + + if not success: + _LOGGER.error("Failed to deactivate away mode") diff --git a/homeassistant/components/climate/vera.py b/homeassistant/components/climate/vera.py index 6fb6bc0ff48410..0f89b15e5a1877 100644 --- a/homeassistant/components/climate/vera.py +++ b/homeassistant/components/climate/vera.py @@ -32,8 +32,8 @@ def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up of Vera thermostats.""" add_devices_callback( - VeraThermostat(device, hass.data[VERA_CONTROLLER]) for - device in hass.data[VERA_DEVICES]['climate']) + [VeraThermostat(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['climate']], True) class VeraThermostat(VeraDevice, ClimateDevice): @@ -55,11 +55,11 @@ def current_operation(self): mode = self.vera_device.get_hvac_mode() if mode == 'HeatOn': return OPERATION_LIST[0] # heat - elif mode == 'CoolOn': + if mode == 'CoolOn': return OPERATION_LIST[1] # cool - elif mode == 'AutoChangeOver': + if mode == 'AutoChangeOver': return OPERATION_LIST[2] # auto - elif mode == 'Off': + if mode == 'Off': return OPERATION_LIST[3] # off return 'Off' @@ -74,9 +74,9 @@ def current_fan_mode(self): mode = self.vera_device.get_fan_mode() if mode == "ContinuousOn": return FAN_OPERATION_LIST[0] # on - elif mode == "Auto": + if mode == "Auto": return FAN_OPERATION_LIST[1] # auto - elif mode == "PeriodicOn": + if mode == "PeriodicOn": return FAN_OPERATION_LIST[2] # cycle return "Auto" @@ -101,10 +101,6 @@ def current_power_w(self): if power: return convert(power, float, 0.0) - def update(self): - """Handle state updates.""" - self._state = self.vera_device.get_hvac_mode() - @property def temperature_unit(self): """Return the unit of measurement.""" diff --git a/homeassistant/components/climate/wink.py b/homeassistant/components/climate/wink.py index 8c66567a4aadef..15e555db8b9f2c 100644 --- a/homeassistant/components/climate/wink.py +++ b/homeassistant/components/climate/wink.py @@ -84,7 +84,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices([WinkWaterHeater(water_heater, hass)]) -# pylint: disable=abstract-method class WinkThermostat(WinkDevice, ClimateDevice): """Representation of a Wink thermostat.""" @@ -190,7 +189,7 @@ def heat_on(self): @property def cool_on(self): """Return whether or not the heat is actually heating.""" - return self.wink.heat_on() + return self.wink.cool_on() @property def current_operation(self): @@ -225,7 +224,7 @@ def target_temperature(self): if self.current_operation != STATE_AUTO and not self.is_away_mode_on: if self.current_operation == STATE_COOL: return self.wink.current_max_set_point() - elif self.current_operation == STATE_HEAT: + if self.current_operation == STATE_HEAT: return self.wink.current_min_set_point() return None @@ -312,7 +311,7 @@ def current_fan_mode(self): """Return whether the fan is on.""" if self.wink.current_fan_mode() == 'on': return STATE_ON - elif self.wink.current_fan_mode() == 'auto': + if self.wink.current_fan_mode() == 'auto': return STATE_AUTO # No Fan available so disable slider return None @@ -484,7 +483,7 @@ def current_fan_mode(self): speed = self.wink.current_fan_speed() if speed <= 0.33: return SPEED_LOW - elif speed <= 0.66: + if speed <= 0.66: return SPEED_MEDIUM return SPEED_HIGH diff --git a/homeassistant/components/climate/zhong_hong.py b/homeassistant/components/climate/zhong_hong.py new file mode 100644 index 00000000000000..7ff19871ee7bd5 --- /dev/null +++ b/homeassistant/components/climate/zhong_hong.py @@ -0,0 +1,217 @@ +""" +Support for ZhongHong HVAC Controller. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/climate.zhong_hong/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.climate import ( + ATTR_OPERATION_MODE, PLATFORM_SCHEMA, STATE_COOL, STATE_DRY, + STATE_FAN_ONLY, STATE_HEAT, SUPPORT_FAN_MODE, SUPPORT_ON_OFF, + SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, ClimateDevice) +from homeassistant.const import (ATTR_TEMPERATURE, CONF_HOST, CONF_PORT, + EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import (async_dispatcher_connect, + async_dispatcher_send) + +_LOGGER = logging.getLogger(__name__) + +CONF_GATEWAY_ADDRRESS = 'gateway_address' + +REQUIREMENTS = ['zhong_hong_hvac==1.0.9'] +SIGNAL_DEVICE_ADDED = 'zhong_hong_device_added' +SIGNAL_ZHONG_HONG_HUB_START = 'zhong_hong_hub_start' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): + cv.string, + vol.Optional(CONF_PORT, default=9999): + vol.Coerce(int), + vol.Optional(CONF_GATEWAY_ADDRRESS, default=1): + vol.Coerce(int), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the ZhongHong HVAC platform.""" + from zhong_hong_hvac.hub import ZhongHongGateway + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + gw_addr = config.get(CONF_GATEWAY_ADDRRESS) + hub = ZhongHongGateway(host, port, gw_addr) + devices = [ + ZhongHongClimate(hub, addr_out, addr_in) + for (addr_out, addr_in) in hub.discovery_ac() + ] + + _LOGGER.debug("We got %s zhong_hong climate devices", len(devices)) + + hub_is_initialized = False + + async def startup(): + """Start hub socket after all climate entity is setted up.""" + nonlocal hub_is_initialized + if not all([device.is_initialized for device in devices]): + return + + if hub_is_initialized: + return + + _LOGGER.debug("zhong_hong hub start listen event") + await hass.async_add_job(hub.start_listen) + await hass.async_add_job(hub.query_all_status) + hub_is_initialized = True + + async_dispatcher_connect(hass, SIGNAL_DEVICE_ADDED, startup) + + # add devices after SIGNAL_DEVICE_SETTED_UP event is listend + add_devices(devices) + + def stop_listen(event): + """Stop ZhongHongHub socket.""" + hub.stop_listen() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_listen) + + +class ZhongHongClimate(ClimateDevice): + """Representation of a ZhongHong controller support HVAC.""" + + def __init__(self, hub, addr_out, addr_in): + """Set up the ZhongHong climate devices.""" + from zhong_hong_hvac.hvac import HVAC + self._device = HVAC(hub, addr_out, addr_in) + self._hub = hub + self._current_operation = None + self._current_temperature = None + self._target_temperature = None + self._current_fan_mode = None + self._is_on = None + self.is_initialized = False + + async def async_added_to_hass(self): + """Register callbacks.""" + self._device.register_update_callback(self._after_update) + self.is_initialized = True + async_dispatcher_send(self.hass, SIGNAL_DEVICE_ADDED) + + def _after_update(self, climate): + """Callback to update state.""" + _LOGGER.debug("async update ha state") + if self._device.current_operation: + self._current_operation = self._device.current_operation.lower() + if self._device.current_temperature: + self._current_temperature = self._device.current_temperature + if self._device.current_fan_mode: + self._current_fan_mode = self._device.current_fan_mode + if self._device.target_temperature: + self._target_temperature = self._device.target_temperature + self._is_on = self._device.is_on + self.schedule_update_ha_state() + + @property + def should_poll(self): + """Return the polling state.""" + return False + + @property + def name(self): + """Return the name of the thermostat, if any.""" + return self.unique_id + + @property + def unique_id(self): + """Return the unique ID of the HVAC.""" + return "zhong_hong_hvac_{}_{}".format(self._device.addr_out, + self._device.addr_in) + + @property + def supported_features(self): + """Return the list of supported features.""" + return (SUPPORT_TARGET_TEMPERATURE | SUPPORT_FAN_MODE + | SUPPORT_OPERATION_MODE | SUPPORT_ON_OFF) + + @property + def temperature_unit(self): + """Return the unit of measurement used by the platform.""" + return TEMP_CELSIUS + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + return self._current_operation + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return [STATE_COOL, STATE_HEAT, STATE_DRY, STATE_FAN_ONLY] + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._current_temperature + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._target_temperature + + @property + def target_temperature_step(self): + """Return the supported step of target temperature.""" + return 1 + + @property + def is_on(self): + """Return true if on.""" + return self._device.is_on + + @property + def current_fan_mode(self): + """Return the fan setting.""" + return self._current_fan_mode + + @property + def fan_list(self): + """Return the list of available fan modes.""" + return self._device.fan_list + + @property + def min_temp(self): + """Return the minimum temperature.""" + return self._device.min_temp + + @property + def max_temp(self): + """Return the maximum temperature.""" + return self._device.max_temp + + def turn_on(self): + """Turn on ac.""" + return self._device.turn_on() + + def turn_off(self): + """Turn off ac.""" + return self._device.turn_off() + + def set_temperature(self, **kwargs): + """Set new target temperature.""" + temperature = kwargs.get(ATTR_TEMPERATURE) + if temperature is not None: + self._device.set_temperature(temperature) + + operation_mode = kwargs.get(ATTR_OPERATION_MODE) + if operation_mode is not None: + self.set_operation_mode(operation_mode) + + def set_operation_mode(self, operation_mode): + """Set new target operation mode.""" + self._device.set_operation_mode(operation_mode.upper()) + + def set_fan_mode(self, fan_mode): + """Set new target fan mode.""" + self._device.set_fan_mode(fan_mode) diff --git a/homeassistant/components/climate/zwave.py b/homeassistant/components/climate/zwave.py index 1eec9c82f3ca9a..f87f2e83f5da96 100644 --- a/homeassistant/components/climate/zwave.py +++ b/homeassistant/components/climate/zwave.py @@ -5,15 +5,15 @@ https://home-assistant.io/components/climate.zwave/ """ # Because we do not compile openzwave on CI -# pylint: disable=import-error import logging from homeassistant.components.climate import ( - DOMAIN, ClimateDevice, SUPPORT_TARGET_TEMPERATURE, SUPPORT_FAN_MODE, + DOMAIN, ClimateDevice, STATE_AUTO, STATE_COOL, STATE_HEAT, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_FAN_MODE, SUPPORT_OPERATION_MODE, SUPPORT_SWING_MODE) from homeassistant.components.zwave import ZWaveDeviceEntity -from homeassistant.components.zwave import async_setup_platform # noqa # pylint: disable=unused-import +from homeassistant.components.zwave import async_setup_platform # noqa pylint: disable=unused-import from homeassistant.const import ( - TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) + STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) _LOGGER = logging.getLogger(__name__) @@ -32,6 +32,15 @@ REMOTEC_ZXT_120_THERMOSTAT: WORKAROUND_ZXT_120 } +STATE_MAPPINGS = { + 'Off': STATE_OFF, + 'Heat': STATE_HEAT, + 'Heat Mode': STATE_HEAT, + 'Heat (Default)': STATE_HEAT, + 'Cool': STATE_COOL, + 'Auto': STATE_AUTO, +} + def get_device(hass, values, **kwargs): """Create Z-Wave entity device.""" @@ -49,6 +58,7 @@ def __init__(self, values, temp_unit): self._current_temperature = None self._current_operation = None self._operation_list = None + self._operation_mapping = None self._operating_state = None self._current_fan_mode = None self._fan_list = None @@ -87,10 +97,21 @@ def update_properties(self): """Handle the data changes for node values.""" # Operation Mode if self.values.mode: - self._current_operation = self.values.mode.data + self._operation_list = [] + self._operation_mapping = {} operation_list = self.values.mode.data_items if operation_list: - self._operation_list = list(operation_list) + for mode in operation_list: + ha_mode = STATE_MAPPINGS.get(mode) + if ha_mode and ha_mode not in self._operation_mapping: + self._operation_mapping[ha_mode] = mode + self._operation_list.append(ha_mode) + continue + self._operation_list.append(mode) + current_mode = self.values.mode.data + self._current_operation = next( + (key for key, value in self._operation_mapping.items() + if value == current_mode), current_mode) _LOGGER.debug("self._operation_list=%s", self._operation_list) _LOGGER.debug("self._current_operation=%s", self._current_operation) @@ -165,7 +186,7 @@ def temperature_unit(self): """Return the unit of measurement.""" if self._unit == 'C': return TEMP_CELSIUS - elif self._unit == 'F': + if self._unit == 'F': return TEMP_FAHRENHEIT return self._unit @@ -206,7 +227,8 @@ def set_fan_mode(self, fan_mode): def set_operation_mode(self, operation_mode): """Set new target operation mode.""" if self.values.mode: - self.values.mode.data = operation_mode + self.values.mode.data = self._operation_mapping.get( + operation_mode, operation_mode) def set_swing_mode(self, swing_mode): """Set new target swing mode.""" diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index 7cf8e50e8668ac..f4ce7bb3d1af0a 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -185,7 +185,7 @@ def _handle_connection(self): yield from client.send_json(response) except client_exceptions.WSServerHandshakeError as err: - if err.code == 401: + if err.status == 401: disconnect_warn = 'Invalid auth.' self.close_requested = True # Should we notify user? @@ -253,5 +253,3 @@ def async_handle_cloud(hass, cloud, payload): payload['reason']) else: _LOGGER.warning("Received unknown cloud action: %s", action) - - return None diff --git a/homeassistant/components/cloudflare.py b/homeassistant/components/cloudflare.py new file mode 100644 index 00000000000000..ae400ca638569d --- /dev/null +++ b/homeassistant/components/cloudflare.py @@ -0,0 +1,77 @@ +""" +Update the IP addresses of your Cloudflare DNS records. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/cloudflare/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.const import CONF_API_KEY, CONF_EMAIL, CONF_ZONE +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import track_time_interval + +REQUIREMENTS = ['pycfdns==0.0.1'] + +_LOGGER = logging.getLogger(__name__) + +CONF_RECORDS = 'records' + +DOMAIN = 'cloudflare' + +INTERVAL = timedelta(minutes=60) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_EMAIL): cv.string, + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_ZONE): cv.string, + vol.Required(CONF_RECORDS): vol.All(cv.ensure_list, [cv.string]), + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Cloudflare component.""" + from pycfdns import CloudflareUpdater + + cfupdate = CloudflareUpdater() + email = config[DOMAIN][CONF_EMAIL] + key = config[DOMAIN][CONF_API_KEY] + zone = config[DOMAIN][CONF_ZONE] + records = config[DOMAIN][CONF_RECORDS] + + def update_records_interval(now): + """Set up recurring update.""" + _update_cloudflare(cfupdate, email, key, zone, records) + + def update_records_service(now): + """Set up service for manual trigger.""" + _update_cloudflare(cfupdate, email, key, zone, records) + + track_time_interval(hass, update_records_interval, INTERVAL) + hass.services.register( + DOMAIN, 'update_records', update_records_service) + return True + + +def _update_cloudflare(cfupdate, email, key, zone, records): + """Update DNS records for a given zone.""" + _LOGGER.debug("Starting update for zone %s", zone) + + headers = cfupdate.set_header(email, key) + _LOGGER.debug("Header data defined as: %s", headers) + + zoneid = cfupdate.get_zoneID(headers, zone) + _LOGGER.debug("Zone ID is set to: %s", zoneid) + + update_records = cfupdate.get_recordInfo(headers, zoneid, zone, records) + _LOGGER.debug("Records: %s", update_records) + + result = cfupdate.update_records(headers, zoneid, update_records) + _LOGGER.debug("Update for zone %s is complete", zone) + + if result is not True: + _LOGGER.warning(result) diff --git a/homeassistant/components/coinbase.py b/homeassistant/components/coinbase.py index c40bd99b542ad6..154320b4abd3a4 100644 --- a/homeassistant/components/coinbase.py +++ b/homeassistant/components/coinbase.py @@ -69,7 +69,7 @@ def setup(hass, config): return True -class CoinbaseData(object): +class CoinbaseData: """Get the latest data and update the states.""" def __init__(self, api_key, api_secret): diff --git a/homeassistant/components/comfoconnect.py b/homeassistant/components/comfoconnect.py index 425ed6f9c9a5fb..69d88274f2965b 100644 --- a/homeassistant/components/comfoconnect.py +++ b/homeassistant/components/comfoconnect.py @@ -88,7 +88,7 @@ def _shutdown(_event): return True -class ComfoConnectBridge(object): +class ComfoConnectBridge: """Representation of a ComfoConnect bridge.""" def __init__(self, hass, bridge, name, token, friendly_name, pin): diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 5a8800d9583f2a..581d8fc3f7b9f1 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -21,7 +21,7 @@ async def async_setup(hass, config): """Set up the config component.""" await hass.components.frontend.async_register_built_in_panel( - 'config', 'config', 'mdi:settings') + 'config', 'config', 'hass:settings') async def setup_panel(panel_name): """Set up a panel.""" @@ -49,6 +49,10 @@ def component_loaded(event): tasks = [setup_panel(panel_name) for panel_name in SECTIONS] + if hass.auth.active: + tasks.append(setup_panel('auth')) + tasks.append(setup_panel('auth_provider_homeassistant')) + for panel_name in ON_DEMAND: if panel_name in hass.config.components: tasks.append(setup_panel(panel_name)) diff --git a/homeassistant/components/config/auth.py b/homeassistant/components/config/auth.py new file mode 100644 index 00000000000000..6f00b03dedb948 --- /dev/null +++ b/homeassistant/components/config/auth.py @@ -0,0 +1,113 @@ +"""Offer API to configure Home Assistant auth.""" +import voluptuous as vol + +from homeassistant.core import callback +from homeassistant.components import websocket_api + + +WS_TYPE_LIST = 'config/auth/list' +SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_LIST, +}) + +WS_TYPE_DELETE = 'config/auth/delete' +SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_DELETE, + vol.Required('user_id'): str, +}) + +WS_TYPE_CREATE = 'config/auth/create' +SCHEMA_WS_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_CREATE, + vol.Required('name'): str, +}) + + +async def async_setup(hass): + """Enable the Home Assistant views.""" + hass.components.websocket_api.async_register_command( + WS_TYPE_LIST, websocket_list, + SCHEMA_WS_LIST + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_DELETE, websocket_delete, + SCHEMA_WS_DELETE + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_CREATE, websocket_create, + SCHEMA_WS_CREATE + ) + return True + + +@callback +@websocket_api.require_owner +def websocket_list(hass, connection, msg): + """Return a list of users.""" + async def send_users(): + """Send users.""" + result = [_user_info(u) for u in await hass.auth.async_get_users()] + + connection.send_message_outside( + websocket_api.result_message(msg['id'], result)) + + hass.async_add_job(send_users()) + + +@callback +@websocket_api.require_owner +def websocket_delete(hass, connection, msg): + """Delete a user.""" + async def delete_user(): + """Delete user.""" + if msg['user_id'] == connection.request.get('hass_user').id: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'no_delete_self', + 'Unable to delete your own account')) + return + + user = await hass.auth.async_get_user(msg['user_id']) + + if not user: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'not_found', 'User not found')) + return + + await hass.auth.async_remove_user(user) + + connection.send_message_outside( + websocket_api.result_message(msg['id'])) + + hass.async_add_job(delete_user()) + + +@callback +@websocket_api.require_owner +def websocket_create(hass, connection, msg): + """Create a user.""" + async def create_user(): + """Create a user.""" + user = await hass.auth.async_create_user(msg['name']) + + connection.send_message_outside( + websocket_api.result_message(msg['id'], { + 'user': _user_info(user) + })) + + hass.async_add_job(create_user()) + + +def _user_info(user): + """Format a user.""" + return { + 'id': user.id, + 'name': user.name, + 'is_owner': user.is_owner, + 'is_active': user.is_active, + 'system_generated': user.system_generated, + 'credentials': [ + { + 'type': c.auth_provider_type, + } for c in user.credentials + ] + } diff --git a/homeassistant/components/config/auth_provider_homeassistant.py b/homeassistant/components/config/auth_provider_homeassistant.py new file mode 100644 index 00000000000000..960e8f5e7b4e42 --- /dev/null +++ b/homeassistant/components/config/auth_provider_homeassistant.py @@ -0,0 +1,174 @@ +"""Offer API to configure the Home Assistant auth provider.""" +import voluptuous as vol + +from homeassistant.auth.providers import homeassistant as auth_ha +from homeassistant.core import callback +from homeassistant.components import websocket_api + + +WS_TYPE_CREATE = 'config/auth_provider/homeassistant/create' +SCHEMA_WS_CREATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_CREATE, + vol.Required('user_id'): str, + vol.Required('username'): str, + vol.Required('password'): str, +}) + +WS_TYPE_DELETE = 'config/auth_provider/homeassistant/delete' +SCHEMA_WS_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_DELETE, + vol.Required('username'): str, +}) + +WS_TYPE_CHANGE_PASSWORD = 'config/auth_provider/homeassistant/change_password' +SCHEMA_WS_CHANGE_PASSWORD = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_CHANGE_PASSWORD, + vol.Required('current_password'): str, + vol.Required('new_password'): str +}) + + +async def async_setup(hass): + """Enable the Home Assistant views.""" + hass.components.websocket_api.async_register_command( + WS_TYPE_CREATE, websocket_create, + SCHEMA_WS_CREATE + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_DELETE, websocket_delete, + SCHEMA_WS_DELETE + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_CHANGE_PASSWORD, websocket_change_password, + SCHEMA_WS_CHANGE_PASSWORD + ) + return True + + +def _get_provider(hass): + """Get homeassistant auth provider.""" + for prv in hass.auth.auth_providers: + if prv.type == 'homeassistant': + return prv + + raise RuntimeError('Provider not found') + + +@callback +@websocket_api.require_owner +def websocket_create(hass, connection, msg): + """Create credentials and attach to a user.""" + async def create_creds(): + """Create credentials.""" + provider = _get_provider(hass) + await provider.async_initialize() + + user = await hass.auth.async_get_user(msg['user_id']) + + if user is None: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'not_found', 'User not found')) + return + + if user.system_generated: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'system_generated', + 'Cannot add credentials to a system generated user.')) + return + + try: + await hass.async_add_executor_job( + provider.data.add_auth, msg['username'], msg['password']) + except auth_ha.InvalidUser: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'username_exists', 'Username already exists')) + return + + credentials = await provider.async_get_or_create_credentials({ + 'username': msg['username'] + }) + await hass.auth.async_link_user(user, credentials) + + await provider.data.async_save() + connection.to_write.put_nowait(websocket_api.result_message(msg['id'])) + + hass.async_add_job(create_creds()) + + +@callback +@websocket_api.require_owner +def websocket_delete(hass, connection, msg): + """Delete username and related credential.""" + async def delete_creds(): + """Delete user credentials.""" + provider = _get_provider(hass) + await provider.async_initialize() + + credentials = await provider.async_get_or_create_credentials({ + 'username': msg['username'] + }) + + # if not new, an existing credential exists. + # Removing the credential will also remove the auth. + if not credentials.is_new: + await hass.auth.async_remove_credentials(credentials) + + connection.to_write.put_nowait( + websocket_api.result_message(msg['id'])) + return + + try: + provider.data.async_remove_auth(msg['username']) + await provider.data.async_save() + except auth_ha.InvalidUser: + connection.to_write.put_nowait(websocket_api.error_message( + msg['id'], 'auth_not_found', 'Given username was not found.')) + return + + connection.to_write.put_nowait( + websocket_api.result_message(msg['id'])) + + hass.async_add_job(delete_creds()) + + +@callback +def websocket_change_password(hass, connection, msg): + """Change user password.""" + async def change_password(): + """Change user password.""" + user = connection.request.get('hass_user') + if user is None: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'user_not_found', 'User not found')) + return + + provider = _get_provider(hass) + await provider.async_initialize() + + username = None + for credential in user.credentials: + if credential.auth_provider_type == provider.type: + username = credential.data['username'] + break + + if username is None: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'credentials_not_found', 'Credentials not found')) + return + + try: + await provider.async_validate_login( + username, msg['current_password']) + except auth_ha.InvalidAuth: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'invalid_password', 'Invalid password')) + return + + await hass.async_add_executor_job( + provider.data.change_password, username, msg['new_password']) + await provider.data.async_save() + + connection.send_message_outside( + websocket_api.result_message(msg['id'])) + + hass.async_add_job(change_password()) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index d2aa918eda2692..04d2c713cdcffc 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -7,7 +7,7 @@ FlowManagerIndexView, FlowManagerResourceView) -REQUIREMENTS = ['voluptuous-serialize==1'] +REQUIREMENTS = ['voluptuous-serialize==2.0.0'] @asyncio.coroutine @@ -96,7 +96,7 @@ def get(self, request): return self.json([ flw for flw in hass.config_entries.flow.async_progress() - if flw['source'] != data_entry_flow.SOURCE_USER]) + if flw['context']['source'] != config_entries.SOURCE_USER]) class ConfigManagerFlowResourceView(FlowManagerResourceView): diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index 4b9a2c89da0bab..7c0867e3852c3c 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -2,48 +2,101 @@ import voluptuous as vol from homeassistant.core import callback -from homeassistant.components.http import HomeAssistantView -from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.helpers.entity_registry import async_get_registry +from homeassistant.components import websocket_api +from homeassistant.helpers import config_validation as cv + +DEPENDENCIES = ['websocket_api'] + +WS_TYPE_GET = 'config/entity_registry/get' +SCHEMA_WS_GET = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_GET, + vol.Required('entity_id'): cv.entity_id +}) + +WS_TYPE_UPDATE = 'config/entity_registry/update' +SCHEMA_WS_UPDATE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_UPDATE, + vol.Required('entity_id'): cv.entity_id, + # If passed in, we update value. Passing None will remove old value. + vol.Optional('name'): vol.Any(str, None), + vol.Optional('new_entity_id'): str, +}) async def async_setup(hass): """Enable the Entity Registry views.""" - hass.http.register_view(ConfigManagerEntityView) + hass.components.websocket_api.async_register_command( + WS_TYPE_GET, websocket_get_entity, + SCHEMA_WS_GET + ) + hass.components.websocket_api.async_register_command( + WS_TYPE_UPDATE, websocket_update_entity, + SCHEMA_WS_UPDATE + ) return True -class ConfigManagerEntityView(HomeAssistantView): - """View to interact with an entity registry entry.""" - - url = '/api/config/entity_registry/{entity_id}' - name = 'api:config:entity_registry:entity' +@callback +def websocket_get_entity(hass, connection, msg): + """Handle get entity registry entry command. - async def get(self, request, entity_id): - """Get the entity registry settings for an entity.""" - hass = request.app['hass'] + Async friendly. + """ + async def retrieve_entity(): + """Get entity from registry.""" registry = await async_get_registry(hass) - entry = registry.entities.get(entity_id) + entry = registry.entities.get(msg['entity_id']) if entry is None: - return self.json_message('Entry not found', 404) + connection.send_message_outside(websocket_api.error_message( + msg['id'], websocket_api.ERR_NOT_FOUND, 'Entity not found')) + return + + connection.send_message_outside(websocket_api.result_message( + msg['id'], _entry_dict(entry) + )) + + hass.async_add_job(retrieve_entity()) - return self.json(_entry_dict(entry)) - @RequestDataValidator(vol.Schema({ - # If passed in, we update value. Passing None will remove old value. - vol.Optional('name'): vol.Any(str, None), - })) - async def post(self, request, entity_id, data): - """Update the entity registry settings for an entity.""" - hass = request.app['hass'] +@callback +def websocket_update_entity(hass, connection, msg): + """Handle get camera thumbnail websocket command. + + Async friendly. + """ + async def update_entity(): + """Get entity from registry.""" registry = await async_get_registry(hass) - if entity_id not in registry.entities: - return self.json_message('Entry not found', 404) + if msg['entity_id'] not in registry.entities: + connection.send_message_outside(websocket_api.error_message( + msg['id'], websocket_api.ERR_NOT_FOUND, 'Entity not found')) + return + + changes = {} + + if 'name' in msg: + changes['name'] = msg['name'] + + if 'new_entity_id' in msg: + changes['new_entity_id'] = msg['new_entity_id'] + + try: + if changes: + entry = registry.async_update_entity( + msg['entity_id'], **changes) + except ValueError as err: + connection.send_message_outside(websocket_api.error_message( + msg['id'], 'invalid_info', str(err) + )) + else: + connection.send_message_outside(websocket_api.result_message( + msg['id'], _entry_dict(entry) + )) - entry = registry.async_update_entity(entity_id, **data) - return self.json(_entry_dict(entry)) + hass.async_create_task(update_entity()) @callback diff --git a/homeassistant/components/config/zwave.py b/homeassistant/components/config/zwave.py index c839ab7bc6ec98..84927712741cba 100644 --- a/homeassistant/components/config/zwave.py +++ b/homeassistant/components/config/zwave.py @@ -29,6 +29,7 @@ def async_setup(hass): hass.http.register_view(ZWaveUserCodeView) hass.http.register_view(ZWaveLogView) hass.http.register_view(ZWaveConfigWriteView) + hass.http.register_view(ZWaveProtectionView) return True @@ -196,3 +197,59 @@ def get(self, request, node_id): 'label': value.label, 'length': len(value.data)} return self.json(usercodes) + + +class ZWaveProtectionView(HomeAssistantView): + """View for the protection commandclass of a node.""" + + url = r"/api/zwave/protection/{node_id:\d+}" + name = "api:zwave:protection" + + async def get(self, request, node_id): + """Retrieve the protection commandclass options of node.""" + nodeid = int(node_id) + hass = request.app['hass'] + network = hass.data.get(const.DATA_NETWORK) + + def _fetch_protection(): + """Helper to get protection data.""" + node = network.nodes.get(nodeid) + if node is None: + return self.json_message('Node not found', HTTP_NOT_FOUND) + protection_options = {} + if not node.has_command_class(const.COMMAND_CLASS_PROTECTION): + return self.json(protection_options) + protections = node.get_protections() + protection_options = { + 'value_id': '{0:d}'.format(list(protections)[0]), + 'selected': node.get_protection_item(list(protections)[0]), + 'options': node.get_protection_items(list(protections)[0])} + return self.json(protection_options) + + return await hass.async_add_executor_job(_fetch_protection) + + async def post(self, request, node_id): + """Change the selected option in protection commandclass.""" + nodeid = int(node_id) + hass = request.app['hass'] + network = hass.data.get(const.DATA_NETWORK) + protection_data = await request.json() + + def _set_protection(): + """Helper to get protection data.""" + node = network.nodes.get(nodeid) + selection = protection_data["selection"] + value_id = int(protection_data[const.ATTR_VALUE_ID]) + if node is None: + return self.json_message('Node not found', HTTP_NOT_FOUND) + if not node.has_command_class(const.COMMAND_CLASS_PROTECTION): + return self.json_message( + 'No protection commandclass on this node', HTTP_NOT_FOUND) + state = node.set_protection(value_id, selection) + if not state: + return self.json_message( + 'Protection setting did not complete', 202) + return self.json_message( + 'Protection setting succsessfully set', HTTP_OK) + + return await hass.async_add_executor_job(_set_protection) diff --git a/homeassistant/components/configurator.py b/homeassistant/components/configurator.py index 2c159633a9b54c..56fb7b4247b092 100644 --- a/homeassistant/components/configurator.py +++ b/homeassistant/components/configurator.py @@ -128,7 +128,7 @@ def async_setup(hass, config): return True -class Configurator(object): +class Configurator: """The class to keep track of current configuration requests.""" def __init__(self, hass): diff --git a/homeassistant/components/conversation.py b/homeassistant/components/conversation/__init__.py similarity index 99% rename from homeassistant/components/conversation.py rename to homeassistant/components/conversation/__init__.py index ddd96c99177d7e..9cb00a84583ae5 100644 --- a/homeassistant/components/conversation.py +++ b/homeassistant/components/conversation/__init__.py @@ -96,6 +96,7 @@ async def async_setup(hass, config): async def process(service): """Parse text into commands.""" text = service.data[ATTR_TEXT] + _LOGGER.debug('Processing: <%s>', text) try: await _process(hass, text) except intent.IntentHandleError as err: diff --git a/homeassistant/components/conversation/services.yaml b/homeassistant/components/conversation/services.yaml new file mode 100644 index 00000000000000..a1b980d8e05a3d --- /dev/null +++ b/homeassistant/components/conversation/services.yaml @@ -0,0 +1,10 @@ +# Describes the format for available component services + +process: + description: Launch a conversation from a transcribed text. + fields: + text: + description: Transcribed text + example: Turn all lights on + + diff --git a/homeassistant/components/counter/__init__.py b/homeassistant/components/counter/__init__.py index 2df17a4e50a9c4..03e5b2734682e1 100644 --- a/homeassistant/components/counter/__init__.py +++ b/homeassistant/components/counter/__init__.py @@ -9,9 +9,9 @@ import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.const import (ATTR_ENTITY_ID, CONF_ICON, CONF_NAME) +from homeassistant.const import ATTR_ENTITY_ID, CONF_ICON, CONF_NAME from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.restore_state import async_get_last_state @@ -94,9 +94,8 @@ def async_reset(hass, entity_id): DOMAIN, SERVICE_RESET, {ATTR_ENTITY_ID: entity_id})) -@asyncio.coroutine -def async_setup(hass, config): - """Set up a counter.""" +async def async_setup(hass, config): + """Set up the counters.""" component = EntityComponent(_LOGGER, DOMAIN, hass) entities = [] @@ -115,8 +114,7 @@ def async_setup(hass, config): if not entities: return False - @asyncio.coroutine - def async_handler_service(service): + async def async_handler_service(service): """Handle a call to the counter services.""" target_counters = component.async_extract_from_service(service) @@ -129,7 +127,7 @@ def async_handler_service(service): tasks = [getattr(counter, attr)() for counter in target_counters] if tasks: - yield from asyncio.wait(tasks, loop=hass.loop) + await asyncio.wait(tasks, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_INCREMENT, async_handler_service) @@ -138,7 +136,7 @@ def async_handler_service(service): hass.services.async_register( DOMAIN, SERVICE_RESET, async_handler_service) - yield from component.async_add_entities(entities) + await component.async_add_entities(entities) return True @@ -181,30 +179,26 @@ def state_attributes(self): ATTR_STEP: self._step, } - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Call when entity about to be added to Home Assistant.""" # If not None, we got an initial value. if self._state is not None: return - state = yield from async_get_last_state(self.hass, self.entity_id) + state = await async_get_last_state(self.hass, self.entity_id) self._state = state and state.state == state - @asyncio.coroutine - def async_decrement(self): + async def async_decrement(self): """Decrement the counter.""" self._state -= self._step - yield from self.async_update_ha_state() + await self.async_update_ha_state() - @asyncio.coroutine - def async_increment(self): + async def async_increment(self): """Increment a counter.""" self._state += self._step - yield from self.async_update_ha_state() + await self.async_update_ha_state() - @asyncio.coroutine - def async_reset(self): + async def async_reset(self): """Reset a counter.""" self._state = self._initial - yield from self.async_update_ha_state() + await self.async_update_ha_state() diff --git a/homeassistant/components/cover/__init__.py b/homeassistant/components/cover/__init__.py index e4c8f5634cf4a9..f5d3d798e2eb6b 100644 --- a/homeassistant/components/cover/__init__.py +++ b/homeassistant/components/cover/__init__.py @@ -198,7 +198,6 @@ async def async_handle_cover_service(service): class CoverDevice(Entity): """Representation a cover.""" - # pylint: disable=no-self-use @property def current_cover_position(self): """Return current position of cover. diff --git a/homeassistant/components/cover/aladdin_connect.py b/homeassistant/components/cover/aladdin_connect.py new file mode 100644 index 00000000000000..efaea39bb864e1 --- /dev/null +++ b/homeassistant/components/cover/aladdin_connect.py @@ -0,0 +1,115 @@ +""" +Platform for the Aladdin Connect cover component. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/cover.aladdin_connect/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.cover import (CoverDevice, PLATFORM_SCHEMA, + SUPPORT_OPEN, SUPPORT_CLOSE) +from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, STATE_CLOSED, + STATE_OPENING, STATE_CLOSING, STATE_OPEN) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['aladdin_connect==0.1'] + +_LOGGER = logging.getLogger(__name__) + +NOTIFICATION_ID = 'aladdin_notification' +NOTIFICATION_TITLE = 'Aladdin Connect Cover Setup' + +STATES_MAP = { + 'open': STATE_OPEN, + 'opening': STATE_OPENING, + 'closed': STATE_CLOSED, + 'closing': STATE_CLOSING +} + +SUPPORTED_FEATURES = SUPPORT_OPEN | SUPPORT_CLOSE + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Aladdin Connect platform.""" + from aladdin_connect import AladdinConnectClient + + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + acc = AladdinConnectClient(username, password) + + try: + if not acc.login(): + raise ValueError("Username or Password is incorrect") + add_devices(AladdinDevice(acc, door) for door in acc.get_doors()) + except (TypeError, KeyError, NameError, ValueError) as ex: + _LOGGER.error("%s", ex) + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + + +class AladdinDevice(CoverDevice): + """Representation of Aladdin Connect cover.""" + + def __init__(self, acc, device): + """Initialize the cover.""" + self._acc = acc + self._device_id = device['device_id'] + self._number = device['door_number'] + self._name = device['name'] + self._status = STATES_MAP.get(device['status']) + + @property + def device_class(self): + """Define this cover as a garage door.""" + return 'garage' + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORTED_FEATURES + + @property + def name(self): + """Return the name of the garage door.""" + return self._name + + @property + def is_opening(self): + """Return if the cover is opening or not.""" + return self._status == STATE_OPENING + + @property + def is_closing(self): + """Return if the cover is closing or not.""" + return self._status == STATE_CLOSING + + @property + def is_closed(self): + """Return None if status is unknown, True if closed, else False.""" + if self._status is None: + return None + return self._status == STATE_CLOSED + + def close_cover(self, **kwargs): + """Issue close command to cover.""" + self._acc.close_door(self._device_id, self._number) + + def open_cover(self, **kwargs): + """Issue open command to cover.""" + self._acc.open_door(self._device_id, self._number) + + def update(self): + """Update status of cover.""" + acc_status = self._acc.get_door_status(self._device_id, self._number) + self._status = STATES_MAP.get(acc_status) diff --git a/homeassistant/components/cover/brunt.py b/homeassistant/components/cover/brunt.py new file mode 100644 index 00000000000000..713f06db7359da --- /dev/null +++ b/homeassistant/components/cover/brunt.py @@ -0,0 +1,182 @@ +""" +Support for Brunt Blind Engine covers. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/cover.brunt +""" + +import logging + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_PASSWORD, CONF_USERNAME) +from homeassistant.components.cover import ( + ATTR_POSITION, CoverDevice, + PLATFORM_SCHEMA, SUPPORT_CLOSE, + SUPPORT_OPEN, SUPPORT_SET_POSITION +) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['brunt==0.1.2'] + +_LOGGER = logging.getLogger(__name__) + +COVER_FEATURES = SUPPORT_OPEN | SUPPORT_CLOSE | SUPPORT_SET_POSITION +DEVICE_CLASS = 'window' + +ATTR_REQUEST_POSITION = 'request_position' +NOTIFICATION_ID = 'brunt_notification' +NOTIFICATION_TITLE = 'Brunt Cover Setup' +ATTRIBUTION = 'Based on an unofficial Brunt SDK.' + +CLOSED_POSITION = 0 +OPEN_POSITION = 100 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the brunt platform.""" + # pylint: disable=no-name-in-module + from brunt import BruntAPI + username = config[CONF_USERNAME] + password = config[CONF_PASSWORD] + + bapi = BruntAPI(username=username, password=password) + try: + things = bapi.getThings()['things'] + if not things: + _LOGGER.error("No things present in account.") + else: + add_devices([BruntDevice( + bapi, thing['NAME'], + thing['thingUri']) for thing in things], True) + except (TypeError, KeyError, NameError, ValueError) as ex: + _LOGGER.error("%s", ex) + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + + +class BruntDevice(CoverDevice): + """ + Representation of a Brunt cover device. + + Contains the common logic for all Brunt devices. + """ + + def __init__(self, bapi, name, thing_uri): + """Init the Brunt device.""" + self._bapi = bapi + self._name = name + self._thing_uri = thing_uri + + self._state = {} + self._available = None + + @property + def name(self): + """Return the name of the device as reported by tellcore.""" + return self._name + + @property + def available(self): + """Could the device be accessed during the last update call.""" + return self._available + + @property + def current_cover_position(self): + """ + Return current position of cover. + + None is unknown, 0 is closed, 100 is fully open. + """ + pos = self._state.get('currentPosition') + return int(pos) if pos else None + + @property + def request_cover_position(self): + """ + Return request position of cover. + + The request position is the position of the last request + to Brunt, at times there is a diff of 1 to current + None is unknown, 0 is closed, 100 is fully open. + """ + pos = self._state.get('requestPosition') + return int(pos) if pos else None + + @property + def move_state(self): + """ + Return current moving state of cover. + + None is unknown, 0 when stopped, 1 when opening, 2 when closing + """ + mov = self._state.get('moveState') + return int(mov) if mov else None + + @property + def is_opening(self): + """Return if the cover is opening or not.""" + return self.move_state == 1 + + @property + def is_closing(self): + """Return if the cover is closing or not.""" + return self.move_state == 2 + + @property + def device_state_attributes(self): + """Return the detailed device state attributes.""" + return { + ATTR_ATTRIBUTION: ATTRIBUTION, + ATTR_REQUEST_POSITION: self.request_cover_position + } + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return DEVICE_CLASS + + @property + def supported_features(self): + """Flag supported features.""" + return COVER_FEATURES + + @property + def is_closed(self): + """Return true if cover is closed, else False.""" + return self.current_cover_position == CLOSED_POSITION + + def update(self): + """Poll the current state of the device.""" + try: + self._state = self._bapi.getState( + thingUri=self._thing_uri).get('thing') + self._available = True + except (TypeError, KeyError, NameError, ValueError) as ex: + _LOGGER.error("%s", ex) + self._available = False + + def open_cover(self, **kwargs): + """Set the cover to the open position.""" + self._bapi.changeRequestPosition( + OPEN_POSITION, thingUri=self._thing_uri) + + def close_cover(self, **kwargs): + """Set the cover to the closed position.""" + self._bapi.changeRequestPosition( + CLOSED_POSITION, thingUri=self._thing_uri) + + def set_cover_position(self, **kwargs): + """Set the cover to a specific position.""" + self._bapi.changeRequestPosition( + kwargs[ATTR_POSITION], thingUri=self._thing_uri) diff --git a/homeassistant/components/cover/demo.py b/homeassistant/components/cover/demo.py index 70e681f11207fb..b81ac4e45e1a1e 100644 --- a/homeassistant/components/cover/demo.py +++ b/homeassistant/components/cover/demo.py @@ -24,7 +24,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class DemoCover(CoverDevice): """Representation of a demo cover.""" - # pylint: disable=no-self-use def __init__(self, hass, name, position=None, tilt_position=None, device_class=None, supported_features=None): """Initialize the cover.""" @@ -98,7 +97,7 @@ def close_cover(self, **kwargs): """Close the cover.""" if self._position == 0: return - elif self._position is None: + if self._position is None: self._closed = True self.schedule_update_ha_state() return @@ -120,7 +119,7 @@ def open_cover(self, **kwargs): """Open the cover.""" if self._position == 100: return - elif self._position is None: + if self._position is None: self._closed = False self.schedule_update_ha_state() return diff --git a/homeassistant/components/cover/garadget.py b/homeassistant/components/cover/garadget.py index c19aa69c8f04bb..70f6956810984f 100644 --- a/homeassistant/components/cover/garadget.py +++ b/homeassistant/components/cover/garadget.py @@ -73,7 +73,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class GaradgetCover(CoverDevice): """Representation of a Garadget cover.""" - # pylint: disable=no-self-use def __init__(self, hass, args): """Initialize the cover.""" self.particle_url = 'https://api.particle.io' diff --git a/homeassistant/components/cover/gogogate2.py b/homeassistant/components/cover/gogogate2.py index 688df62ca6af9b..2b91591e71b9d9 100644 --- a/homeassistant/components/cover/gogogate2.py +++ b/homeassistant/components/cover/gogogate2.py @@ -1,5 +1,5 @@ """ -Support for Gogogate2 Garage Doors. +Support for Gogogate2 garage Doors. For more details about this platform, please refer to the documentation https://home-assistant.io/components/cover.gogogate2/ @@ -15,7 +15,7 @@ CONF_IP_ADDRESS, CONF_NAME) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pygogogate2==0.0.7'] +REQUIREMENTS = ['pygogogate2==0.1.1'] _LOGGER = logging.getLogger(__name__) @@ -25,9 +25,9 @@ NOTIFICATION_TITLE = 'Gogogate2 Cover Setup' COVER_SCHEMA = vol.Schema({ - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) @@ -36,10 +36,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Gogogate2 component.""" from pygogogate2 import Gogogate2API as pygogogate2 - username = config.get(CONF_USERNAME) - password = config.get(CONF_PASSWORD) ip_address = config.get(CONF_IP_ADDRESS) name = config.get(CONF_NAME) + password = config.get(CONF_PASSWORD) + username = config.get(CONF_USERNAME) + mygogogate2 = pygogogate2(username, password, ip_address) try: diff --git a/homeassistant/components/cover/group.py b/homeassistant/components/cover/group.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/cover/isy994.py b/homeassistant/components/cover/isy994.py index 82ca60e84e6c91..0ccfe267989e71 100644 --- a/homeassistant/components/cover/isy994.py +++ b/homeassistant/components/cover/isy994.py @@ -5,7 +5,7 @@ https://home-assistant.io/components/cover.isy994/ """ import logging -from typing import Callable # noqa +from typing import Callable from homeassistant.components.cover import CoverDevice, DOMAIN from homeassistant.components.isy994 import (ISY994_NODES, ISY994_PROGRAMS, @@ -25,7 +25,6 @@ } -# pylint: disable=unused-argument def setup_platform(hass, config: ConfigType, add_devices: Callable[[list], None], discovery_info=None): """Set up the ISY994 cover platform.""" diff --git a/homeassistant/components/cover/knx.py b/homeassistant/components/cover/knx.py index 83668924268e0d..7bb20e4cf1f2cf 100644 --- a/homeassistant/components/cover/knx.py +++ b/homeassistant/components/cover/knx.py @@ -107,7 +107,6 @@ def async_register_callbacks(self): """Register callbacks to update hass after device was changed.""" async def after_update_callback(device): """Call after device was updated.""" - # pylint: disable=unused-argument await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) @@ -197,7 +196,6 @@ def stop_auto_updater(self): @callback def auto_updater_hook(self, now): """Call for the autoupdater.""" - # pylint: disable=unused-argument self.async_schedule_update_ha_state() if self.device.position_reached(): self.stop_auto_updater() diff --git a/homeassistant/components/cover/lutron.py b/homeassistant/components/cover/lutron.py index 4e38681a310f3c..599bdb1cebab7f 100644 --- a/homeassistant/components/cover/lutron.py +++ b/homeassistant/components/cover/lutron.py @@ -17,7 +17,6 @@ DEPENDENCIES = ['lutron'] -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Lutron shades.""" devs = [] diff --git a/homeassistant/components/cover/lutron_caseta.py b/homeassistant/components/cover/lutron_caseta.py index 6ad9b093ed84ae..87821b802ba6b9 100644 --- a/homeassistant/components/cover/lutron_caseta.py +++ b/homeassistant/components/cover/lutron_caseta.py @@ -4,7 +4,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.lutron_caseta/ """ -import asyncio import logging from homeassistant.components.cover import ( @@ -18,9 +17,8 @@ DEPENDENCIES = ['lutron_caseta'] -# pylint: disable=unused-argument -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the Lutron Caseta shades as a cover device.""" devs = [] bridge = hass.data[LUTRON_CASETA_SMARTBRIDGE] @@ -50,25 +48,21 @@ def current_cover_position(self): """Return the current position of cover.""" return self._state['current_state'] - @asyncio.coroutine - def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs): """Close the cover.""" self._smartbridge.set_value(self._device_id, 0) - @asyncio.coroutine - def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs): """Open the cover.""" self._smartbridge.set_value(self._device_id, 100) - @asyncio.coroutine - def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs): """Move the shade to a specific position.""" if ATTR_POSITION in kwargs: position = kwargs[ATTR_POSITION] self._smartbridge.set_value(self._device_id, position) - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Call when forcing a refresh of the device.""" self._state = self._smartbridge.get_device_by_id(self._device_id) _LOGGER.debug(self._state) diff --git a/homeassistant/components/cover/mqtt.py b/homeassistant/components/cover/mqtt.py index 0f31d3a9fe030d..e1775e2f968fd0 100644 --- a/homeassistant/components/cover/mqtt.py +++ b/homeassistant/components/cover/mqtt.py @@ -4,13 +4,12 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.mqtt/ """ -import asyncio import logging import voluptuous as vol from homeassistant.core import callback -import homeassistant.components.mqtt as mqtt +from homeassistant.components import mqtt from homeassistant.components.cover import ( CoverDevice, ATTR_TILT_POSITION, SUPPORT_OPEN_TILT, SUPPORT_CLOSE_TILT, SUPPORT_STOP_TILT, SUPPORT_SET_TILT_POSITION, @@ -93,8 +92,8 @@ }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the MQTT Cover.""" if discovery_info is not None: config = PLATFORM_SCHEMA(discovery_info) @@ -174,10 +173,9 @@ def __init__(self, name, state_topic, command_topic, availability_topic, self._position_topic = position_topic self._set_position_template = set_position_template - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Subscribe MQTT events.""" - yield from super().async_added_to_hass() + await super().async_added_to_hass() @callback def tilt_updated(topic, payload, qos): @@ -218,7 +216,7 @@ def state_message_received(topic, payload, qos): # Force into optimistic mode. self._optimistic = True else: - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._state_topic, state_message_received, self._qos) @@ -227,7 +225,7 @@ def state_message_received(topic, payload, qos): else: self._tilt_optimistic = False self._tilt_value = STATE_UNKNOWN - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._tilt_status_topic, tilt_updated, self._qos) @property @@ -235,6 +233,11 @@ def should_poll(self): """No polling needed.""" return False + @property + def assumed_state(self): + """Return true if we do optimistic updates.""" + return self._optimistic + @property def name(self): """Return the name of the cover.""" @@ -273,8 +276,7 @@ def supported_features(self): return supported_features - @asyncio.coroutine - def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs): """Move the cover up. This method is a coroutine. @@ -287,8 +289,7 @@ def async_open_cover(self, **kwargs): self._state = False self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs): """Move the cover down. This method is a coroutine. @@ -301,8 +302,7 @@ def async_close_cover(self, **kwargs): self._state = True self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs): """Stop the device. This method is a coroutine. @@ -311,8 +311,7 @@ def async_stop_cover(self, **kwargs): self.hass, self._command_topic, self._payload_stop, self._qos, self._retain) - @asyncio.coroutine - def async_open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs): """Tilt the cover open.""" mqtt.async_publish(self.hass, self._tilt_command_topic, self._tilt_open_position, self._qos, @@ -321,8 +320,7 @@ def async_open_cover_tilt(self, **kwargs): self._tilt_value = self._tilt_open_position self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs): """Tilt the cover closed.""" mqtt.async_publish(self.hass, self._tilt_command_topic, self._tilt_closed_position, self._qos, @@ -331,8 +329,7 @@ def async_close_cover_tilt(self, **kwargs): self._tilt_value = self._tilt_closed_position self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" if ATTR_TILT_POSITION not in kwargs: return @@ -345,8 +342,7 @@ def async_set_cover_tilt_position(self, **kwargs): mqtt.async_publish(self.hass, self._tilt_command_topic, level, self._qos, self._retain) - @asyncio.coroutine - def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" if ATTR_POSITION in kwargs: position = kwargs[ATTR_POSITION] diff --git a/homeassistant/components/cover/myq.py b/homeassistant/components/cover/myq.py index f07d3849fae77e..a4682172feee46 100644 --- a/homeassistant/components/cover/myq.py +++ b/homeassistant/components/cover/myq.py @@ -13,7 +13,7 @@ CONF_USERNAME, CONF_PASSWORD, CONF_TYPE, STATE_CLOSED) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pymyq==0.0.8'] +REQUIREMENTS = ['pymyq==0.0.11'] _LOGGER = logging.getLogger(__name__) @@ -69,6 +69,11 @@ def __init__(self, myq, device): self._name = device['name'] self._status = STATE_CLOSED + @property + def device_class(self): + """Define this cover as a garage door.""" + return 'garage' + @property def should_poll(self): """Poll for state.""" diff --git a/homeassistant/components/cover/mysensors.py b/homeassistant/components/cover/mysensors.py index 669a7ce672300d..c815cf44df2d02 100644 --- a/homeassistant/components/cover/mysensors.py +++ b/homeassistant/components/cover/mysensors.py @@ -17,7 +17,7 @@ async def async_setup_platform( async_add_devices=async_add_devices) -class MySensorsCover(mysensors.MySensorsEntity, CoverDevice): +class MySensorsCover(mysensors.device.MySensorsEntity, CoverDevice): """Representation of the value of a MySensors Cover child node.""" @property @@ -42,7 +42,7 @@ def current_cover_position(self): set_req = self.gateway.const.SetReq return self._values.get(set_req.V_DIMMER) - def open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs): """Move the cover up.""" set_req = self.gateway.const.SetReq self.gateway.set_child_value( @@ -53,9 +53,9 @@ def open_cover(self, **kwargs): self._values[set_req.V_DIMMER] = 100 else: self._values[set_req.V_LIGHT] = STATE_ON - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() - def close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs): """Move the cover down.""" set_req = self.gateway.const.SetReq self.gateway.set_child_value( @@ -66,9 +66,9 @@ def close_cover(self, **kwargs): self._values[set_req.V_DIMMER] = 0 else: self._values[set_req.V_LIGHT] = STATE_OFF - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() - def set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" position = kwargs.get(ATTR_POSITION) set_req = self.gateway.const.SetReq @@ -77,9 +77,9 @@ def set_cover_position(self, **kwargs): if self.gateway.optimistic: # Optimistically assume that cover has changed state. self._values[set_req.V_DIMMER] = position - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() - def stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs): """Stop the device.""" set_req = self.gateway.const.SetReq self.gateway.set_child_value( diff --git a/homeassistant/components/cover/opengarage.py b/homeassistant/components/cover/opengarage.py index 028a7a0c9fc8ad..fe6c7763cc7779 100644 --- a/homeassistant/components/cover/opengarage.py +++ b/homeassistant/components/cover/opengarage.py @@ -72,7 +72,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class OpenGarageCover(CoverDevice): """Representation of a OpenGarage cover.""" - # pylint: disable=no-self-use def __init__(self, hass, args): """Initialize the cover.""" self.opengarage_url = 'http://{}:{}'.format( diff --git a/homeassistant/components/cover/rflink.py b/homeassistant/components/cover/rflink.py index a9b7598159f967..3357bf2d204fb0 100644 --- a/homeassistant/components/cover/rflink.py +++ b/homeassistant/components/cover/rflink.py @@ -4,7 +4,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.rflink/ """ -import asyncio import logging import voluptuous as vol @@ -79,8 +78,8 @@ def devices_from_config(domain_config, hass=None): return devices -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the Rflink cover platform.""" async_add_devices(devices_from_config(config, hass)) diff --git a/homeassistant/components/cover/rfxtrx.py b/homeassistant/components/cover/rfxtrx.py index aefb7ab89d711b..5079a3b60c2902 100644 --- a/homeassistant/components/cover/rfxtrx.py +++ b/homeassistant/components/cover/rfxtrx.py @@ -6,7 +6,7 @@ """ import voluptuous as vol -import homeassistant.components.rfxtrx as rfxtrx +from homeassistant.components import rfxtrx from homeassistant.components.cover import CoverDevice, PLATFORM_SCHEMA from homeassistant.const import CONF_NAME from homeassistant.components.rfxtrx import ( diff --git a/homeassistant/components/cover/rpi_gpio.py b/homeassistant/components/cover/rpi_gpio.py index 49666139330c5c..2f6951cfc0d254 100644 --- a/homeassistant/components/cover/rpi_gpio.py +++ b/homeassistant/components/cover/rpi_gpio.py @@ -14,7 +14,7 @@ from homeassistant.components.cover import CoverDevice, PLATFORM_SCHEMA from homeassistant.const import CONF_NAME -import homeassistant.components.rpi_gpio as rpi_gpio +from homeassistant.components import rpi_gpio import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -54,7 +54,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the RPi cover platform.""" relay_time = config.get(CONF_RELAY_TIME) diff --git a/homeassistant/components/cover/ryobi_gdo.py b/homeassistant/components/cover/ryobi_gdo.py new file mode 100644 index 00000000000000..a11d70dd3adbad --- /dev/null +++ b/homeassistant/components/cover/ryobi_gdo.py @@ -0,0 +1,103 @@ +""" +Ryobi platform for the cover component. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/cover.ryobi_gdo/ +""" +import logging +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv + +from homeassistant.components.cover import ( + CoverDevice, PLATFORM_SCHEMA, SUPPORT_OPEN, SUPPORT_CLOSE) +from homeassistant.const import ( + CONF_USERNAME, CONF_PASSWORD, STATE_UNKNOWN, STATE_CLOSED) + +REQUIREMENTS = ['py_ryobi_gdo==0.0.10'] + +_LOGGER = logging.getLogger(__name__) + +CONF_DEVICE_ID = 'device_id' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_DEVICE_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, +}) + +SUPPORTED_FEATURES = (SUPPORT_OPEN | SUPPORT_CLOSE) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Ryobi covers.""" + from py_ryobi_gdo import RyobiGDO as ryobi_door + covers = [] + + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + devices = config.get(CONF_DEVICE_ID) + + for device_id in devices: + my_door = ryobi_door(username, password, device_id) + _LOGGER.debug("Getting the API key") + if my_door.get_api_key() is False: + _LOGGER.error("Wrong credentials, no API key retrieved") + return + _LOGGER.debug("Checking if the device ID is present") + if my_door.check_device_id() is False: + _LOGGER.error("%s not in your device list", device_id) + return + _LOGGER.debug("Adding device %s to covers", device_id) + covers.append(RyobiCover(hass, my_door)) + if covers: + _LOGGER.debug("Adding covers") + add_devices(covers, True) + + +class RyobiCover(CoverDevice): + """Representation of a ryobi cover.""" + + def __init__(self, hass, ryobi_door): + """Initialize the cover.""" + self.ryobi_door = ryobi_door + self._name = 'ryobi_gdo_{}'.format(ryobi_door.get_device_id()) + self._door_state = None + + @property + def name(self): + """Return the name of the cover.""" + return self._name + + @property + def is_closed(self): + """Return if the cover is closed.""" + if self._door_state == STATE_UNKNOWN: + return False + return self._door_state == STATE_CLOSED + + @property + def device_class(self): + """Return the class of this device, from component DEVICE_CLASSES.""" + return 'garage' + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORTED_FEATURES + + def close_cover(self, **kwargs): + """Close the cover.""" + _LOGGER.debug("Closing garage door") + self.ryobi_door.close_device() + + def open_cover(self, **kwargs): + """Open the cover.""" + _LOGGER.debug("Opening garage door") + self.ryobi_door.open_device() + + def update(self): + """Update status from the door.""" + _LOGGER.debug("Updating RyobiGDO status") + self.ryobi_door.update() + self._door_state = self.ryobi_door.get_door_status() diff --git a/homeassistant/components/cover/scsgate.py b/homeassistant/components/cover/scsgate.py index ac4fddf98bb790..04bf0ef1d32a84 100644 --- a/homeassistant/components/cover/scsgate.py +++ b/homeassistant/components/cover/scsgate.py @@ -8,7 +8,7 @@ import voluptuous as vol -import homeassistant.components.scsgate as scsgate +from homeassistant.components import scsgate from homeassistant.components.cover import (CoverDevice, PLATFORM_SCHEMA) from homeassistant.const import (CONF_DEVICES, CONF_NAME) import homeassistant.helpers.config_validation as cv diff --git a/homeassistant/components/cover/tahoma.py b/homeassistant/components/cover/tahoma.py index 20625143daf1db..b38a863ebe04d0 100644 --- a/homeassistant/components/cover/tahoma.py +++ b/homeassistant/components/cover/tahoma.py @@ -4,8 +4,10 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.tahoma/ """ +from datetime import timedelta import logging +from homeassistant.util.dt import utcnow from homeassistant.components.cover import CoverDevice, ATTR_POSITION from homeassistant.components.tahoma import ( DOMAIN as TAHOMA_DOMAIN, TahomaDevice) @@ -14,6 +16,13 @@ _LOGGER = logging.getLogger(__name__) +ATTR_MEM_POS = 'memorized_position' +ATTR_RSSI_LEVEL = 'rssi_level' +ATTR_LOCK_START_TS = 'lock_start_ts' +ATTR_LOCK_END_TS = 'lock_end_ts' +ATTR_LOCK_LEVEL = 'lock_level' +ATTR_LOCK_ORIG = 'lock_originator' + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Tahoma covers.""" @@ -27,27 +36,107 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class TahomaCover(TahomaDevice, CoverDevice): """Representation a Tahoma Cover.""" + def __init__(self, tahoma_device, controller): + """Initialize the device.""" + super().__init__(tahoma_device, controller) + + self._closure = 0 + # 100 equals open + self._position = 100 + self._closed = False + self._rssi_level = None + self._icon = None + # Can be 0 and bigger + self._lock_timer = 0 + self._lock_start_ts = None + self._lock_end_ts = None + # Can be 'comfortLevel1', 'comfortLevel2', 'comfortLevel3', + # 'comfortLevel4', 'environmentProtection', 'humanProtection', + # 'userLevel1', 'userLevel2' + self._lock_level = None + # Can be 'LSC', 'SAAC', 'SFC', 'UPS', 'externalGateway', 'localUser', + # 'myself', 'rain', 'security', 'temperature', 'timer', 'user', 'wind' + self._lock_originator = None + def update(self): """Update method.""" self.controller.get_states([self.tahoma_device]) + # For vertical covers + self._closure = self.tahoma_device.active_states.get( + 'core:ClosureState') + # For horizontal covers + if self._closure is None: + self._closure = self.tahoma_device.active_states.get( + 'core:DeploymentState') + + # For all, if available + if 'core:PriorityLockTimerState' in self.tahoma_device.active_states: + old_lock_timer = self._lock_timer + self._lock_timer = \ + self.tahoma_device.active_states['core:PriorityLockTimerState'] + # Derive timestamps from _lock_timer, only if not already set or + # something has changed + if self._lock_timer > 0: + _LOGGER.debug("Update %s, lock_timer: %d", self._name, + self._lock_timer) + if self._lock_start_ts is None: + self._lock_start_ts = utcnow() + if self._lock_end_ts is None or \ + old_lock_timer != self._lock_timer: + self._lock_end_ts = utcnow() +\ + timedelta(seconds=self._lock_timer) + else: + self._lock_start_ts = None + self._lock_end_ts = None + else: + self._lock_timer = 0 + self._lock_start_ts = None + self._lock_end_ts = None + + self._lock_level = self.tahoma_device.active_states.get( + 'io:PriorityLockLevelState') + + self._lock_originator = self.tahoma_device.active_states.get( + 'io:PriorityLockOriginatorState') + + self._rssi_level = self.tahoma_device.active_states.get( + 'core:RSSILevelState') + + # Define which icon to use + if self._lock_timer > 0: + if self._lock_originator == 'wind': + self._icon = 'mdi:weather-windy' + else: + self._icon = 'mdi:lock-alert' + else: + self._icon = None + + # Define current position. + # _position: 0 is closed, 100 is fully open. + # 'core:ClosureState': 100 is closed, 0 is fully open. + if self._closure is not None: + self._position = 100 - self._closure + if self._position <= 5: + self._position = 0 + if self._position >= 95: + self._position = 100 + self._closed = self._position == 0 + else: + self._position = None + if 'core:OpenClosedState' in self.tahoma_device.active_states: + self._closed = \ + self.tahoma_device.active_states['core:OpenClosedState']\ + == 'closed' + else: + self._closed = False + + _LOGGER.debug("Update %s, position: %d", self._name, self._position) + @property def current_cover_position(self): - """ - Return current position of cover. - - 0 is closed, 100 is fully open. - """ - try: - position = 100 - \ - self.tahoma_device.active_states['core:ClosureState'] - if position <= 5: - return 0 - if position >= 95: - return 100 - return position - except KeyError: - return None + """Return current position of cover.""" + return self._position def set_cover_position(self, **kwargs): """Move the cover to a specific position.""" @@ -56,8 +145,7 @@ def set_cover_position(self, **kwargs): @property def is_closed(self): """Return if the cover is closed.""" - if self.current_cover_position is not None: - return self.current_cover_position == 0 + return self._closed @property def device_class(self): @@ -66,20 +154,65 @@ def device_class(self): return 'window' return None + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attr = {} + super_attr = super().device_state_attributes + if super_attr is not None: + attr.update(super_attr) + + if 'core:Memorized1PositionState' in self.tahoma_device.active_states: + attr[ATTR_MEM_POS] = self.tahoma_device.active_states[ + 'core:Memorized1PositionState'] + if self._rssi_level is not None: + attr[ATTR_RSSI_LEVEL] = self._rssi_level + if self._lock_start_ts is not None: + attr[ATTR_LOCK_START_TS] = self._lock_start_ts.isoformat() + if self._lock_end_ts is not None: + attr[ATTR_LOCK_END_TS] = self._lock_end_ts.isoformat() + if self._lock_level is not None: + attr[ATTR_LOCK_LEVEL] = self._lock_level + if self._lock_originator is not None: + attr[ATTR_LOCK_ORIG] = self._lock_originator + return attr + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return self._icon + def open_cover(self, **kwargs): """Open the cover.""" - self.apply_action('open') + if self.tahoma_device.type == 'io:HorizontalAwningIOComponent': + self.apply_action('close') + else: + self.apply_action('open') def close_cover(self, **kwargs): """Close the cover.""" - self.apply_action('close') + if self.tahoma_device.type == 'io:HorizontalAwningIOComponent': + self.apply_action('open') + else: + self.apply_action('close') def stop_cover(self, **kwargs): """Stop the cover.""" if self.tahoma_device.type == \ 'io:RollerShutterWithLowSpeedManagementIOComponent': self.apply_action('setPosition', 'secured') - elif self.tahoma_device.type == 'rts:BlindRTSComponent': + elif self.tahoma_device.type in \ + ('rts:BlindRTSComponent', + 'io:ExteriorVenetianBlindIOComponent', + 'rts:VenetianBlindRTSComponent', + 'rts:DualCurtainRTSComponent', + 'rts:ExteriorVenetianBlindRTSComponent', + 'rts:BlindRTSComponent'): self.apply_action('my') + elif self.tahoma_device.type in \ + ('io:HorizontalAwningIOComponent', + 'io:RollerShutterGenericIOComponent', + 'io:VerticalExteriorAwningIOComponent'): + self.apply_action('stop') else: self.apply_action('stopIdentify') diff --git a/homeassistant/components/cover/template.py b/homeassistant/components/cover/template.py index 4e197365a7098e..d9d0d61c77a8f0 100644 --- a/homeassistant/components/cover/template.py +++ b/homeassistant/components/cover/template.py @@ -4,7 +4,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.template/ """ -import asyncio import logging import voluptuous as vol @@ -72,8 +71,8 @@ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the Template cover.""" covers = [] @@ -199,8 +198,7 @@ def __init__(self, hass, device_id, friendly_name, state_template, if self._entity_picture_template is not None: self._entity_picture_template.hass = self.hass - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Register callbacks.""" @callback def template_cover_state_listener(entity, old_state, new_state): @@ -277,70 +275,62 @@ def should_poll(self): """Return the polling state.""" return False - @asyncio.coroutine - def async_open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs): """Move the cover up.""" if self._open_script: - yield from self._open_script.async_run() + await self._open_script.async_run() elif self._position_script: - yield from self._position_script.async_run({"position": 100}) + await self._position_script.async_run({"position": 100}) if self._optimistic: self._position = 100 self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs): """Move the cover down.""" if self._close_script: - yield from self._close_script.async_run() + await self._close_script.async_run() elif self._position_script: - yield from self._position_script.async_run({"position": 0}) + await self._position_script.async_run({"position": 0}) if self._optimistic: self._position = 0 self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_stop_cover(self, **kwargs): + async def async_stop_cover(self, **kwargs): """Fire the stop action.""" if self._stop_script: - yield from self._stop_script.async_run() + await self._stop_script.async_run() - @asyncio.coroutine - def async_set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs): """Set cover position.""" self._position = kwargs[ATTR_POSITION] - yield from self._position_script.async_run( + await self._position_script.async_run( {"position": self._position}) if self._optimistic: self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_open_cover_tilt(self, **kwargs): + async def async_open_cover_tilt(self, **kwargs): """Tilt the cover open.""" self._tilt_value = 100 - yield from self._tilt_script.async_run({"tilt": self._tilt_value}) + await self._tilt_script.async_run({"tilt": self._tilt_value}) if self._tilt_optimistic: self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_close_cover_tilt(self, **kwargs): + async def async_close_cover_tilt(self, **kwargs): """Tilt the cover closed.""" self._tilt_value = 0 - yield from self._tilt_script.async_run( + await self._tilt_script.async_run( {"tilt": self._tilt_value}) if self._tilt_optimistic: self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" self._tilt_value = kwargs[ATTR_TILT_POSITION] - yield from self._tilt_script.async_run({"tilt": self._tilt_value}) + await self._tilt_script.async_run({"tilt": self._tilt_value}) if self._tilt_optimistic: self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Update the state from the template.""" if self._template is not None: try: diff --git a/homeassistant/components/cover/tuya.py b/homeassistant/components/cover/tuya.py new file mode 100644 index 00000000000000..7b5fefee58aab1 --- /dev/null +++ b/homeassistant/components/cover/tuya.py @@ -0,0 +1,60 @@ +""" +Support for Tuya cover. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/cover.tuya/ +""" +from homeassistant.components.cover import ( + CoverDevice, ENTITY_ID_FORMAT, SUPPORT_OPEN, SUPPORT_CLOSE, SUPPORT_STOP) +from homeassistant.components.tuya import DATA_TUYA, TuyaDevice + +DEPENDENCIES = ['tuya'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Tuya cover devices.""" + if discovery_info is None: + return + tuya = hass.data[DATA_TUYA] + dev_ids = discovery_info.get('dev_ids') + devices = [] + for dev_id in dev_ids: + device = tuya.get_device_by_id(dev_id) + if device is None: + continue + devices.append(TuyaCover(device)) + add_devices(devices) + + +class TuyaCover(TuyaDevice, CoverDevice): + """Tuya cover devices.""" + + def __init__(self, tuya): + """Init tuya cover device.""" + super().__init__(tuya) + self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) + + @property + def supported_features(self): + """Flag supported features.""" + supported_features = SUPPORT_OPEN | SUPPORT_CLOSE + if self.tuya.support_stop(): + supported_features |= SUPPORT_STOP + return supported_features + + @property + def is_closed(self): + """Return if the cover is closed or not.""" + return None + + def open_cover(self, **kwargs): + """Open the cover.""" + self.tuya.open_cover() + + def close_cover(self, **kwargs): + """Close cover.""" + self.tuya.close_cover() + + def stop_cover(self, **kwargs): + """Stop the cover.""" + self.tuya.stop_cover() diff --git a/homeassistant/components/cover/velbus.py b/homeassistant/components/cover/velbus.py index ab5d6e8ef7947a..fd060e7a7e1c07 100644 --- a/homeassistant/components/cover/velbus.py +++ b/homeassistant/components/cover/velbus.py @@ -5,7 +5,6 @@ https://home-assistant.io/components/cover.velbus/ """ import logging -import asyncio import time import voluptuous as vol @@ -70,15 +69,14 @@ def __init__(self, velbus, name, module, open_channel, close_channel): self._open_channel = open_channel self._close_channel = close_channel - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Add listener for Velbus messages on bus.""" def _init_velbus(): """Initialize Velbus on startup.""" self._velbus.subscribe(self._on_message) self.get_status() - yield from self.hass.async_add_job(_init_velbus) + await self.hass.async_add_job(_init_velbus) def _on_message(self, message): import velbus diff --git a/homeassistant/components/cover/vera.py b/homeassistant/components/cover/vera.py index ff9ba6f762b861..9b2e8f3aad02b1 100644 --- a/homeassistant/components/cover/vera.py +++ b/homeassistant/components/cover/vera.py @@ -19,8 +19,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Vera covers.""" add_devices( - VeraCover(device, hass.data[VERA_CONTROLLER]) for - device in hass.data[VERA_DEVICES]['cover']) + [VeraCover(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['cover']], True) class VeraCover(VeraDevice, CoverDevice): diff --git a/homeassistant/components/cover/wink.py b/homeassistant/components/cover/wink.py index 093ccd43473a56..2206de05041435 100644 --- a/homeassistant/components/cover/wink.py +++ b/homeassistant/components/cover/wink.py @@ -4,8 +4,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/cover.wink/ """ -import asyncio - from homeassistant.components.cover import CoverDevice, STATE_UNKNOWN, \ ATTR_POSITION from homeassistant.components.wink import WinkDevice, DOMAIN @@ -21,6 +19,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _id = shade.object_id() + shade.name() if _id not in hass.data[DOMAIN]['unique_ids']: add_devices([WinkCoverDevice(shade, hass)]) + for shade in pywink.get_shade_groups(): + _id = shade.object_id() + shade.name() + if _id not in hass.data[DOMAIN]['unique_ids']: + add_devices([WinkCoverDevice(shade, hass)]) for door in pywink.get_garage_doors(): _id = door.object_id() + door.name() if _id not in hass.data[DOMAIN]['unique_ids']: @@ -30,8 +32,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): class WinkCoverDevice(WinkDevice, CoverDevice): """Representation of a Wink cover device.""" - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Call when entity is added to hass.""" self.hass.data[DOMAIN]['entities']['cover'].append(self) diff --git a/homeassistant/components/cover/zwave.py b/homeassistant/components/cover/zwave.py index 6f4a11684bde61..8c8c88ecb87f71 100644 --- a/homeassistant/components/cover/zwave.py +++ b/homeassistant/components/cover/zwave.py @@ -11,7 +11,7 @@ DOMAIN, SUPPORT_OPEN, SUPPORT_CLOSE, ATTR_POSITION) from homeassistant.components.zwave import ZWaveDeviceEntity from homeassistant.components import zwave -from homeassistant.components.zwave import async_setup_platform # noqa # pylint: disable=unused-import +from homeassistant.components.zwave import async_setup_platform # noqa pylint: disable=unused-import from homeassistant.components.zwave import workaround from homeassistant.components.cover import CoverDevice @@ -27,11 +27,10 @@ def get_device(hass, values, node_config, **kwargs): zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL and values.primary.index == 0): return ZwaveRollershutter(hass, values, invert_buttons) - elif (values.primary.command_class == - zwave.const.COMMAND_CLASS_SWITCH_BINARY): + if values.primary.command_class == zwave.const.COMMAND_CLASS_SWITCH_BINARY: return ZwaveGarageDoorSwitch(values) - elif (values.primary.command_class == - zwave.const.COMMAND_CLASS_BARRIER_OPERATOR): + if values.primary.command_class == \ + zwave.const.COMMAND_CLASS_BARRIER_OPERATOR: return ZwaveGarageDoorBarrier(values) return None @@ -42,7 +41,6 @@ class ZwaveRollershutter(zwave.ZWaveDeviceEntity, CoverDevice): def __init__(self, hass, values, invert_buttons): """Initialize the Z-Wave rollershutter.""" ZWaveDeviceEntity.__init__(self, values, DOMAIN) - # pylint: disable=no-member self._network = hass.data[zwave.const.DATA_NETWORK] self._open_id = None self._close_id = None @@ -85,7 +83,7 @@ def current_cover_position(self): if self._current_position is not None: if self._current_position <= 5: return 0 - elif self._current_position >= 95: + if self._current_position >= 95: return 100 return self._current_position diff --git a/homeassistant/components/daikin.py b/homeassistant/components/daikin.py index 5808528ca5adf7..8983ecf82d8249 100644 --- a/homeassistant/components/daikin.py +++ b/homeassistant/components/daikin.py @@ -115,7 +115,7 @@ def daikin_api_setup(hass, host, name=None): return api -class DaikinApi(object): +class DaikinApi: """Keep the Daikin instance in one place and centralize the update.""" def __init__(self, device, name): diff --git a/homeassistant/components/deconz/.translations/bg.json b/homeassistant/components/deconz/.translations/bg.json index 91727cae257009..2ea6576206375d 100644 --- a/homeassistant/components/deconz/.translations/bg.json +++ b/homeassistant/components/deconz/.translations/bg.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "\u041c\u043e\u0441\u0442\u044a\u0442 \u0432\u0435\u0447\u0435 \u0435 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0438\u0440\u0430\u043d", "no_bridges": "\u041d\u0435 \u0441\u0430 \u043e\u0442\u043a\u0440\u0438\u0442\u0438 \u043c\u043e\u0441\u0442\u043e\u0432\u0435 deCONZ", "one_instance_only": "\u041a\u043e\u043c\u043f\u043e\u043d\u0435\u043d\u0442 \u043f\u043e\u0434\u0434\u044a\u0440\u0436\u0430 \u0441\u0430\u043c\u043e \u0435\u0434\u043d\u043e \u043a\u043e\u043f\u0438\u0435 \u043d\u0430 deCONZ" }, diff --git a/homeassistant/components/deconz/.translations/ca.json b/homeassistant/components/deconz/.translations/ca.json new file mode 100644 index 00000000000000..0a9e6fdee3f68e --- /dev/null +++ b/homeassistant/components/deconz/.translations/ca.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "L'enlla\u00e7 ja est\u00e0 configurat", + "no_bridges": "No s'han descobert enlla\u00e7os amb deCONZ", + "one_instance_only": "El component nom\u00e9s admet una inst\u00e0ncia deCONZ" + }, + "error": { + "no_key": "No s'ha pogut obtenir una clau API" + }, + "step": { + "init": { + "data": { + "host": "Amfitri\u00f3", + "port": "Port (predeterminat: '80')" + }, + "title": "Definiu la passarel\u00b7la deCONZ" + }, + "link": { + "description": "Desbloqueja la teva passarel\u00b7la d'enlla\u00e7 deCONZ per a registrar-te amb Home Assistant.\n\n1. V\u00e9s a la configuraci\u00f3 del sistema deCONZ\n2. Prem el bot\u00f3 \"Desbloquejar passarel\u00b7la\"", + "title": "Vincular amb deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Permet la importaci\u00f3 de sensors virtuals", + "allow_deconz_groups": "Permet la importaci\u00f3 de grups deCONZ" + }, + "title": "Opcions de configuraci\u00f3 addicionals per deCONZ" + } + }, + "title": "deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/cs.json b/homeassistant/components/deconz/.translations/cs.json new file mode 100644 index 00000000000000..1588766e406c78 --- /dev/null +++ b/homeassistant/components/deconz/.translations/cs.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "P\u0159emost\u011bn\u00ed je ji\u017e nakonfigurov\u00e1no", + "no_bridges": "\u017d\u00e1dn\u00e9 deCONZ p\u0159emost\u011bn\u00ed nebyly nalezeny", + "one_instance_only": "Komponent podporuje pouze jednu instanci deCONZ" + }, + "error": { + "no_key": "Nelze z\u00edskat kl\u00ed\u010d API" + }, + "step": { + "init": { + "data": { + "host": "Hostitel", + "port": "Port (v\u00fdchoz\u00ed hodnota: '80')" + }, + "title": "Definujte br\u00e1nu deCONZ" + }, + "link": { + "description": "Odemkn\u011bte br\u00e1nu deCONZ, pro registraci v Home Assistant. \n\n 1. P\u0159ejd\u011bte do nastaven\u00ed syst\u00e9mu deCONZ \n 2. Stiskn\u011bte tla\u010d\u00edtko \"Unlock Gateway\"", + "title": "Propojit s deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Povolit import virtu\u00e1ln\u00edch \u010didel", + "allow_deconz_groups": "Povolit import skupin deCONZ " + }, + "title": "Dal\u0161\u00ed mo\u017enosti konfigurace pro deCONZ" + } + }, + "title": "Br\u00e1na deCONZ Zigbee" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/de.json b/homeassistant/components/deconz/.translations/de.json index 9d3dc9e6e62f41..51b496906a2a08 100644 --- a/homeassistant/components/deconz/.translations/de.json +++ b/homeassistant/components/deconz/.translations/de.json @@ -19,8 +19,15 @@ "link": { "description": "Entsperren Sie Ihr deCONZ-Gateway, um sich bei Home Assistant zu registrieren. \n\n 1. Gehen Sie zu den deCONZ-Systemeinstellungen \n 2. Dr\u00fccken Sie die Taste \"Gateway entsperren\"", "title": "Mit deCONZ verbinden" + }, + "options": { + "data": { + "allow_clip_sensor": "Import virtueller Sensoren zulassen", + "allow_deconz_groups": "Import von deCONZ-Gruppen zulassen" + }, + "title": "Weitere Konfigurationsoptionen f\u00fcr deCONZ" } }, - "title": "deCONZ" + "title": "deCONZ Zigbee Gateway" } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/en.json b/homeassistant/components/deconz/.translations/en.json index 0009986d45f48e..f55f64ca43094a 100644 --- a/homeassistant/components/deconz/.translations/en.json +++ b/homeassistant/components/deconz/.translations/en.json @@ -19,8 +19,15 @@ "link": { "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ system settings\n2. Press \"Unlock Gateway\" button", "title": "Link with deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Allow importing virtual sensors", + "allow_deconz_groups": "Allow importing deCONZ groups" + }, + "title": "Extra configuration options for deCONZ" } }, - "title": "deCONZ" + "title": "deCONZ Zigbee gateway" } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/es-419.json b/homeassistant/components/deconz/.translations/es-419.json new file mode 100644 index 00000000000000..ab47a5b43c824c --- /dev/null +++ b/homeassistant/components/deconz/.translations/es-419.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "already_configured": "El Bridge ya est\u00e1 configurado", + "no_bridges": "No se descubrieron puentes deCONZ", + "one_instance_only": "El componente solo admite una instancia deCONZ" + }, + "error": { + "no_key": "No se pudo obtener una clave de API" + }, + "step": { + "init": { + "data": { + "host": "Host", + "port": "Puerto (valor predeterminado: '80')" + }, + "title": "Definir el gateway deCONZ" + }, + "link": { + "title": "Enlazar con deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Permitir la importaci\u00f3n de sensores virtuales", + "allow_deconz_groups": "Permitir la importaci\u00f3n de grupos deCONZ" + } + } + }, + "title": "deCONZ Zigbee gateway" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/fr.json b/homeassistant/components/deconz/.translations/fr.json new file mode 100644 index 00000000000000..02f174cd59f746 --- /dev/null +++ b/homeassistant/components/deconz/.translations/fr.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "already_configured": "Ce pont est d\u00e9j\u00e0 configur\u00e9", + "no_bridges": "Aucun pont deCONZ n'a \u00e9t\u00e9 d\u00e9couvert", + "one_instance_only": "Le composant prend uniquement en charge une instance deCONZ" + }, + "error": { + "no_key": "Impossible d'obtenir une cl\u00e9 d'API" + }, + "step": { + "init": { + "data": { + "host": "H\u00f4te", + "port": "Port (valeur par d\u00e9faut : 80)" + }, + "title": "Initialiser la passerelle deCONZ" + }, + "link": { + "description": "D\u00e9verrouillez votre passerelle deCONZ pour vous enregistrer aupr\u00e8s de Home Assistant. \n\n 1. Acc\u00e9dez aux param\u00e8tres du syst\u00e8me deCONZ \n 2. Cliquez sur \"D\u00e9verrouiller la passerelle\"", + "title": "Lien vers deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Autoriser l'importation de capteurs virtuels" + }, + "title": "Options de configuration suppl\u00e9mentaires pour deCONZ" + } + }, + "title": "Passerelle deCONZ Zigbee" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/hu.json b/homeassistant/components/deconz/.translations/hu.json index 42aab9c6d7e56f..c1fd76c5035fc2 100644 --- a/homeassistant/components/deconz/.translations/hu.json +++ b/homeassistant/components/deconz/.translations/hu.json @@ -1,6 +1,8 @@ { "config": { "abort": { + "already_configured": "A bridge m\u00e1r konfigur\u00e1lva van", + "no_bridges": "Nem tal\u00e1ltam deCONZ bridget", "one_instance_only": "Ez a komponens csak egy deCONZ egys\u00e9get t\u00e1mogat" }, "error": { @@ -11,9 +13,11 @@ "data": { "host": "H\u00e1zigazda (Host)", "port": "Port (alap\u00e9rtelmezett \u00e9rt\u00e9k: '80')" - } + }, + "title": "deCONZ \u00e1tj\u00e1r\u00f3 megad\u00e1sa" }, "link": { + "description": "Oldja fel a deCONZ \u00e1tj\u00e1r\u00f3t a Home Assistant-ban val\u00f3 regisztr\u00e1l\u00e1shoz.\n\n1. Menjen a deCONZ rendszer be\u00e1ll\u00edt\u00e1sokhoz\n2. Nyomja meg az \"\u00c1tj\u00e1r\u00f3 felold\u00e1sa\" gombot", "title": "Kapcsol\u00f3d\u00e1s a deCONZ-hoz" } }, diff --git a/homeassistant/components/deconz/.translations/it.json b/homeassistant/components/deconz/.translations/it.json new file mode 100644 index 00000000000000..87dcd0610f2ccf --- /dev/null +++ b/homeassistant/components/deconz/.translations/it.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Il Bridge \u00e8 gi\u00e0 configurato", + "no_bridges": "Nessun bridge deCONZ rilevato", + "one_instance_only": "Il componente supporto solo un'istanza di deCONZ" + }, + "error": { + "no_key": "Impossibile ottenere una API key" + }, + "step": { + "init": { + "data": { + "host": "Host", + "port": "Porta (valore di default: '80')" + }, + "title": "Definisci il gateway deCONZ" + }, + "link": { + "description": "Sblocca il tuo gateway deCONZ per registrarlo in Home Assistant.\n\n1. Vai nelle impostazioni di sistema di deCONZ\n2. Premi il bottone \"Unlock Gateway\"", + "title": "Collega con deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Consenti l'importazione di sensori virtuali", + "allow_deconz_groups": "Consenti l'importazione di gruppi deCONZ" + }, + "title": "Opzioni di configurazione extra per deCONZ" + } + }, + "title": "deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/ja.json b/homeassistant/components/deconz/.translations/ja.json new file mode 100644 index 00000000000000..5148ebeaa86b2c --- /dev/null +++ b/homeassistant/components/deconz/.translations/ja.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "no_key": "API\u30ad\u30fc\u3092\u53d6\u5f97\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f" + }, + "step": { + "init": { + "data": { + "host": "\u30db\u30b9\u30c8", + "port": "\u30dd\u30fc\u30c8\uff08\u30c7\u30d5\u30a9\u30eb\u30c8\u5024\uff1a'80'\uff09" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/ko.json b/homeassistant/components/deconz/.translations/ko.json index d6de1028218dee..a584a1db9b50e2 100644 --- a/homeassistant/components/deconz/.translations/ko.json +++ b/homeassistant/components/deconz/.translations/ko.json @@ -18,9 +18,16 @@ }, "link": { "description": "deCONZ \uac8c\uc774\ud2b8\uc6e8\uc774\ub97c \uc5b8\ub77d\ud558\uc5ec Home Assistant \uc5d0 \uc5f0\uacb0\ud558\uae30\n\n1. deCONZ \uc2dc\uc2a4\ud15c \uc124\uc815\uc73c\ub85c \uc774\ub3d9\ud558\uc138\uc694\n2. \"Unlock Gateway\" \ubc84\ud2bc\uc744 \ub204\ub974\uc138\uc694 ", - "title": "deCONZ \uc640 \uc5f0\uacb0" + "title": "deCONZ\uc640 \uc5f0\uacb0" + }, + "options": { + "data": { + "allow_clip_sensor": "\uac00\uc0c1 \uc13c\uc11c \uac00\uc838\uc624\uae30 \ud5c8\uc6a9", + "allow_deconz_groups": "deCONZ \uadf8\ub8f9 \uac00\uc838\uc624\uae30 \ud5c8\uc6a9" + }, + "title": "deCONZ\ub97c \uc704\ud55c \ucd94\uac00 \uad6c\uc131 \uc635\uc158" } }, - "title": "deCONZ" + "title": "deCONZ Zigbee \uac8c\uc774\ud2b8\uc6e8\uc774" } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/lb.json b/homeassistant/components/deconz/.translations/lb.json index 2a9dfc5e5438dd..3de7de9ddb3e6e 100644 --- a/homeassistant/components/deconz/.translations/lb.json +++ b/homeassistant/components/deconz/.translations/lb.json @@ -19,6 +19,13 @@ "link": { "description": "Entsperrt \u00e4r deCONZ gateway fir se mat Home Assistant ze registr\u00e9ieren.\n\n1. Gidd op\u00a0deCONZ System Astellungen\n2. Dr\u00e9ckt \"Unlock\" Gateway Kn\u00e4ppchen", "title": "Link mat deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Erlaabt den Import vun virtuellen Sensoren", + "allow_deconz_groups": "Erlaabt den Import vun deCONZ Gruppen" + }, + "title": "Extra Konfiguratiouns Optiounen fir deCONZ" } }, "title": "deCONZ" diff --git a/homeassistant/components/deconz/.translations/nl.json b/homeassistant/components/deconz/.translations/nl.json index 90d13bb39b470d..6f3fa2ec9a4c01 100644 --- a/homeassistant/components/deconz/.translations/nl.json +++ b/homeassistant/components/deconz/.translations/nl.json @@ -19,6 +19,13 @@ "link": { "description": "Ontgrendel je deCONZ gateway om te registreren met Home Assistant.\n\n1. Ga naar deCONZ systeeminstellingen\n2. Druk op de knop \"Gateway ontgrendelen\"", "title": "Koppel met deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Sta het importeren van virtuele sensoren toe", + "allow_deconz_groups": "Sta de import van deCONZ-groepen toe" + }, + "title": "Extra configuratieopties voor deCONZ" } }, "title": "deCONZ" diff --git a/homeassistant/components/deconz/.translations/no.json b/homeassistant/components/deconz/.translations/no.json index 25e3b0b7d68c40..55518b7da532ae 100644 --- a/homeassistant/components/deconz/.translations/no.json +++ b/homeassistant/components/deconz/.translations/no.json @@ -19,6 +19,13 @@ "link": { "description": "L\u00e5s opp deCONZ-gatewayen din for \u00e5 registrere deg med Home Assistant. \n\n 1. G\u00e5 til deCONZ-systeminnstillinger \n 2. Trykk p\u00e5 \"L\u00e5s opp gateway\" knappen", "title": "Koble til deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Tillat import av virtuelle sensorer", + "allow_deconz_groups": "Tillat import av deCONZ grupper" + }, + "title": "Ekstra konfigurasjonsalternativer for deCONZ" } }, "title": "deCONZ" diff --git a/homeassistant/components/deconz/.translations/pl.json b/homeassistant/components/deconz/.translations/pl.json index bb7488fcbec1e9..5dd87d9e46214a 100644 --- a/homeassistant/components/deconz/.translations/pl.json +++ b/homeassistant/components/deconz/.translations/pl.json @@ -19,6 +19,13 @@ "link": { "description": "Odblokuj bramk\u0119 deCONZ, aby zarejestrowa\u0107 j\u0105 w Home Assistant. \n\n 1. Przejd\u017a do ustawie\u0144 systemu deCONZ \n 2. Naci\u015bnij przycisk \"Odblokuj bramk\u0119\"", "title": "Po\u0142\u0105cz z deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Zezwalaj na importowanie wirtualnych sensor\u00f3w", + "allow_deconz_groups": "Zezw\u00f3l na importowanie grup deCONZ" + }, + "title": "Dodatkowe opcje konfiguracji dla deCONZ" } }, "title": "deCONZ" diff --git a/homeassistant/components/deconz/.translations/pt-BR.json b/homeassistant/components/deconz/.translations/pt-BR.json new file mode 100644 index 00000000000000..be79e7e461ae0b --- /dev/null +++ b/homeassistant/components/deconz/.translations/pt-BR.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "A ponte j\u00e1 est\u00e1 configurada", + "no_bridges": "N\u00e3o h\u00e1 pontes de deCONZ descobertas", + "one_instance_only": "Componente suporta apenas uma inst\u00e2ncia deCONZ" + }, + "error": { + "no_key": "N\u00e3o foi poss\u00edvel obter uma chave de API" + }, + "step": { + "init": { + "data": { + "host": "Hospedeiro", + "port": "Porta (valor padr\u00e3o: '80')" + }, + "title": "Defina o gateway deCONZ" + }, + "link": { + "description": "Desbloqueie o seu gateway deCONZ para se registar no Home Assistant. \n\n 1. V\u00e1 para as configura\u00e7\u00f5es do sistema deCONZ \n 2. Pressione o bot\u00e3o \"Desbloquear Gateway\"", + "title": "Linkar com deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Permitir a importa\u00e7\u00e3o de sensores virtuais", + "allow_deconz_groups": "Permitir a importa\u00e7\u00e3o de grupos deCONZ" + }, + "title": "Op\u00e7\u00f5es extras de configura\u00e7\u00e3o para deCONZ" + } + }, + "title": "Gateway deCONZ Zigbee" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/pt.json b/homeassistant/components/deconz/.translations/pt.json index 2a00c69869140e..1f7b8209089e6b 100644 --- a/homeassistant/components/deconz/.translations/pt.json +++ b/homeassistant/components/deconz/.translations/pt.json @@ -1,7 +1,33 @@ { "config": { "abort": { - "already_configured": "Bridge j\u00e1 est\u00e1 configurada" - } + "already_configured": "Bridge j\u00e1 est\u00e1 configurada", + "no_bridges": "Nenhum deCONZ descoberto", + "one_instance_only": "Componente suporta apenas uma conex\u00e3o deCONZ" + }, + "error": { + "no_key": "N\u00e3o foi poss\u00edvel obter uma chave de API" + }, + "step": { + "init": { + "data": { + "host": "Servidor", + "port": "Porta (por omiss\u00e3o: '80')" + }, + "title": "Defina o gateway deCONZ" + }, + "link": { + "description": "Desbloqueie o seu gateway deCONZ para se registar no Home Assistant. \n\n 1. V\u00e1 para as configura\u00e7\u00f5es do sistema deCONZ \n 2. Pressione o bot\u00e3o \"Desbloquear Gateway\"", + "title": "Link com deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Permitir a importa\u00e7\u00e3o de sensores virtuais", + "allow_deconz_groups": "Permitir a importa\u00e7\u00e3o de grupos deCONZ" + }, + "title": "Op\u00e7\u00f5es extra de configura\u00e7\u00e3o para deCONZ" + } + }, + "title": "deCONZ" } } \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/ru.json b/homeassistant/components/deconz/.translations/ru.json index b0dc6a8a4a85f4..56490f67cb3dc6 100644 --- a/homeassistant/components/deconz/.translations/ru.json +++ b/homeassistant/components/deconz/.translations/ru.json @@ -19,6 +19,13 @@ "link": { "description": "\u0420\u0430\u0437\u0431\u043b\u043e\u043a\u0438\u0440\u0443\u0439\u0442\u0435 \u0448\u043b\u044e\u0437 deCONZ \u0434\u043b\u044f \u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0430\u0446\u0438\u0438 \u0432 Home Assistant:\n\n1. \u041f\u0435\u0440\u0435\u0439\u0434\u0438\u0442\u0435 \u043a \u043d\u0430\u0441\u0442\u0440\u043e\u0439\u043a\u0430\u043c \u0441\u0438\u0441\u0442\u0435\u043c\u044b deCONZ\n2. \u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u043a\u043d\u043e\u043f\u043a\u0443 \u00ab\u0420\u0430\u0437\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u0442\u044c \u0448\u043b\u044e\u0437\u00bb", "title": "\u0421\u0432\u044f\u0437\u044c \u0441 deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0438\u043c\u043f\u043e\u0440\u0442 \u0432\u0438\u0440\u0442\u0443\u0430\u043b\u044c\u043d\u044b\u0445 \u0434\u0430\u0442\u0447\u0438\u043a\u043e\u0432", + "allow_deconz_groups": "\u0420\u0430\u0437\u0440\u0435\u0448\u0438\u0442\u044c \u0438\u043c\u043f\u043e\u0440\u0442 \u0433\u0440\u0443\u043f\u043f deCONZ" + }, + "title": "\u0414\u043e\u043f\u043e\u043b\u043d\u0438\u0442\u0435\u043b\u044c\u043d\u044b\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u044b \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0434\u043b\u044f deCONZ" } }, "title": "deCONZ" diff --git a/homeassistant/components/deconz/.translations/sl.json b/homeassistant/components/deconz/.translations/sl.json index b738002b273d64..bc7a2cbd861583 100644 --- a/homeassistant/components/deconz/.translations/sl.json +++ b/homeassistant/components/deconz/.translations/sl.json @@ -19,6 +19,13 @@ "link": { "description": "Odklenite va\u0161 deCONZ gateway za registracijo z Home Assistant-om. \n1. Pojdite v deCONT sistemske nastavitve\n2. Pritisnite tipko \"odkleni prehod\"", "title": "Povezava z deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Dovoli uvoz virtualnih senzorjev", + "allow_deconz_groups": "Dovoli uvoz deCONZ skupin" + }, + "title": "Dodatne mo\u017enosti konfiguracije za deCONZ" } }, "title": "deCONZ" diff --git a/homeassistant/components/deconz/.translations/sv.json b/homeassistant/components/deconz/.translations/sv.json new file mode 100644 index 00000000000000..88cf8742acde8c --- /dev/null +++ b/homeassistant/components/deconz/.translations/sv.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Bryggan \u00e4r redan konfigurerad", + "no_bridges": "Inga deCONZ-bryggor uppt\u00e4cktes", + "one_instance_only": "Komponenten st\u00f6djer endast en deCONZ-instans" + }, + "error": { + "no_key": "Det gick inte att ta emot en API-nyckel" + }, + "step": { + "init": { + "data": { + "host": "V\u00e4rd", + "port": "Port (standardv\u00e4rde: '80')" + }, + "title": "Definiera deCONZ-gatewaye" + }, + "link": { + "description": "L\u00e5s upp din deCONZ-gateway f\u00f6r att registrera dig med Home Assistant. \n\n 1. G\u00e5 till deCONZ-systeminst\u00e4llningarna \n 2. Tryck p\u00e5 \"L\u00e5s upp gateway\"-knappen", + "title": "L\u00e4nka med deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "Till\u00e5t import av virtuella sensorer", + "allow_deconz_groups": "Till\u00e5t import av deCONZ-grupper" + }, + "title": "Extra konfigurationsalternativ f\u00f6r deCONZ" + } + }, + "title": "deCONZ" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/vi.json b/homeassistant/components/deconz/.translations/vi.json new file mode 100644 index 00000000000000..00f1d9be57f07e --- /dev/null +++ b/homeassistant/components/deconz/.translations/vi.json @@ -0,0 +1,26 @@ +{ + "config": { + "abort": { + "already_configured": "C\u1ea7u \u0111\u00e3 \u0111\u01b0\u1ee3c c\u1ea5u h\u00ecnh", + "no_bridges": "Kh\u00f4ng t\u00ecm th\u1ea5y c\u1ea7u deCONZ n\u00e0o", + "one_instance_only": "Th\u00e0nh ph\u1ea7n ch\u1ec9 h\u1ed7 tr\u1ee3 m\u1ed9t c\u00e1 th\u1ec3 deCONZ" + }, + "error": { + "no_key": "Kh\u00f4ng th\u1ec3 l\u1ea5y kh\u00f3a API" + }, + "step": { + "init": { + "data": { + "port": "C\u1ed5ng (gi\u00e1 tr\u1ecb m\u1eb7c \u0111\u1ecbnh: '80')" + } + }, + "options": { + "data": { + "allow_clip_sensor": "Cho ph\u00e9p nh\u1eadp c\u1ea3m bi\u1ebfn \u1ea3o", + "allow_deconz_groups": "Cho ph\u00e9p nh\u1eadp c\u00e1c nh\u00f3m deCONZ" + }, + "title": "T\u00f9y ch\u1ecdn c\u1ea5u h\u00ecnh b\u1ed5 sung cho deCONZ" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/zh-Hans.json b/homeassistant/components/deconz/.translations/zh-Hans.json index f41b5b5111c2be..2e5a216c77ddec 100644 --- a/homeassistant/components/deconz/.translations/zh-Hans.json +++ b/homeassistant/components/deconz/.translations/zh-Hans.json @@ -19,6 +19,13 @@ "link": { "description": "\u89e3\u9501\u60a8\u7684 deCONZ \u7f51\u5173\u4ee5\u6ce8\u518c\u5230 Home Assistant\u3002 \n\n 1. \u524d\u5f80 deCONZ \u7cfb\u7edf\u8bbe\u7f6e\n 2. \u70b9\u51fb\u201c\u89e3\u9501\u7f51\u5173\u201d\u6309\u94ae", "title": "\u8fde\u63a5 deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "\u5141\u8bb8\u5bfc\u5165\u865a\u62df\u4f20\u611f\u5668", + "allow_deconz_groups": "\u5141\u8bb8\u5bfc\u5165 deCONZ \u7fa4\u7ec4" + }, + "title": "deCONZ \u7684\u9644\u52a0\u914d\u7f6e\u9879" } }, "title": "deCONZ" diff --git a/homeassistant/components/deconz/.translations/zh-Hant.json b/homeassistant/components/deconz/.translations/zh-Hant.json index 33be3846eb8290..5cd1a14d499bd3 100644 --- a/homeassistant/components/deconz/.translations/zh-Hant.json +++ b/homeassistant/components/deconz/.translations/zh-Hant.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "already_configured": "Bridge \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", "no_bridges": "\u672a\u641c\u5c0b\u5230 deCONZ Bridfe", "one_instance_only": "\u7d44\u4ef6\u50c5\u652f\u63f4\u4e00\u7d44 deCONZ \u5be6\u4f8b" }, @@ -18,6 +19,13 @@ "link": { "description": "\u89e3\u9664 deCONZ \u7db2\u95dc\u9396\u5b9a\uff0c\u4ee5\u65bc Home Assistant \u9032\u884c\u8a3b\u518a\u3002\n\n1. \u9032\u5165 deCONZ \u7cfb\u7d71\u8a2d\u5b9a\n2. \u6309\u4e0b\u300c\u89e3\u9664\u7db2\u95dc\u9396\u5b9a\uff08Unlock Gateway\uff09\u300d\u6309\u9215", "title": "\u9023\u7d50\u81f3 deCONZ" + }, + "options": { + "data": { + "allow_clip_sensor": "\u5141\u8a31\u532f\u5165\u865b\u64ec\u611f\u61c9\u5668", + "allow_deconz_groups": "\u5141\u8a31\u532f\u5165 deCONZ \u7fa4\u7d44" + }, + "title": "deCONZ \u9644\u52a0\u8a2d\u5b9a\u9078\u9805" } }, "title": "deCONZ" diff --git a/homeassistant/components/deconz/__init__.py b/homeassistant/components/deconz/__init__.py index 47573be6add304..eacfe22e818c01 100644 --- a/homeassistant/components/deconz/__init__.py +++ b/homeassistant/components/deconz/__init__.py @@ -6,6 +6,7 @@ """ import voluptuous as vol +from homeassistant import config_entries from homeassistant.const import ( CONF_API_KEY, CONF_EVENT, CONF_HOST, CONF_ID, CONF_PORT, EVENT_HOMEASSISTANT_STOP) @@ -19,10 +20,10 @@ # Loading the config flow file will register the flow from .config_flow import configured_hosts from .const import ( - CONFIG_FILE, DATA_DECONZ_EVENT, DATA_DECONZ_ID, - DATA_DECONZ_UNSUB, DOMAIN, _LOGGER) + CONF_ALLOW_CLIP_SENSOR, CONFIG_FILE, DATA_DECONZ_EVENT, + DATA_DECONZ_ID, DATA_DECONZ_UNSUB, DOMAIN, _LOGGER) -REQUIREMENTS = ['pydeconz==37'] +REQUIREMENTS = ['pydeconz==43'] CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -60,7 +61,9 @@ async def async_setup(hass, config): deconz_config = config[DOMAIN] if deconz_config and not configured_hosts(hass): hass.async_add_job(hass.config_entries.flow.async_init( - DOMAIN, source='import', data=deconz_config + DOMAIN, + context={'source': config_entries.SOURCE_IMPORT}, + data=deconz_config )) return True @@ -96,16 +99,18 @@ def async_add_device_callback(device_type, device): hass.data[DATA_DECONZ_EVENT] = [] hass.data[DATA_DECONZ_UNSUB] = [] - for component in ['binary_sensor', 'light', 'scene', 'sensor']: - hass.async_add_job(hass.config_entries.async_forward_entry_setup( + for component in ['binary_sensor', 'light', 'scene', 'sensor', 'switch']: + hass.async_create_task(hass.config_entries.async_forward_entry_setup( config_entry, component)) @callback def async_add_remote(sensors): """Setup remote from deCONZ.""" from pydeconz.sensor import SWITCH as DECONZ_REMOTE + allow_clip_sensor = config_entry.data.get(CONF_ALLOW_CLIP_SENSOR, True) for sensor in sensors: - if sensor.type in DECONZ_REMOTE: + if sensor.type in DECONZ_REMOTE and \ + not (not allow_clip_sensor and sensor.type.startswith('CLIP')): hass.data[DATA_DECONZ_EVENT].append(DeconzEvent(hass, sensor)) hass.data[DATA_DECONZ_UNSUB].append( async_dispatcher_connect(hass, 'deconz_new_sensor', async_add_remote)) @@ -176,7 +181,7 @@ async def async_unload_entry(hass, config_entry): return True -class DeconzEvent(object): +class DeconzEvent: """When you want signals instead of entities. Stateless sensors such as remotes are expected to generate an event diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index e900782ea658d8..fb2eb54232a1c7 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -8,13 +8,17 @@ from homeassistant.helpers import aiohttp_client from homeassistant.util.json import load_json -from .const import CONFIG_FILE, DOMAIN +from .const import ( + CONF_ALLOW_DECONZ_GROUPS, CONF_ALLOW_CLIP_SENSOR, CONFIG_FILE, DOMAIN) + + +CONF_BRIDGEID = 'bridgeid' @callback def configured_hosts(hass): """Return a set of the configured hosts.""" - return set(entry.data['host'] for entry + return set(entry.data[CONF_HOST] for entry in hass.config_entries.async_entries(DOMAIN)) @@ -29,8 +33,17 @@ def __init__(self): self.bridges = [] self.deconz_config = {} + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + return await self.async_step_init(user_input) + async def async_step_init(self, user_input=None): - """Handle a deCONZ config flow start.""" + """Handle a deCONZ config flow start. + + Only allows one instance to be set up. + If only one bridge is found go to link step. + If more than one bridge is found let user choose bridge to link. + """ from pydeconz.utils import async_discovery if configured_hosts(self.hass): @@ -48,7 +61,7 @@ async def async_step_init(self, user_input=None): if len(self.bridges) == 1: self.deconz_config = self.bridges[0] return await self.async_step_link() - elif len(self.bridges) > 1: + if len(self.bridges) > 1: hosts = [] for bridge in self.bridges: hosts.append(bridge[CONF_HOST]) @@ -65,7 +78,7 @@ async def async_step_init(self, user_input=None): async def async_step_link(self, user_input=None): """Attempt to link with the deCONZ bridge.""" - from pydeconz.utils import async_get_api_key, async_get_bridgeid + from pydeconz.utils import async_get_api_key errors = {} if user_input is not None: @@ -75,13 +88,7 @@ async def async_step_link(self, user_input=None): api_key = await async_get_api_key(session, **self.deconz_config) if api_key: self.deconz_config[CONF_API_KEY] = api_key - if 'bridgeid' not in self.deconz_config: - self.deconz_config['bridgeid'] = await async_get_bridgeid( - session, **self.deconz_config) - return self.async_create_entry( - title='deCONZ-' + self.deconz_config['bridgeid'], - data=self.deconz_config - ) + return await self.async_step_options() errors['base'] = 'no_key' return self.async_show_form( @@ -89,6 +96,38 @@ async def async_step_link(self, user_input=None): errors=errors, ) + async def async_step_options(self, user_input=None): + """Extra options for deCONZ. + + CONF_CLIP_SENSOR -- Allow user to choose if they want clip sensors. + CONF_DECONZ_GROUPS -- Allow user to choose if they want deCONZ groups. + """ + from pydeconz.utils import async_get_bridgeid + + if user_input is not None: + self.deconz_config[CONF_ALLOW_CLIP_SENSOR] = \ + user_input[CONF_ALLOW_CLIP_SENSOR] + self.deconz_config[CONF_ALLOW_DECONZ_GROUPS] = \ + user_input[CONF_ALLOW_DECONZ_GROUPS] + + if CONF_BRIDGEID not in self.deconz_config: + session = aiohttp_client.async_get_clientsession(self.hass) + self.deconz_config[CONF_BRIDGEID] = await async_get_bridgeid( + session, **self.deconz_config) + + return self.async_create_entry( + title='deCONZ-' + self.deconz_config[CONF_BRIDGEID], + data=self.deconz_config + ) + + return self.async_show_form( + step_id='options', + data_schema=vol.Schema({ + vol.Optional(CONF_ALLOW_CLIP_SENSOR): bool, + vol.Optional(CONF_ALLOW_DECONZ_GROUPS): bool, + }), + ) + async def async_step_discovery(self, discovery_info): """Prepare configuration for a discovered deCONZ bridge. @@ -97,7 +136,7 @@ async def async_step_discovery(self, discovery_info): deconz_config = {} deconz_config[CONF_HOST] = discovery_info.get(CONF_HOST) deconz_config[CONF_PORT] = discovery_info.get(CONF_PORT) - deconz_config['bridgeid'] = discovery_info.get('serial') + deconz_config[CONF_BRIDGEID] = discovery_info.get('serial') config_file = await self.hass.async_add_job( load_json, self.hass.config.path(CONFIG_FILE)) @@ -121,19 +160,13 @@ async def async_step_import(self, import_config): Otherwise we will delegate to `link` step which will ask user to link the bridge. """ - from pydeconz.utils import async_get_bridgeid - if configured_hosts(self.hass): return self.async_abort(reason='one_instance_only') - elif CONF_API_KEY not in import_config: - self.deconz_config = import_config + + self.deconz_config = import_config + if CONF_API_KEY not in import_config: return await self.async_step_link() - if 'bridgeid' not in import_config: - session = aiohttp_client.async_get_clientsession(self.hass) - import_config['bridgeid'] = await async_get_bridgeid( - session, **import_config) - return self.async_create_entry( - title='deCONZ-' + import_config['bridgeid'], - data=import_config - ) + user_input = {CONF_ALLOW_CLIP_SENSOR: True, + CONF_ALLOW_DECONZ_GROUPS: True} + return await self.async_step_options(user_input=user_input) diff --git a/homeassistant/components/deconz/const.py b/homeassistant/components/deconz/const.py index 48e5ea75d684c3..e7bc5605aee400 100644 --- a/homeassistant/components/deconz/const.py +++ b/homeassistant/components/deconz/const.py @@ -8,3 +8,13 @@ DATA_DECONZ_EVENT = 'deconz_events' DATA_DECONZ_ID = 'deconz_entities' DATA_DECONZ_UNSUB = 'deconz_dispatchers' + +CONF_ALLOW_CLIP_SENSOR = 'allow_clip_sensor' +CONF_ALLOW_DECONZ_GROUPS = 'allow_deconz_groups' + +ATTR_DARK = 'dark' +ATTR_ON = 'on' + +POWER_PLUGS = ["On/Off plug-in unit", "Smart plug"] +SIRENS = ["Warning device"] +SWITCH_TYPES = POWER_PLUGS + SIRENS diff --git a/homeassistant/components/deconz/strings.json b/homeassistant/components/deconz/strings.json index 7ea68af01c1dc4..09549a300a0d3f 100644 --- a/homeassistant/components/deconz/strings.json +++ b/homeassistant/components/deconz/strings.json @@ -1,6 +1,6 @@ { "config": { - "title": "deCONZ", + "title": "deCONZ Zigbee gateway", "step": { "init": { "title": "Define deCONZ gateway", @@ -12,6 +12,13 @@ "link": { "title": "Link with deCONZ", "description": "Unlock your deCONZ gateway to register with Home Assistant.\n\n1. Go to deCONZ system settings\n2. Press \"Unlock Gateway\" button" + }, + "options": { + "title": "Extra configuration options for deCONZ", + "data":{ + "allow_clip_sensor": "Allow importing virtual sensors", + "allow_deconz_groups": "Allow importing deCONZ groups" + } } }, "error": { diff --git a/homeassistant/components/demo.py b/homeassistant/components/demo.py index 64ce3cda073019..c2c7866148ff5d 100644 --- a/homeassistant/components/demo.py +++ b/homeassistant/components/demo.py @@ -7,7 +7,7 @@ import asyncio import time -import homeassistant.bootstrap as bootstrap +from homeassistant import bootstrap import homeassistant.core as ha from homeassistant.const import ATTR_ENTITY_ID, CONF_PLATFORM diff --git a/homeassistant/components/device_tracker/__init__.py b/homeassistant/components/device_tracker/__init__.py index e1dd52a28ea9eb..74cb0a77fef51d 100644 --- a/homeassistant/components/device_tracker/__init__.py +++ b/homeassistant/components/device_tracker/__init__.py @@ -24,7 +24,7 @@ from homeassistant.helpers.restore_state import async_get_last_state from homeassistant.helpers.typing import GPSType, ConfigType, HomeAssistantType import homeassistant.helpers.config_validation as cv -import homeassistant.util as util +from homeassistant import util from homeassistant.util.async_ import run_coroutine_threadsafe import homeassistant.util.dt as dt_util from homeassistant.util.yaml import dump @@ -33,7 +33,7 @@ from homeassistant.const import ( ATTR_GPS_ACCURACY, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_NAME, CONF_MAC, DEVICE_DEFAULT_NAME, STATE_HOME, STATE_NOT_HOME, ATTR_ENTITY_ID, - CONF_ICON, ATTR_ICON) + CONF_ICON, ATTR_ICON, ATTR_NAME) _LOGGER = logging.getLogger(__name__) @@ -71,7 +71,6 @@ ATTR_HOST_NAME = 'host_name' ATTR_LOCATION_NAME = 'location_name' ATTR_MAC = 'mac' -ATTR_NAME = 'name' ATTR_SOURCE_TYPE = 'source_type' ATTR_CONSIDER_HOME = 'consider_home' @@ -232,7 +231,7 @@ def async_see_service(call): return True -class DeviceTracker(object): +class DeviceTracker: """Representation of a device tracker.""" def __init__(self, hass: HomeAssistantType, consider_home: timedelta, @@ -538,7 +537,7 @@ def async_update(self): """ if not self.last_seen: return - elif self.location_name: + if self.location_name: self._state = self.location_name elif self.gps is not None and self.source_type == SOURCE_TYPE_GPS: zone_state = async_active_zone( @@ -578,7 +577,7 @@ def async_added_to_hass(self): state.attributes[ATTR_LONGITUDE]) -class DeviceScanner(object): +class DeviceScanner: """Device scanner object.""" hass = None # type: HomeAssistantType diff --git a/homeassistant/components/device_tracker/actiontec.py b/homeassistant/components/device_tracker/actiontec.py index 781e486a40e83f..72d9992c60f3e2 100644 --- a/homeassistant/components/device_tracker/actiontec.py +++ b/homeassistant/components/device_tracker/actiontec.py @@ -31,7 +31,6 @@ }) -# pylint: disable=unused-argument def get_scanner(hass, config): """Validate the configuration and return an Actiontec scanner.""" scanner = ActiontecDeviceScanner(config[DOMAIN]) diff --git a/homeassistant/components/device_tracker/aruba.py b/homeassistant/components/device_tracker/aruba.py index 79d8806fe22bb1..142842b12d2884 100644 --- a/homeassistant/components/device_tracker/aruba.py +++ b/homeassistant/components/device_tracker/aruba.py @@ -16,7 +16,7 @@ _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pexpect==4.0.1'] +REQUIREMENTS = ['pexpect==4.6.0'] _DEVICES_REGEX = re.compile( r'(?P([^\s]+)?)\s+' + @@ -30,7 +30,6 @@ }) -# pylint: disable=unused-argument def get_scanner(hass, config): """Validate the configuration and return a Aruba scanner.""" scanner = ArubaDeviceScanner(config[DOMAIN]) @@ -95,10 +94,10 @@ def get_aruba_data(self): if query == 1: _LOGGER.error("Timeout") return - elif query == 2: + if query == 2: _LOGGER.error("Unexpected response from router") return - elif query == 3: + if query == 3: ssh.sendline('yes') ssh.expect('password:') elif query == 4: diff --git a/homeassistant/components/device_tracker/asuswrt.py b/homeassistant/components/device_tracker/asuswrt.py index 7e9b10e9241aa1..710a07f77d38a8 100644 --- a/homeassistant/components/device_tracker/asuswrt.py +++ b/homeassistant/components/device_tracker/asuswrt.py @@ -19,7 +19,7 @@ CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_MODE, CONF_PROTOCOL) -REQUIREMENTS = ['pexpect==4.0.1'] +REQUIREMENTS = ['pexpect==4.6.0'] _LOGGER = logging.getLogger(__name__) @@ -78,7 +78,6 @@ r'.*') -# pylint: disable=unused-argument def get_scanner(hass, config): """Validate the configuration and return an ASUS-WRT scanner.""" scanner = AsusWrtDeviceScanner(config[DOMAIN]) @@ -312,12 +311,11 @@ def connect(self): super().connect() - def disconnect(self): \ - # pylint: disable=broad-except + def disconnect(self): """Disconnect the current SSH connection.""" try: self._ssh.logout() - except Exception: + except Exception: # pylint: disable=broad-except pass finally: self._ssh = None @@ -380,12 +378,11 @@ def connect(self): super().connect() - def disconnect(self): \ - # pylint: disable=broad-except + def disconnect(self): """Disconnect the current Telnet connection.""" try: self._telnet.write('exit\n'.encode('ascii')) - except Exception: + except Exception: # pylint: disable=broad-except pass super().disconnect() diff --git a/homeassistant/components/device_tracker/automatic.py b/homeassistant/components/device_tracker/automatic.py index 607f236f92052d..4fcc550d7dbd94 100644 --- a/homeassistant/components/device_tracker/automatic.py +++ b/homeassistant/components/device_tracker/automatic.py @@ -193,7 +193,7 @@ def get(self, request): # pylint: disable=no-self-use return response -class AutomaticData(object): +class AutomaticData: """A class representing an Automatic cloud service connection.""" def __init__(self, hass, client, session, devices, async_see): diff --git a/homeassistant/components/device_tracker/bmw_connected_drive.py b/homeassistant/components/device_tracker/bmw_connected_drive.py index f36afc622ee1b5..02a12653180705 100644 --- a/homeassistant/components/device_tracker/bmw_connected_drive.py +++ b/homeassistant/components/device_tracker/bmw_connected_drive.py @@ -27,7 +27,7 @@ def setup_scanner(hass, config, see, discovery_info=None): return True -class BMWDeviceTracker(object): +class BMWDeviceTracker: """BMW Connected Drive device tracker.""" def __init__(self, see, vehicle): diff --git a/homeassistant/components/device_tracker/bt_home_hub_5.py b/homeassistant/components/device_tracker/bt_home_hub_5.py index a3b5bcac77c824..21c41df3a1d2ba 100644 --- a/homeassistant/components/device_tracker/bt_home_hub_5.py +++ b/homeassistant/components/device_tracker/bt_home_hub_5.py @@ -5,28 +5,25 @@ https://home-assistant.io/components/device_tracker.bt_home_hub_5/ """ import logging -import re -import xml.etree.ElementTree as ET -import json -from urllib.parse import unquote -import requests import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.components.device_tracker import ( - DOMAIN, PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.components.device_tracker import (DOMAIN, PLATFORM_SCHEMA, + DeviceScanner) from homeassistant.const import CONF_HOST +REQUIREMENTS = ['bthomehub5-devicelist==0.1.1'] + _LOGGER = logging.getLogger(__name__) -_MAC_REGEX = re.compile(r'(([0-9A-Fa-f]{1,2}\:){5}[0-9A-Fa-f]{1,2})') + +CONF_DEFAULT_IP = '192.168.1.254' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_HOST): cv.string + vol.Optional(CONF_HOST, default=CONF_DEFAULT_IP): cv.string, }) -# pylint: disable=unused-argument def get_scanner(hass, config): """Return a BT Home Hub 5 scanner if successful.""" scanner = BTHomeHub5DeviceScanner(config[DOMAIN]) @@ -39,18 +36,19 @@ class BTHomeHub5DeviceScanner(DeviceScanner): def __init__(self, config): """Initialise the scanner.""" + import bthomehub5_devicelist + _LOGGER.info("Initialising BT Home Hub 5") - self.host = config.get(CONF_HOST, '192.168.1.254') + self.host = config[CONF_HOST] self.last_results = {} - self.url = 'http://{}/nonAuth/home_status.xml'.format(self.host) # Test the router is accessible - data = _get_homehub_data(self.url) + data = bthomehub5_devicelist.get_devicelist(self.host) self.success_init = data is not None def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" - self._update_info() + self.update_info() return (device for device in self.last_results) @@ -58,72 +56,23 @@ def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" # If not initialised and not already scanned and not found. if device not in self.last_results: - self._update_info() + self.update_info() if not self.last_results: return None return self.last_results.get(device) - def _update_info(self): - """Ensure the information from the BT Home Hub 5 is up to date. - - Return boolean if scanning successful. - """ - if not self.success_init: - return False + def update_info(self): + """Ensure the information from the BT Home Hub 5 is up to date.""" + import bthomehub5_devicelist _LOGGER.info("Scanning") - data = _get_homehub_data(self.url) + data = bthomehub5_devicelist.get_devicelist(self.host) if not data: _LOGGER.warning("Error scanning devices") - return False + return self.last_results = data - - return True - - -def _get_homehub_data(url): - """Retrieve data from BT Home Hub 5 and return parsed result.""" - try: - response = requests.get(url, timeout=5) - except requests.exceptions.Timeout: - _LOGGER.exception("Connection to the router timed out") - return - if response.status_code == 200: - return _parse_homehub_response(response.text) - else: - _LOGGER.error("Invalid response from Home Hub: %s", response) - - -def _parse_homehub_response(data_str): - """Parse the BT Home Hub 5 data format.""" - root = ET.fromstring(data_str) - - dirty_json = root.find('known_device_list').get('value') - - # Normalise the JavaScript data to JSON. - clean_json = unquote(dirty_json.replace('\'', '\"') - .replace('{', '{\"') - .replace(':\"', '\":\"') - .replace('\",', '\",\"')) - - known_devices = [x for x in json.loads(clean_json) if x] - - devices = {} - - for device in known_devices: - name = device.get('name') - mac = device.get('mac') - - if _MAC_REGEX.match(mac) or ',' in mac: - for mac_addr in mac.split(','): - if _MAC_REGEX.match(mac_addr): - devices[mac_addr] = name - else: - devices[mac] = name - - return devices diff --git a/homeassistant/components/device_tracker/cisco_ios.py b/homeassistant/components/device_tracker/cisco_ios.py index 0978ba99593e65..1afea2c1607f73 100644 --- a/homeassistant/components/device_tracker/cisco_ios.py +++ b/homeassistant/components/device_tracker/cisco_ios.py @@ -16,7 +16,7 @@ _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pexpect==4.0.1'] +REQUIREMENTS = ['pexpect==4.6.0'] PLATFORM_SCHEMA = vol.All( PLATFORM_SCHEMA.extend({ @@ -50,7 +50,6 @@ def __init__(self, config): self.success_init = self._update_info() _LOGGER.info('cisco_ios scanner initialized') - # pylint: disable=no-self-use def get_device_name(self, device): """Get the firmware doesn't save the name of the wireless device.""" return None diff --git a/homeassistant/components/device_tracker/ddwrt.py b/homeassistant/components/device_tracker/ddwrt.py index 3d36a1b428c024..539d4fde5efea3 100644 --- a/homeassistant/components/device_tracker/ddwrt.py +++ b/homeassistant/components/device_tracker/ddwrt.py @@ -27,7 +27,6 @@ }) -# pylint: disable=unused-argument def get_scanner(hass, config): """Validate the configuration and return a DD-WRT scanner.""" try: @@ -132,13 +131,12 @@ def get_ddwrt_data(self, url): return if response.status_code == 200: return _parse_ddwrt_response(response.text) - elif response.status_code == 401: + if response.status_code == 401: # Authentication error _LOGGER.exception( "Failed to authenticate, check your username and password") return - else: - _LOGGER.error("Invalid response from DD-WRT: %s", response) + _LOGGER.error("Invalid response from DD-WRT: %s", response) def _parse_ddwrt_response(data_str): diff --git a/homeassistant/components/device_tracker/freebox.py b/homeassistant/components/device_tracker/freebox.py new file mode 100644 index 00000000000000..2cac81fd405d91 --- /dev/null +++ b/homeassistant/components/device_tracker/freebox.py @@ -0,0 +1,120 @@ +""" +Support for device tracking through Freebox routers. + +This tracker keeps track of the devices connected to the configured Freebox. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.freebox/ +""" +import asyncio +import copy +import logging +import socket +from collections import namedtuple +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.components.device_tracker import ( + PLATFORM_SCHEMA, CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) +from homeassistant.const import ( + CONF_HOST, CONF_PORT) + +REQUIREMENTS = ['aiofreepybox==0.0.4'] + +_LOGGER = logging.getLogger(__name__) + +FREEBOX_CONFIG_FILE = 'freebox.conf' + +PLATFORM_SCHEMA = vol.All( + PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORT): cv.port + })) + +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) + + +async def async_setup_scanner(hass, config, async_see, discovery_info=None): + """Set up the Freebox device tracker and start the polling.""" + freebox_config = copy.deepcopy(config) + if discovery_info is not None: + freebox_config[CONF_HOST] = discovery_info['properties']['api_domain'] + freebox_config[CONF_PORT] = discovery_info['properties']['https_port'] + _LOGGER.info("Discovered Freebox server: %s:%s", + freebox_config[CONF_HOST], freebox_config[CONF_PORT]) + + scanner = FreeboxDeviceScanner(hass, freebox_config, async_see) + interval = freebox_config.get(CONF_SCAN_INTERVAL, DEFAULT_SCAN_INTERVAL) + await scanner.async_start(hass, interval) + return True + + +Device = namedtuple('Device', ['id', 'name', 'ip']) + + +def _build_device(device_dict): + return Device( + device_dict['l2ident']['id'], + device_dict['primary_name'], + device_dict['l3connectivities'][0]['addr']) + + +class FreeboxDeviceScanner: + """This class scans for devices connected to the Freebox.""" + + def __init__(self, hass, config, async_see): + """Initialize the scanner.""" + from aiofreepybox import Freepybox + + self.host = config[CONF_HOST] + self.port = config[CONF_PORT] + self.token_file = hass.config.path(FREEBOX_CONFIG_FILE) + self.async_see = async_see + + # Hardcode the app description to avoid invalidating the authentication + # file at each new version. + # The version can be changed if we want the user to re-authorize HASS + # on her Freebox. + app_desc = { + 'app_id': 'hass', + 'app_name': 'Home Assistant', + 'app_version': '0.65', + 'device_name': socket.gethostname() + } + + api_version = 'v1' # Use the lowest working version. + self.fbx = Freepybox( + app_desc=app_desc, + token_file=self.token_file, + api_version=api_version) + + async def async_start(self, hass, interval): + """Perform a first update and start polling at the given interval.""" + await self.async_update_info() + interval = max(interval, MIN_TIME_BETWEEN_SCANS) + async_track_time_interval(hass, self.async_update_info, interval) + + async def async_update_info(self, now=None): + """Check the Freebox for devices.""" + from aiofreepybox.exceptions import HttpRequestError + + _LOGGER.info('Scanning devices') + + await self.fbx.open(self.host, self.port) + try: + hosts = await self.fbx.lan.get_hosts_list() + except HttpRequestError: + _LOGGER.exception('Failed to scan devices') + else: + active_devices = [_build_device(device) + for device in hosts + if device['active']] + + if active_devices: + await asyncio.wait([self.async_see(mac=d.id, host_name=d.name) + for d in active_devices]) + + await self.fbx.close() diff --git a/homeassistant/components/device_tracker/geofency.py b/homeassistant/components/device_tracker/geofency.py index adb5c6f6d28dfa..7231c5127befec 100644 --- a/homeassistant/components/device_tracker/geofency.py +++ b/homeassistant/components/device_tracker/geofency.py @@ -70,16 +70,15 @@ def post(self, request): if self._is_mobile_beacon(data): return (yield from self._set_location(hass, data, None)) + if data['entry'] == LOCATION_ENTRY: + location_name = data['name'] else: - if data['entry'] == LOCATION_ENTRY: - location_name = data['name'] - else: - location_name = STATE_NOT_HOME - if ATTR_CURRENT_LATITUDE in data: - data[ATTR_LATITUDE] = data[ATTR_CURRENT_LATITUDE] - data[ATTR_LONGITUDE] = data[ATTR_CURRENT_LONGITUDE] - - return (yield from self._set_location(hass, data, location_name)) + location_name = STATE_NOT_HOME + if ATTR_CURRENT_LATITUDE in data: + data[ATTR_LATITUDE] = data[ATTR_CURRENT_LATITUDE] + data[ATTR_LONGITUDE] = data[ATTR_CURRENT_LONGITUDE] + + return (yield from self._set_location(hass, data, location_name)) @staticmethod def _validate_data(data): diff --git a/homeassistant/components/device_tracker/google_maps.py b/homeassistant/components/device_tracker/google_maps.py index 1d0058ed22984d..8c21e71bd3097a 100644 --- a/homeassistant/components/device_tracker/google_maps.py +++ b/homeassistant/components/device_tracker/google_maps.py @@ -4,40 +4,48 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.google_maps/ """ -import logging from datetime import timedelta +import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( PLATFORM_SCHEMA, SOURCE_TYPE_GPS) -from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +from homeassistant.const import ATTR_ID, CONF_PASSWORD, CONF_USERNAME +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import track_time_interval from homeassistant.helpers.typing import ConfigType from homeassistant.util import slugify +REQUIREMENTS = ['locationsharinglib==2.0.11'] + _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['locationsharinglib==1.2.2'] +ATTR_ADDRESS = 'address' +ATTR_FULL_NAME = 'full_name' +ATTR_LAST_SEEN = 'last_seen' +ATTR_NICKNAME = 'nickname' + +CONF_MAX_GPS_ACCURACY = 'max_gps_accuracy' CREDENTIALS_FILE = '.google_maps_location_sharing.cookies' MIN_TIME_BETWEEN_SCANS = timedelta(seconds=30) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_MAX_GPS_ACCURACY, default=100000): vol.Coerce(float), }) def setup_scanner(hass, config: ConfigType, see, discovery_info=None): - """Set up the scanner.""" + """Set up the Google Maps Location sharing scanner.""" scanner = GoogleMapsScanner(hass, config, see) return scanner.success_init -class GoogleMapsScanner(object): +class GoogleMapsScanner: """Representation of an Google Maps location sharing account.""" def __init__(self, hass, config: ConfigType, see) -> None: @@ -48,6 +56,7 @@ def __init__(self, hass, config: ConfigType, see) -> None: self.see = see self.username = config[CONF_USERNAME] self.password = config[CONF_PASSWORD] + self.max_gps_accuracy = config[CONF_MAX_GPS_ACCURACY] try: self.service = Service(self.username, self.password, @@ -60,19 +69,31 @@ def __init__(self, hass, config: ConfigType, see) -> None: self.success_init = True except InvalidUser: - _LOGGER.error('You have specified invalid login credentials') + _LOGGER.error("You have specified invalid login credentials") self.success_init = False def _update_info(self, now=None): for person in self.service.get_all_people(): - dev_id = 'google_maps_{0}'.format(slugify(person.id)) + try: + dev_id = 'google_maps_{0}'.format(slugify(person.id)) + except TypeError: + _LOGGER.warning("No location(s) shared with this account") + return + + if self.max_gps_accuracy is not None and \ + person.accuracy > self.max_gps_accuracy: + _LOGGER.info("Ignoring %s update because expected GPS " + "accuracy %s is not met: %s", + person.nickname, self.max_gps_accuracy, + person.accuracy) + continue attrs = { - 'id': person.id, - 'nickname': person.nickname, - 'full_name': person.full_name, - 'last_seen': person.datetime, - 'address': person.address + ATTR_ADDRESS: person.address, + ATTR_FULL_NAME: person.full_name, + ATTR_ID: person.id, + ATTR_LAST_SEEN: person.datetime, + ATTR_NICKNAME: person.nickname, } self.see( dev_id=dev_id, @@ -80,5 +101,5 @@ def _update_info(self, now=None): picture=person.picture_url, source_type=SOURCE_TYPE_GPS, gps_accuracy=person.accuracy, - attributes=attrs + attributes=attrs, ) diff --git a/homeassistant/components/device_tracker/gpslogger.py b/homeassistant/components/device_tracker/gpslogger.py index 68ea9ac88ae819..6336ba51d23dd8 100644 --- a/homeassistant/components/device_tracker/gpslogger.py +++ b/homeassistant/components/device_tracker/gpslogger.py @@ -7,7 +7,7 @@ import logging from hmac import compare_digest -from aiohttp.web import Request, HTTPUnauthorized # NOQA +from aiohttp.web import Request, HTTPUnauthorized import voluptuous as vol import homeassistant.helpers.config_validation as cv diff --git a/homeassistant/components/device_tracker/hitron_coda.py b/homeassistant/components/device_tracker/hitron_coda.py index aa437eeef860e1..72817ca695c85f 100644 --- a/homeassistant/components/device_tracker/hitron_coda.py +++ b/homeassistant/components/device_tracker/hitron_coda.py @@ -14,15 +14,18 @@ from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_USERNAME + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_TYPE ) _LOGGER = logging.getLogger(__name__) +DEFAULT_TYPE = "rogers" + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_TYPE, default=DEFAULT_TYPE): cv.string, }) @@ -49,6 +52,11 @@ def __init__(self, config): self._username = config.get(CONF_USERNAME) self._password = config.get(CONF_PASSWORD) + if config.get(CONF_TYPE) == "shaw": + self._type = 'pwd' + else: + self._type = 'pws' + self._userid = None self.success_init = self._update_info() @@ -74,7 +82,7 @@ def _login(self): try: data = [ ('user', self._username), - ('pws', self._password), + (self._type, self._password), ] res = requests.post(self._loginurl, data=data, timeout=10) except requests.exceptions.Timeout: diff --git a/homeassistant/components/device_tracker/huawei_router.py b/homeassistant/components/device_tracker/huawei_router.py index 775075b8a4aae9..f5e4fa8a7141a5 100644 --- a/homeassistant/components/device_tracker/huawei_router.py +++ b/homeassistant/components/device_tracker/huawei_router.py @@ -26,7 +26,6 @@ }) -# pylint: disable=unused-argument def get_scanner(hass, config): """Validate the configuration and return a HUAWEI scanner.""" scanner = HuaweiDeviceScanner(config[DOMAIN]) @@ -86,8 +85,7 @@ def _update_info(self): active_clients = [client for client in data if client.state] self.last_results = active_clients - # pylint: disable=logging-not-lazy - _LOGGER.debug("Active clients: " + "\n" + _LOGGER.debug("Active clients: %s", "\n" .join((client.mac + " " + client.name) for client in active_clients)) return True diff --git a/homeassistant/components/device_tracker/icloud.py b/homeassistant/components/device_tracker/icloud.py index 5d40f5d533ac6a..8ea81e88440f78 100644 --- a/homeassistant/components/device_tracker/icloud.py +++ b/homeassistant/components/device_tracker/icloud.py @@ -24,8 +24,9 @@ REQUIREMENTS = ['pyicloud==0.9.1'] -CONF_IGNORED_DEVICES = 'ignored_devices' CONF_ACCOUNTNAME = 'account_name' +CONF_MAX_INTERVAL = 'max_interval' +CONF_GPS_ACCURACY_THRESHOLD = 'gps_accuracy_threshold' # entity attributes ATTR_ACCOUNTNAME = 'account_name' @@ -64,13 +65,15 @@ SERVICE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ACCOUNTNAME): vol.All(cv.ensure_list, [cv.slugify]), vol.Optional(ATTR_DEVICENAME): cv.slugify, - vol.Optional(ATTR_INTERVAL): cv.positive_int, + vol.Optional(ATTR_INTERVAL): cv.positive_int }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(ATTR_ACCOUNTNAME): cv.slugify, + vol.Optional(CONF_MAX_INTERVAL, default=30): cv.positive_int, + vol.Optional(CONF_GPS_ACCURACY_THRESHOLD, default=1000): cv.positive_int }) @@ -79,8 +82,11 @@ def setup_scanner(hass, config: dict, see, discovery_info=None): username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) account = config.get(CONF_ACCOUNTNAME, slugify(username.partition('@')[0])) + max_interval = config.get(CONF_MAX_INTERVAL) + gps_accuracy_threshold = config.get(CONF_GPS_ACCURACY_THRESHOLD) - icloudaccount = Icloud(hass, username, password, account, see) + icloudaccount = Icloud(hass, username, password, account, max_interval, + gps_accuracy_threshold, see) if icloudaccount.api is not None: ICLOUDTRACKERS[account] = icloudaccount @@ -96,6 +102,7 @@ def lost_iphone(call): for account in accounts: if account in ICLOUDTRACKERS: ICLOUDTRACKERS[account].lost_iphone(devicename) + hass.services.register(DOMAIN, 'icloud_lost_iphone', lost_iphone, schema=SERVICE_SCHEMA) @@ -106,6 +113,7 @@ def update_icloud(call): for account in accounts: if account in ICLOUDTRACKERS: ICLOUDTRACKERS[account].update_icloud(devicename) + hass.services.register(DOMAIN, 'icloud_update', update_icloud, schema=SERVICE_SCHEMA) @@ -115,6 +123,7 @@ def reset_account_icloud(call): for account in accounts: if account in ICLOUDTRACKERS: ICLOUDTRACKERS[account].reset_account_icloud() + hass.services.register(DOMAIN, 'icloud_reset_account', reset_account_icloud, schema=SERVICE_SCHEMA) @@ -137,7 +146,8 @@ def setinterval(call): class Icloud(DeviceScanner): """Representation of an iCloud account.""" - def __init__(self, hass, username, password, name, see): + def __init__(self, hass, username, password, name, max_interval, + gps_accuracy_threshold, see): """Initialize an iCloud account.""" self.hass = hass self.username = username @@ -148,6 +158,8 @@ def __init__(self, hass, username, password, name, see): self.seen_devices = {} self._overridestates = {} self._intervals = {} + self._max_interval = max_interval + self._gps_accuracy_threshold = gps_accuracy_threshold self.see = see self._trusted_device = None @@ -348,7 +360,7 @@ def determine_interval(self, devicename, latitude, longitude, battery): self._overridestates[devicename] = None if currentzone is not None: - self._intervals[devicename] = 30 + self._intervals[devicename] = self._max_interval return if mindistance is None: @@ -363,7 +375,6 @@ def determine_interval(self, devicename, latitude, longitude, battery): if interval > 180: # Three hour drive? This is far enough that they might be flying - # home - check every half hour interval = 30 if battery is not None and battery <= 33 and mindistance > 3: @@ -403,22 +414,24 @@ def update_device(self, devicename): status = device.status(DEVICESTATUSSET) battery = status.get('batteryLevel', 0) * 100 location = status['location'] - if location: - self.determine_interval( - devicename, location['latitude'], - location['longitude'], battery) - interval = self._intervals.get(devicename, 1) - attrs[ATTR_INTERVAL] = interval - accuracy = location['horizontalAccuracy'] - kwargs['dev_id'] = dev_id - kwargs['host_name'] = status['name'] - kwargs['gps'] = (location['latitude'], - location['longitude']) - kwargs['battery'] = battery - kwargs['gps_accuracy'] = accuracy - kwargs[ATTR_ATTRIBUTES] = attrs - self.see(**kwargs) - self.seen_devices[devicename] = True + if location and location['horizontalAccuracy']: + horizontal_accuracy = int(location['horizontalAccuracy']) + if horizontal_accuracy < self._gps_accuracy_threshold: + self.determine_interval( + devicename, location['latitude'], + location['longitude'], battery) + interval = self._intervals.get(devicename, 1) + attrs[ATTR_INTERVAL] = interval + accuracy = location['horizontalAccuracy'] + kwargs['dev_id'] = dev_id + kwargs['host_name'] = status['name'] + kwargs['gps'] = (location['latitude'], + location['longitude']) + kwargs['battery'] = battery + kwargs['gps_accuracy'] = accuracy + kwargs[ATTR_ATTRIBUTES] = attrs + self.see(**kwargs) + self.seen_devices[devicename] = True except PyiCloudNoDevicesException: _LOGGER.error("No iCloud Devices found") @@ -434,7 +447,7 @@ def lost_iphone(self, devicename): device.play_sound() def update_icloud(self, devicename=None): - """Authenticate against iCloud and scan for devices.""" + """Request device information from iCloud and update device_tracker.""" from pyicloud.exceptions import PyiCloudNoDevicesException if self.api is None: @@ -443,13 +456,13 @@ def update_icloud(self, devicename=None): try: if devicename is not None: if devicename in self.devices: - self.devices[devicename].location() + self.update_device(devicename) else: _LOGGER.error("devicename %s unknown for account %s", devicename, self._attrs[ATTR_ACCOUNTNAME]) else: for device in self.devices: - self.devices[device].location() + self.update_device(device) except PyiCloudNoDevicesException: _LOGGER.error("No iCloud Devices found") diff --git a/homeassistant/components/device_tracker/keenetic_ndms2.py b/homeassistant/components/device_tracker/keenetic_ndms2.py index 36dc1182a9294e..4b5e3d6333d909 100644 --- a/homeassistant/components/device_tracker/keenetic_ndms2.py +++ b/homeassistant/components/device_tracker/keenetic_ndms2.py @@ -5,18 +5,18 @@ https://home-assistant.io/components/device_tracker.keenetic_ndms2/ """ import logging -from collections import namedtuple -import requests import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_USERNAME + CONF_HOST, CONF_PORT, CONF_PASSWORD, CONF_USERNAME ) +REQUIREMENTS = ['ndms2_client==0.0.3'] + _LOGGER = logging.getLogger(__name__) # Interface name to track devices for. Most likely one will not need to @@ -25,11 +25,13 @@ CONF_INTERFACE = 'interface' DEFAULT_INTERFACE = 'Home' +DEFAULT_PORT = 23 PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Required(CONF_PASSWORD): cv.string, vol.Required(CONF_INTERFACE, default=DEFAULT_INTERFACE): cv.string, }) @@ -42,21 +44,22 @@ def get_scanner(_hass, config): return scanner if scanner.success_init else None -Device = namedtuple('Device', ['mac', 'name']) - - class KeeneticNDMS2DeviceScanner(DeviceScanner): """This class scans for devices using keenetic NDMS2 web interface.""" def __init__(self, config): """Initialize the scanner.""" + from ndms2_client import Client, TelnetConnection self.last_results = [] - self._url = 'http://%s/rci/show/ip/arp' % config[CONF_HOST] self._interface = config[CONF_INTERFACE] - self._username = config.get(CONF_USERNAME) - self._password = config.get(CONF_PASSWORD) + self._client = Client(TelnetConnection( + config.get(CONF_HOST), + config.get(CONF_PORT), + config.get(CONF_USERNAME), + config.get(CONF_PASSWORD), + )) self.success_init = self._update_info() _LOGGER.info("Scanner initialized") @@ -69,53 +72,32 @@ def scan_devices(self): def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" - filter_named = [result.name for result in self.last_results - if result.mac == device] - - if filter_named: - return filter_named[0] - return None + name = next(( + result.name for result in self.last_results + if result.mac == device), None) + return name + + def get_extra_attributes(self, device): + """Return the IP of the given device.""" + attributes = next(( + {'ip': result.ip} for result in self.last_results + if result.mac == device), {}) + return attributes def _update_info(self): """Get ARP from keenetic router.""" - _LOGGER.info("Fetching...") - - last_results = [] + _LOGGER.debug("Fetching devices from router...") - # doing a request - try: - from requests.auth import HTTPDigestAuth - res = requests.get(self._url, timeout=10, auth=HTTPDigestAuth( - self._username, self._password - )) - except requests.exceptions.Timeout: - _LOGGER.error( - "Connection to the router timed out at URL %s", self._url) - return False - if res.status_code != 200: - _LOGGER.error( - "Connection failed with http code %s", res.status_code) - return False + from ndms2_client import ConnectionException try: - result = res.json() - except ValueError: - # If json decoder could not parse the response - _LOGGER.error("Failed to parse response from router") + self.last_results = [ + dev + for dev in self._client.get_devices() + if dev.interface == self._interface + ] + _LOGGER.debug("Successfully fetched data from router") + return True + + except ConnectionException: + _LOGGER.error("Error fetching data from router") return False - - # parsing response - for info in result: - if info.get('interface') != self._interface: - continue - mac = info.get('mac') - name = info.get('name') - # No address = no item :) - if mac is None: - continue - - last_results.append(Device(mac.upper(), name)) - - self.last_results = last_results - - _LOGGER.info("Request successful") - return True diff --git a/homeassistant/components/device_tracker/linksys_ap.py b/homeassistant/components/device_tracker/linksys_ap.py index 8837b628b32650..a2a371163fd695 100644 --- a/homeassistant/components/device_tracker/linksys_ap.py +++ b/homeassistant/components/device_tracker/linksys_ap.py @@ -19,7 +19,7 @@ INTERFACES = 2 DEFAULT_TIMEOUT = 10 -REQUIREMENTS = ['beautifulsoup4==4.6.0'] +REQUIREMENTS = ['beautifulsoup4==4.6.1'] _LOGGER = logging.getLogger(__name__) @@ -61,7 +61,6 @@ def scan_devices(self): return self.last_results - # pylint: disable=no-self-use def get_device_name(self, device): """ Return the name (if known) of the device. diff --git a/homeassistant/components/device_tracker/locative.py b/homeassistant/components/device_tracker/locative.py index aee584aa953120..354d3b0980cbf2 100644 --- a/homeassistant/components/device_tracker/locative.py +++ b/homeassistant/components/device_tracker/locative.py @@ -84,7 +84,7 @@ def _handle(self, hass, data): gps=gps_location)) return 'Setting location to {}'.format(location_name) - elif direction == 'exit': + if direction == 'exit': current_state = hass.states.get( '{}.{}'.format(DOMAIN, device)) @@ -102,7 +102,7 @@ def _handle(self, hass, data): return 'Ignoring exit from {} (already in {})'.format( location_name, current_state) - elif direction == 'test': + if direction == 'test': # In the app, a test message can be sent. Just return something to # the user to let them know that it works. return 'Received test message.' diff --git a/homeassistant/components/device_tracker/luci.py b/homeassistant/components/device_tracker/luci.py index a4b826a009f906..f479dea184bfbf 100644 --- a/homeassistant/components/device_tracker/luci.py +++ b/homeassistant/components/device_tracker/luci.py @@ -15,14 +15,18 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.components.device_tracker import ( DOMAIN, PLATFORM_SCHEMA, DeviceScanner) -from homeassistant.const import CONF_HOST, CONF_PASSWORD, CONF_USERNAME +from homeassistant.const import ( + CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_SSL) _LOGGER = logging.getLogger(__name__) +DEFAULT_SSL = False + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean }) @@ -44,7 +48,9 @@ class LuciDeviceScanner(DeviceScanner): def __init__(self, config): """Initialize the scanner.""" - self.host = config[CONF_HOST] + host = config[CONF_HOST] + protocol = 'http' if not config[CONF_SSL] else 'https' + self.origin = '{}://{}'.format(protocol, host) self.username = config[CONF_USERNAME] self.password = config[CONF_PASSWORD] @@ -57,7 +63,7 @@ def __init__(self, config): def refresh_token(self): """Get a new token.""" - self.token = _get_token(self.host, self.username, self.password) + self.token = _get_token(self.origin, self.username, self.password) def scan_devices(self): """Scan for new devices and return a list with found device IDs.""" @@ -67,9 +73,9 @@ def scan_devices(self): def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" if self.mac2name is None: - url = 'http://{}/cgi-bin/luci/rpc/uci'.format(self.host) - result = _req_json_rpc(url, 'get_all', 'dhcp', - params={'auth': self.token}) + url = '{}/cgi-bin/luci/rpc/uci'.format(self.origin) + result = _req_json_rpc( + url, 'get_all', 'dhcp', params={'auth': self.token}) if result: hosts = [x for x in result.values() if x['.type'] == 'host' and @@ -92,11 +98,11 @@ def _update_info(self): _LOGGER.info("Checking ARP") - url = 'http://{}/cgi-bin/luci/rpc/sys'.format(self.host) + url = '{}/cgi-bin/luci/rpc/sys'.format(self.origin) try: - result = _req_json_rpc(url, 'net.arptable', - params={'auth': self.token}) + result = _req_json_rpc( + url, 'net.arptable', params={'auth': self.token}) except InvalidLuciTokenError: _LOGGER.info("Refreshing token") self.refresh_token() @@ -146,10 +152,10 @@ def _req_json_rpc(url, method, *args, **kwargs): raise InvalidLuciTokenError else: - _LOGGER.error('Invalid response from luci: %s', res) + _LOGGER.error("Invalid response from luci: %s", res) -def _get_token(host, username, password): - """Get authentication token for the given host+username+password.""" - url = 'http://{}/cgi-bin/luci/rpc/auth'.format(host) +def _get_token(origin, username, password): + """Get authentication token for the given configuration.""" + url = '{}/cgi-bin/luci/rpc/auth'.format(origin) return _req_json_rpc(url, 'login', username, password) diff --git a/homeassistant/components/device_tracker/meraki.py b/homeassistant/components/device_tracker/meraki.py index 9bbc6bf9ffed12..c996b7e643bf26 100644 --- a/homeassistant/components/device_tracker/meraki.py +++ b/homeassistant/components/device_tracker/meraki.py @@ -74,17 +74,16 @@ def post(self, request): _LOGGER.error("Invalid Secret received from Meraki") return self.json_message('Invalid secret', HTTP_UNPROCESSABLE_ENTITY) - elif data['version'] != VERSION: + if data['version'] != VERSION: _LOGGER.error("Invalid API version: %s", data['version']) return self.json_message('Invalid version', HTTP_UNPROCESSABLE_ENTITY) - else: - _LOGGER.debug('Valid Secret') - if data['type'] not in ('DevicesSeen', 'BluetoothDevicesSeen'): - _LOGGER.error("Unknown Device %s", data['type']) - return self.json_message('Invalid device type', - HTTP_UNPROCESSABLE_ENTITY) - _LOGGER.debug("Processing %s", data['type']) + _LOGGER.debug('Valid Secret') + if data['type'] not in ('DevicesSeen', 'BluetoothDevicesSeen'): + _LOGGER.error("Unknown Device %s", data['type']) + return self.json_message('Invalid device type', + HTTP_UNPROCESSABLE_ENTITY) + _LOGGER.debug("Processing %s", data['type']) if not data["data"]["observations"]: _LOGGER.debug("No observations found") return diff --git a/homeassistant/components/device_tracker/mikrotik.py b/homeassistant/components/device_tracker/mikrotik.py index a6a67749f764e4..dfc66a412c39f8 100644 --- a/homeassistant/components/device_tracker/mikrotik.py +++ b/homeassistant/components/device_tracker/mikrotik.py @@ -14,7 +14,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT) -REQUIREMENTS = ['librouteros==1.0.5'] +REQUIREMENTS = ['librouteros==2.1.0'] MTK_DEFAULT_API_PORT = '8728' @@ -66,7 +66,6 @@ def __init__(self, config): def connect_to_device(self): """Connect to Mikrotik method.""" - # pylint: disable=import-error import librouteros try: self.client = librouteros.connect( diff --git a/homeassistant/components/device_tracker/mqtt.py b/homeassistant/components/device_tracker/mqtt.py index 2e2d9b10d9859a..b5031e8ccfbcdf 100644 --- a/homeassistant/components/device_tracker/mqtt.py +++ b/homeassistant/components/device_tracker/mqtt.py @@ -9,7 +9,7 @@ import voluptuous as vol -import homeassistant.components.mqtt as mqtt +from homeassistant.components import mqtt from homeassistant.core import callback from homeassistant.const import CONF_DEVICES from homeassistant.components.mqtt import CONF_QOS diff --git a/homeassistant/components/device_tracker/mqtt_json.py b/homeassistant/components/device_tracker/mqtt_json.py index 9a5532fc9f440d..7e5ae7c922711d 100644 --- a/homeassistant/components/device_tracker/mqtt_json.py +++ b/homeassistant/components/device_tracker/mqtt_json.py @@ -10,7 +10,7 @@ import voluptuous as vol -import homeassistant.components.mqtt as mqtt +from homeassistant.components import mqtt from homeassistant.core import callback from homeassistant.components.mqtt import CONF_QOS from homeassistant.components.device_tracker import PLATFORM_SCHEMA diff --git a/homeassistant/components/device_tracker/mysensors.py b/homeassistant/components/device_tracker/mysensors.py index b0d29bf0566757..49d3f3207ba5ca 100644 --- a/homeassistant/components/device_tracker/mysensors.py +++ b/homeassistant/components/device_tracker/mysensors.py @@ -23,13 +23,13 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None): id(device.gateway), device.node_id, device.child_id, device.value_type) async_dispatcher_connect( - hass, mysensors.SIGNAL_CALLBACK.format(*dev_id), + hass, mysensors.const.SIGNAL_CALLBACK.format(*dev_id), device.async_update_callback) return True -class MySensorsDeviceScanner(mysensors.MySensorsDevice): +class MySensorsDeviceScanner(mysensors.device.MySensorsDevice): """Represent a MySensors scanner.""" def __init__(self, async_see, *args): diff --git a/homeassistant/components/device_tracker/netgear.py b/homeassistant/components/device_tracker/netgear.py index 0e48e3072b208a..87be70b2040a58 100644 --- a/homeassistant/components/device_tracker/netgear.py +++ b/homeassistant/components/device_tracker/netgear.py @@ -15,7 +15,7 @@ CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT, CONF_SSL, CONF_DEVICES, CONF_EXCLUDE) -REQUIREMENTS = ['pynetgear==0.4.0'] +REQUIREMENTS = ['pynetgear==0.4.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/device_tracker/owntracks.py b/homeassistant/components/device_tracker/owntracks.py index e99524c36db61e..2d7f1e80406d86 100644 --- a/homeassistant/components/device_tracker/owntracks.py +++ b/homeassistant/components/device_tracker/owntracks.py @@ -12,7 +12,7 @@ import voluptuous as vol -import homeassistant.components.mqtt as mqtt +from homeassistant.components import mqtt import homeassistant.helpers.config_validation as cv from homeassistant.components import zone as zone_comp from homeassistant.components.device_tracker import ( diff --git a/homeassistant/components/device_tracker/ping.py b/homeassistant/components/device_tracker/ping.py index 6a0cb18d55ec64..f3492da9e80b59 100644 --- a/homeassistant/components/device_tracker/ping.py +++ b/homeassistant/components/device_tracker/ping.py @@ -28,7 +28,7 @@ }) -class Host(object): +class Host: """Host object with ping detection.""" def __init__(self, ip_address, dev_id, hass, config): @@ -38,7 +38,7 @@ def __init__(self, ip_address, dev_id, hass, config): self.dev_id = dev_id self._count = config[CONF_PING_COUNT] if sys.platform == 'win32': - self._ping_cmd = ['ping', '-n 1', '-w', '1000', self.ip_address] + self._ping_cmd = ['ping', '-n', '1', '-w', '1000', self.ip_address] else: self._ping_cmd = ['ping', '-n', '-q', '-c1', '-W1', self.ip_address] diff --git a/homeassistant/components/device_tracker/ritassist.py b/homeassistant/components/device_tracker/ritassist.py new file mode 100644 index 00000000000000..9fc50de5062329 --- /dev/null +++ b/homeassistant/components/device_tracker/ritassist.py @@ -0,0 +1,87 @@ +""" +Support for RitAssist Platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/device_tracker.ritassist/ +""" +import logging + +import requests +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.device_tracker import PLATFORM_SCHEMA +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +from homeassistant.helpers.event import track_utc_time_change + +REQUIREMENTS = ['ritassist==0.5'] + +_LOGGER = logging.getLogger(__name__) + +CONF_CLIENT_ID = 'client_id' +CONF_CLIENT_SECRET = 'client_secret' +CONF_INCLUDE = 'include' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + vol.Optional(CONF_INCLUDE, default=[]): + vol.All(cv.ensure_list, [cv.string]) +}) + + +def setup_scanner(hass, config: dict, see, discovery_info=None): + """Set up the DeviceScanner and check if login is valid.""" + scanner = RitAssistDeviceScanner(config, see) + if not scanner.login(hass): + _LOGGER.error('RitAssist authentication failed') + return False + return True + + +class RitAssistDeviceScanner: + """Define a scanner for the RitAssist platform.""" + + def __init__(self, config, see): + """Initialize RitAssistDeviceScanner.""" + from ritassist import API + + self._include = config.get(CONF_INCLUDE) + self._see = see + + self._api = API(config.get(CONF_CLIENT_ID), + config.get(CONF_CLIENT_SECRET), + config.get(CONF_USERNAME), + config.get(CONF_PASSWORD)) + + def setup(self, hass): + """Setup a timer and start gathering devices.""" + self._refresh() + track_utc_time_change(hass, + lambda now: self._refresh(), + second=range(0, 60, 30)) + + def login(self, hass): + """Perform a login on the RitAssist API.""" + if self._api.login(): + self.setup(hass) + return True + return False + + def _refresh(self) -> None: + """Refresh device information from the platform.""" + try: + devices = self._api.get_devices() + + for device in devices: + if (not self._include or + device.license_plate in self._include): + self._see(dev_id=device.plate_as_id, + gps=(device.latitude, device.longitude), + attributes=device.state_attributes, + icon='mdi:car') + + except requests.exceptions.ConnectionError: + _LOGGER.error('ConnectionError: Could not connect to RitAssist') diff --git a/homeassistant/components/device_tracker/sky_hub.py b/homeassistant/components/device_tracker/sky_hub.py index c48c9bd029b94e..deab486ec6e410 100644 --- a/homeassistant/components/device_tracker/sky_hub.py +++ b/homeassistant/components/device_tracker/sky_hub.py @@ -23,7 +23,6 @@ }) -# pylint: disable=unused-argument def get_scanner(hass, config): """Return a Sky Hub scanner if successful.""" scanner = SkyHubDeviceScanner(config[DOMAIN]) @@ -92,8 +91,7 @@ def _get_skyhub_data(url): return if response.status_code == 200: return _parse_skyhub_response(response.text) - else: - _LOGGER.error("Invalid response from Sky Hub: %s", response) + _LOGGER.error("Invalid response from Sky Hub: %s", response) def _parse_skyhub_response(data_str): diff --git a/homeassistant/components/device_tracker/snmp.py b/homeassistant/components/device_tracker/snmp.py index c9c27fb2bfa848..a9afc76e67c273 100644 --- a/homeassistant/components/device_tracker/snmp.py +++ b/homeassistant/components/device_tracker/snmp.py @@ -14,7 +14,7 @@ DOMAIN, PLATFORM_SCHEMA, DeviceScanner) from homeassistant.const import CONF_HOST -REQUIREMENTS = ['pysnmp==4.4.4'] +REQUIREMENTS = ['pysnmp==4.4.5'] _LOGGER = logging.getLogger(__name__) @@ -34,7 +34,6 @@ }) -# pylint: disable=unused-argument def get_scanner(hass, config): """Validate the configuration and return an SNMP scanner.""" scanner = SnmpScanner(config[DOMAIN]) @@ -75,8 +74,6 @@ def scan_devices(self): return [client['mac'] for client in self.last_results if client.get('mac')] - # Suppressing no-self-use warning - # pylint: disable=R0201 def get_device_name(self, device): """Return the name of the given device or None if we don't know.""" # We have no names @@ -107,7 +104,6 @@ def get_snmp_data(self): if errindication: _LOGGER.error("SNMPLIB error: %s", errindication) return - # pylint: disable=no-member if errstatus: _LOGGER.error("SNMP error: %s at %s", errstatus.prettyPrint(), errindex and restable[int(errindex) - 1][0] or '?') diff --git a/homeassistant/components/device_tracker/tesla.py b/homeassistant/components/device_tracker/tesla.py index ba9bc8c2631a8f..c08ddb4047b6a7 100644 --- a/homeassistant/components/device_tracker/tesla.py +++ b/homeassistant/components/device_tracker/tesla.py @@ -23,7 +23,7 @@ def setup_scanner(hass, config, see, discovery_info=None): return True -class TeslaDeviceTracker(object): +class TeslaDeviceTracker: """A class representing a Tesla device.""" def __init__(self, hass, config, see, tesla_devices): diff --git a/homeassistant/components/device_tracker/thomson.py b/homeassistant/components/device_tracker/thomson.py index 3fa161e467dec4..8a56fcee7024b5 100644 --- a/homeassistant/components/device_tracker/thomson.py +++ b/homeassistant/components/device_tracker/thomson.py @@ -33,7 +33,6 @@ }) -# pylint: disable=unused-argument def get_scanner(hass, config): """Validate the configuration and return a THOMSON scanner.""" scanner = ThomsonDeviceScanner(config[DOMAIN]) diff --git a/homeassistant/components/device_tracker/tile.py b/homeassistant/components/device_tracker/tile.py index 377686b69054f3..07f15e7e88ad26 100644 --- a/homeassistant/components/device_tracker/tile.py +++ b/homeassistant/components/device_tracker/tile.py @@ -5,24 +5,22 @@ https://home-assistant.io/components/device_tracker.tile/ """ import logging +from datetime import timedelta import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.components.device_tracker import ( - PLATFORM_SCHEMA, DeviceScanner) +from homeassistant.components.device_tracker import PLATFORM_SCHEMA from homeassistant.const import ( CONF_USERNAME, CONF_MONITORED_VARIABLES, CONF_PASSWORD) -from homeassistant.helpers.event import track_utc_time_change +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.event import async_track_time_interval from homeassistant.util import slugify from homeassistant.util.json import load_json, save_json _LOGGER = logging.getLogger(__name__) - -REQUIREMENTS = ['pytile==1.1.0'] +REQUIREMENTS = ['pytile==2.0.2'] CLIENT_UUID_CONFIG_FILE = '.tile.conf' -DEFAULT_ICON = 'mdi:bluetooth' DEVICE_TYPES = ['PHONE', 'TILE'] ATTR_ALTITUDE = 'altitude' @@ -34,89 +32,111 @@ CONF_SHOW_INACTIVE = 'show_inactive' +DEFAULT_ICON = 'mdi:bluetooth' +DEFAULT_SCAN_INTERVAL = timedelta(minutes=2) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_SHOW_INACTIVE, default=False): cv.boolean, - vol.Optional(CONF_MONITORED_VARIABLES): + vol.Optional(CONF_MONITORED_VARIABLES, default=DEVICE_TYPES): vol.All(cv.ensure_list, [vol.In(DEVICE_TYPES)]), }) -def setup_scanner(hass, config: dict, see, discovery_info=None): +async def async_setup_scanner(hass, config, async_see, discovery_info=None): """Validate the configuration and return a Tile scanner.""" - TileDeviceScanner(hass, config, see) - return True - - -class TileDeviceScanner(DeviceScanner): - """Define a device scanner for Tiles.""" - - def __init__(self, hass, config, see): + from pytile import Client + + websession = aiohttp_client.async_get_clientsession(hass) + + config_data = await hass.async_add_job( + load_json, hass.config.path(CLIENT_UUID_CONFIG_FILE)) + if config_data: + client = Client( + config[CONF_USERNAME], + config[CONF_PASSWORD], + websession, + client_uuid=config_data['client_uuid']) + else: + client = Client( + config[CONF_USERNAME], config[CONF_PASSWORD], websession) + + config_data = {'client_uuid': client.client_uuid} + config_saved = await hass.async_add_job( + save_json, hass.config.path(CLIENT_UUID_CONFIG_FILE), config_data) + if not config_saved: + _LOGGER.error('Failed to save the client UUID') + + scanner = TileScanner( + client, hass, async_see, config[CONF_MONITORED_VARIABLES], + config[CONF_SHOW_INACTIVE]) + return await scanner.async_init() + + +class TileScanner: + """Define an object to retrieve Tile data.""" + + def __init__(self, client, hass, async_see, types, show_inactive): """Initialize.""" - from pytile import Client - - _LOGGER.debug('Received configuration data: %s', config) + self._async_see = async_see + self._client = client + self._hass = hass + self._show_inactive = show_inactive + self._types = types - # Load the client UUID (if it exists): - config_data = load_json(hass.config.path(CLIENT_UUID_CONFIG_FILE)) - if config_data: - _LOGGER.debug('Using existing client UUID') - self._client = Client( - config[CONF_USERNAME], - config[CONF_PASSWORD], - config_data['client_uuid']) - else: - _LOGGER.debug('Generating new client UUID') - self._client = Client( - config[CONF_USERNAME], - config[CONF_PASSWORD]) + async def async_init(self): + """Further initialize connection to the Tile servers.""" + from pytile.errors import TileError - if not save_json( - hass.config.path(CLIENT_UUID_CONFIG_FILE), - {'client_uuid': self._client.client_uuid}): - _LOGGER.error("Failed to save configuration file") + try: + await self._client.async_init() + except TileError as err: + _LOGGER.error('Unable to set up Tile scanner: %s', err) + return False - _LOGGER.debug('Client UUID: %s', self._client.client_uuid) - _LOGGER.debug('User UUID: %s', self._client.user_uuid) + await self._async_update() - self._show_inactive = config.get(CONF_SHOW_INACTIVE) - self._types = config.get(CONF_MONITORED_VARIABLES) + async_track_time_interval( + self._hass, self._async_update, DEFAULT_SCAN_INTERVAL) - self.devices = {} - self.see = see + return True - track_utc_time_change( - hass, self._update_info, second=range(0, 60, 30)) + async def _async_update(self, now=None): + """Update info from Tile.""" + from pytile.errors import SessionExpiredError, TileError - self._update_info() + _LOGGER.debug('Updating Tile data') - def _update_info(self, now=None) -> None: - """Update the device info.""" - self.devices = self._client.get_tiles( - type_whitelist=self._types, show_inactive=self._show_inactive) + try: + await self._client.async_init() + tiles = await self._client.tiles.all( + whitelist=self._types, show_inactive=self._show_inactive) + except SessionExpiredError: + _LOGGER.info('Session expired; trying again shortly') + return + except TileError as err: + _LOGGER.error('There was an error while updating: %s', err) + return - if not self.devices: + if not tiles: _LOGGER.warning('No Tiles found') return - for dev in self.devices: - dev_id = 'tile_{0}'.format(slugify(dev['name'])) - lat = dev['tileState']['latitude'] - lon = dev['tileState']['longitude'] - - attrs = { - ATTR_ALTITUDE: dev['tileState']['altitude'], - ATTR_CONNECTION_STATE: dev['tileState']['connection_state'], - ATTR_IS_DEAD: dev['is_dead'], - ATTR_IS_LOST: dev['tileState']['is_lost'], - ATTR_RING_STATE: dev['tileState']['ring_state'], - ATTR_VOIP_STATE: dev['tileState']['voip_state'], - } - - self.see( - dev_id=dev_id, - gps=(lat, lon), - attributes=attrs, - icon=DEFAULT_ICON - ) + for tile in tiles: + await self._async_see( + dev_id='tile_{0}'.format(slugify(tile['name'])), + gps=( + tile['tileState']['latitude'], + tile['tileState']['longitude'] + ), + attributes={ + ATTR_ALTITUDE: tile['tileState']['altitude'], + ATTR_CONNECTION_STATE: + tile['tileState']['connection_state'], + ATTR_IS_DEAD: tile['is_dead'], + ATTR_IS_LOST: tile['tileState']['is_lost'], + ATTR_RING_STATE: tile['tileState']['ring_state'], + ATTR_VOIP_STATE: tile['tileState']['voip_state'], + }, + icon=DEFAULT_ICON) diff --git a/homeassistant/components/device_tracker/tomato.py b/homeassistant/components/device_tracker/tomato.py index 01ae2977f6d9f6..718adad421231b 100644 --- a/homeassistant/components/device_tracker/tomato.py +++ b/homeassistant/components/device_tracker/tomato.py @@ -102,12 +102,12 @@ def _update_tomato_info(self): for param, value in \ self.parse_api_pattern.findall(response.text): - if param == 'wldev' or param == 'dhcpd_lease': + if param in ('wldev', 'dhcpd_lease'): self.last_results[param] = \ json.loads(value.replace("'", '"')) return True - elif response.status_code == 401: + if response.status_code == 401: # Authentication error _LOGGER.exception(( "Failed to authenticate, " diff --git a/homeassistant/components/device_tracker/tplink.py b/homeassistant/components/device_tracker/tplink.py index 6c5fb697c072da..346f381db34b1c 100644 --- a/homeassistant/components/device_tracker/tplink.py +++ b/homeassistant/components/device_tracker/tplink.py @@ -22,6 +22,8 @@ CONF_HOST, CONF_PASSWORD, CONF_USERNAME, HTTP_HEADER_X_REQUESTED_WITH) import homeassistant.helpers.config_validation as cv +REQUIREMENTS = ['tplink==0.2.1'] + _LOGGER = logging.getLogger(__name__) HTTP_HEADER_NO_CACHE = 'no-cache' @@ -34,10 +36,22 @@ def get_scanner(hass, config): - """Validate the configuration and return a TP-Link scanner.""" - for cls in [Tplink5DeviceScanner, Tplink4DeviceScanner, - Tplink3DeviceScanner, Tplink2DeviceScanner, - TplinkDeviceScanner]: + """ + Validate the configuration and return a TP-Link scanner. + + The default way of integrating devices is to use a pypi + + package, The TplinkDeviceScanner has been refactored + + to depend on a pypi package, the other implementations + + should be gradually migrated in the pypi package + + """ + for cls in [ + TplinkDeviceScanner, Tplink5DeviceScanner, Tplink4DeviceScanner, + Tplink3DeviceScanner, Tplink2DeviceScanner, Tplink1DeviceScanner + ]: scanner = cls(config[DOMAIN]) if scanner.success_init: return scanner @@ -46,6 +60,46 @@ def get_scanner(hass, config): class TplinkDeviceScanner(DeviceScanner): + """Queries the router for connected devices.""" + + def __init__(self, config): + """Initialize the scanner.""" + from tplink.tplink import TpLinkClient + host = config[CONF_HOST] + password = config[CONF_PASSWORD] + username = config[CONF_USERNAME] + + self.tplink_client = TpLinkClient( + password, host=host, username=username) + + self.last_results = {} + self.success_init = self._update_info() + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self._update_info() + return self.last_results.keys() + + def get_device_name(self, device): + """Get the name of the device.""" + return self.last_results.get(device) + + def _update_info(self): + """Ensure the information from the TP-Link router is up to date. + + Return boolean if scanning successful. + """ + _LOGGER.info("Loading wireless clients...") + result = self.tplink_client.get_connected_devices() + + if result: + self.last_results = result + return True + + return False + + +class Tplink1DeviceScanner(DeviceScanner): """This class queries a wireless router running TP-Link firmware.""" def __init__(self, config): @@ -68,7 +122,6 @@ def scan_devices(self): self._update_info() return self.last_results - # pylint: disable=no-self-use def get_device_name(self, device): """Get firmware doesn't save the name of the wireless device.""" return None @@ -95,7 +148,7 @@ def _update_info(self): return False -class Tplink2DeviceScanner(TplinkDeviceScanner): +class Tplink2DeviceScanner(Tplink1DeviceScanner): """This class queries a router with newer version of TP-Link firmware.""" def scan_devices(self): @@ -103,7 +156,6 @@ def scan_devices(self): self._update_info() return self.last_results.keys() - # pylint: disable=no-self-use def get_device_name(self, device): """Get firmware doesn't save the name of the wireless device.""" return self.last_results.get(device) @@ -149,7 +201,7 @@ def _update_info(self): return False -class Tplink3DeviceScanner(TplinkDeviceScanner): +class Tplink3DeviceScanner(Tplink1DeviceScanner): """This class queries the Archer C9 router with version 150811 or high.""" def __init__(self, config): @@ -164,7 +216,6 @@ def scan_devices(self): self._log_out() return self.last_results.keys() - # pylint: disable=no-self-use def get_device_name(self, device): """Get the firmware doesn't save the name of the wireless device. @@ -259,7 +310,7 @@ def _log_out(self): self.sysauth = '' -class Tplink4DeviceScanner(TplinkDeviceScanner): +class Tplink4DeviceScanner(Tplink1DeviceScanner): """This class queries an Archer C7 router with TP-Link firmware 150427.""" def __init__(self, config): @@ -273,7 +324,6 @@ def scan_devices(self): self._update_info() return self.last_results - # pylint: disable=no-self-use def get_device_name(self, device): """Get the name of the wireless device.""" return None @@ -341,7 +391,7 @@ def _update_info(self): return True -class Tplink5DeviceScanner(TplinkDeviceScanner): +class Tplink5DeviceScanner(Tplink1DeviceScanner): """This class queries a TP-Link EAP-225 AP with newer TP-Link FW.""" def scan_devices(self): @@ -349,7 +399,6 @@ def scan_devices(self): self._update_info() return self.last_results.keys() - # pylint: disable=no-self-use def get_device_name(self, device): """Get firmware doesn't save the name of the wireless device.""" return None diff --git a/homeassistant/components/device_tracker/trackr.py b/homeassistant/components/device_tracker/trackr.py index 84fb449c070156..08d3a4c944588e 100644 --- a/homeassistant/components/device_tracker/trackr.py +++ b/homeassistant/components/device_tracker/trackr.py @@ -30,7 +30,7 @@ def setup_scanner(hass, config: dict, see, discovery_info=None): return True -class TrackRDeviceScanner(object): +class TrackRDeviceScanner: """A class representing a TrackR device.""" def __init__(self, hass, config: dict, see) -> None: diff --git a/homeassistant/components/device_tracker/ubus.py b/homeassistant/components/device_tracker/ubus.py index f265014657bef7..94e3b407d13aba 100644 --- a/homeassistant/components/device_tracker/ubus.py +++ b/homeassistant/components/device_tracker/ubus.py @@ -56,7 +56,7 @@ def decorator(self, *args, **kwargs): try: return func(self, *args, **kwargs) except PermissionError: - _LOGGER.warning("Invalid session detected." + + _LOGGER.warning("Invalid session detected." " Trying to refresh session_id and re-run RPC") self.session_id = _get_session_id( self.url, self.username, self.password) diff --git a/homeassistant/components/device_tracker/unifi_direct.py b/homeassistant/components/device_tracker/unifi_direct.py index 168ab04ec6f14a..228443fe22ba66 100644 --- a/homeassistant/components/device_tracker/unifi_direct.py +++ b/homeassistant/components/device_tracker/unifi_direct.py @@ -16,7 +16,7 @@ CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_PORT) -REQUIREMENTS = ['pexpect==4.0.1'] +REQUIREMENTS = ['pexpect==4.6.0'] _LOGGER = logging.getLogger(__name__) @@ -33,7 +33,6 @@ }) -# pylint: disable=unused-argument def get_scanner(hass, config): """Validate the configuration and return a Unifi direct scanner.""" scanner = UnifiDeviceScanner(config[DOMAIN]) diff --git a/homeassistant/components/device_tracker/xiaomi_miio.py b/homeassistant/components/device_tracker/xiaomi_miio.py index c5769253657c61..074d6a1054ee51 100644 --- a/homeassistant/components/device_tracker/xiaomi_miio.py +++ b/homeassistant/components/device_tracker/xiaomi_miio.py @@ -20,7 +20,7 @@ vol.Required(CONF_TOKEN): vol.All(cv.string, vol.Length(min=32, max=32)), }) -REQUIREMENTS = ['python-miio==0.3.9', 'construct==2.9.41'] +REQUIREMENTS = ['python-miio==0.4.0', 'construct==2.9.41'] def get_scanner(hass, config): @@ -64,7 +64,7 @@ async def async_scan_devices(self): station_info = await self.hass.async_add_job(self.device.status) _LOGGER.debug("Got new station info: %s", station_info) - for device in station_info['mat']: + for device in station_info.associated_stations: devices.append(device['mac']) except DeviceException as ex: diff --git a/homeassistant/components/dialogflow.py b/homeassistant/components/dialogflow.py index 7a0918aab25e0d..0f275a7fe66e1f 100644 --- a/homeassistant/components/dialogflow.py +++ b/homeassistant/components/dialogflow.py @@ -99,7 +99,8 @@ async def async_handle_message(hass, message): return None action = req.get('action', '') - parameters = req.get('parameters') + parameters = req.get('parameters').copy() + parameters["dialogflow_query"] = message dialogflow_response = DialogflowResponse(parameters) if action == "": @@ -118,7 +119,7 @@ async def async_handle_message(hass, message): return dialogflow_response.as_dict() -class DialogflowResponse(object): +class DialogflowResponse: """Help generating the response for Dialogflow.""" def __init__(self, parameters): diff --git a/homeassistant/components/digital_ocean.py b/homeassistant/components/digital_ocean.py index bd03fb019759f9..c0c9d95586c4ab 100644 --- a/homeassistant/components/digital_ocean.py +++ b/homeassistant/components/digital_ocean.py @@ -27,6 +27,7 @@ ATTR_REGION = 'region' ATTR_VCPUS = 'vcpus' +CONF_ATTRIBUTION = 'Data provided by Digital Ocean' CONF_DROPLETS = 'droplets' DATA_DIGITAL_OCEAN = 'data_do' @@ -64,7 +65,7 @@ def setup(hass, config): return True -class DigitalOcean(object): +class DigitalOcean: """Handle all communication with the Digital Ocean API.""" def __init__(self, access_token): diff --git a/homeassistant/components/discovery.py b/homeassistant/components/discovery.py index 65d0a1c76f3368..41cf3791256d7a 100644 --- a/homeassistant/components/discovery.py +++ b/homeassistant/components/discovery.py @@ -13,7 +13,7 @@ import voluptuous as vol -from homeassistant import data_entry_flow +from homeassistant import config_entries from homeassistant.core import callback from homeassistant.const import EVENT_HOMEASSISTANT_START import homeassistant.helpers.config_validation as cv @@ -21,7 +21,7 @@ from homeassistant.helpers.discovery import async_load_platform, async_discover import homeassistant.util.dt as dt_util -REQUIREMENTS = ['netdisco==1.4.1'] +REQUIREMENTS = ['netdisco==2.0.0'] DOMAIN = 'discovery' @@ -37,14 +37,18 @@ SERVICE_XIAOMI_GW = 'xiaomi_gw' SERVICE_TELLDUSLIVE = 'tellstick' SERVICE_HUE = 'philips_hue' +SERVICE_KONNECTED = 'konnected' SERVICE_DECONZ = 'deconz' SERVICE_DAIKIN = 'daikin' +SERVICE_SABNZBD = 'sabnzbd' SERVICE_SAMSUNG_PRINTER = 'samsung_printer' SERVICE_HOMEKIT = 'homekit' CONFIG_ENTRY_HANDLERS = { SERVICE_DECONZ: 'deconz', + 'google_cast': 'cast', SERVICE_HUE: 'hue', + 'sonos': 'sonos', } SERVICE_HANDLERS = { @@ -59,12 +63,12 @@ SERVICE_XIAOMI_GW: ('xiaomi_aqara', None), SERVICE_TELLDUSLIVE: ('tellduslive', None), SERVICE_DAIKIN: ('daikin', None), + SERVICE_SABNZBD: ('sabnzbd', None), SERVICE_SAMSUNG_PRINTER: ('sensor', 'syncthru'), - 'google_cast': ('media_player', 'cast'), + SERVICE_KONNECTED: ('konnected', None), 'panasonic_viera': ('media_player', 'panasonic_viera'), 'plex_mediaserver': ('media_player', 'plex'), 'roku': ('media_player', 'roku'), - 'sonos': ('media_player', 'sonos'), 'yamaha': ('media_player', 'yamaha'), 'logitech_mediaserver': ('media_player', 'squeezebox'), 'directv': ('media_player', 'directv'), @@ -74,16 +78,18 @@ 'frontier_silicon': ('media_player', 'frontier_silicon'), 'openhome': ('media_player', 'openhome'), 'harmony': ('remote', 'harmony'), - 'sabnzbd': ('sensor', 'sabnzbd'), 'bose_soundtouch': ('media_player', 'soundtouch'), 'bluesound': ('media_player', 'bluesound'), 'songpal': ('media_player', 'songpal'), 'kodi': ('media_player', 'kodi'), 'volumio': ('media_player', 'volumio'), + 'nanoleaf_aurora': ('light', 'nanoleaf_aurora'), + 'freebox': ('device_tracker', 'freebox'), } OPTIONAL_SERVICE_HANDLERS = { SERVICE_HOMEKIT: ('homekit_controller', None), + 'dlna_dmr': ('media_player', 'dlna_dmr'), } CONF_IGNORE = 'ignore' @@ -132,7 +138,7 @@ async def new_service_found(service, info): if service in CONFIG_ENTRY_HANDLERS: await hass.config_entries.flow.async_init( CONFIG_ENTRY_HANDLERS[service], - source=data_entry_flow.SOURCE_DISCOVERY, + context={'source': config_entries.SOURCE_DISCOVERY}, data=info ) return @@ -190,6 +196,7 @@ def _discover(netdisco): for disc in netdisco.discover(): for service in netdisco.get_info(disc): results.append((disc, service)) + finally: netdisco.stop() diff --git a/homeassistant/components/doorbird.py b/homeassistant/components/doorbird.py index 48f229b49cad8c..6cd820816e2fef 100644 --- a/homeassistant/components/doorbird.py +++ b/homeassistant/components/doorbird.py @@ -4,14 +4,16 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/doorbird/ """ -import asyncio import logging +import asyncio import voluptuous as vol -from homeassistant.const import CONF_HOST, CONF_USERNAME, CONF_PASSWORD from homeassistant.components.http import HomeAssistantView +from homeassistant.const import CONF_HOST, CONF_USERNAME, \ + CONF_PASSWORD, CONF_NAME, CONF_DEVICES, CONF_MONITORED_CONDITIONS import homeassistant.helpers.config_validation as cv +from homeassistant.util import slugify REQUIREMENTS = ['DoorBirdPy==0.1.3'] @@ -24,60 +26,139 @@ CONF_DOORBELL_EVENTS = 'doorbell_events' CONF_CUSTOM_URL = 'hass_url_override' +DOORBELL_EVENT = 'doorbell' +MOTION_EVENT = 'motionsensor' + +# Sensor types: Name, device_class, event +SENSOR_TYPES = { + 'doorbell': ['Button', 'occupancy', DOORBELL_EVENT], + 'motion': ['Motion', 'motion', MOTION_EVENT], +} + +DEVICE_SCHEMA = vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_CUSTOM_URL): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_DOORBELL_EVENTS): cv.boolean, - vol.Optional(CONF_CUSTOM_URL): cv.string, - }) + vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, [DEVICE_SCHEMA]) + }), }, extra=vol.ALLOW_EXTRA) -SENSOR_DOORBELL = 'doorbell' - def setup(hass, config): """Set up the DoorBird component.""" from doorbirdpy import DoorBird - device_ip = config[DOMAIN].get(CONF_HOST) - username = config[DOMAIN].get(CONF_USERNAME) - password = config[DOMAIN].get(CONF_PASSWORD) + # Provide an endpoint for the doorstations to call to trigger events + hass.http.register_view(DoorbirdRequestView()) + + doorstations = [] + + for index, doorstation_config in enumerate(config[DOMAIN][CONF_DEVICES]): + device_ip = doorstation_config.get(CONF_HOST) + username = doorstation_config.get(CONF_USERNAME) + password = doorstation_config.get(CONF_PASSWORD) + custom_url = doorstation_config.get(CONF_CUSTOM_URL) + events = doorstation_config.get(CONF_MONITORED_CONDITIONS) + name = (doorstation_config.get(CONF_NAME) + or 'DoorBird {}'.format(index + 1)) + + device = DoorBird(device_ip, username, password) + status = device.ready() + + if status[0]: + _LOGGER.info("Connected to DoorBird at %s as %s", device_ip, + username) + doorstation = ConfiguredDoorbird(device, name, events, custom_url) + doorstations.append(doorstation) + elif status[1] == 401: + _LOGGER.error("Authorization rejected by DoorBird at %s", + device_ip) + return False + else: + _LOGGER.error("Could not connect to DoorBird at %s: Error %s", + device_ip, str(status[1])) + return False + + # SETUP EVENT SUBSCRIBERS + if events is not None: + # This will make HA the only service that receives events. + doorstation.device.reset_notifications() + + # Subscribe to doorbell or motion events + subscribe_events(hass, doorstation) + + hass.data[DOMAIN] = doorstations - device = DoorBird(device_ip, username, password) - status = device.ready() + return True - if status[0]: - _LOGGER.info("Connected to DoorBird at %s as %s", device_ip, username) - hass.data[DOMAIN] = device - elif status[1] == 401: - _LOGGER.error("Authorization rejected by DoorBird at %s", device_ip) - return False - else: - _LOGGER.error("Could not connect to DoorBird at %s: Error %s", - device_ip, str(status[1])) - return False - if config[DOMAIN].get(CONF_DOORBELL_EVENTS): - # Provide an endpoint for the device to call to trigger events - hass.http.register_view(DoorbirdRequestView()) +def subscribe_events(hass, doorstation): + """Initialize the subscriber.""" + for sensor_type in doorstation.monitored_events: + name = '{} {}'.format(doorstation.name, + SENSOR_TYPES[sensor_type][0]) + event_type = SENSOR_TYPES[sensor_type][2] # Get the URL of this server hass_url = hass.config.api.base_url - # Override it if another is specified in the component configuration - if config[DOMAIN].get(CONF_CUSTOM_URL): - hass_url = config[DOMAIN].get(CONF_CUSTOM_URL) - _LOGGER.info("DoorBird will connect to this instance via %s", - hass_url) + # Override url if another is specified onth configuration + if doorstation.custom_url is not None: + hass_url = doorstation.custom_url - # This will make HA the only service that gets doorbell events - url = '{}{}/{}'.format(hass_url, API_URL, SENSOR_DOORBELL) - device.reset_notifications() - device.subscribe_notification(SENSOR_DOORBELL, url) + slug = slugify(name) + + url = '{}{}/{}'.format(hass_url, API_URL, slug) + + _LOGGER.info("DoorBird will connect to this instance via %s", + url) + + _LOGGER.info("You may use the following event name for automations" + ": %s_%s", DOMAIN, slug) + + doorstation.device.subscribe_notification(event_type, url) - return True + +class ConfiguredDoorbird(): + """Attach additional information to pass along with configured device.""" + + def __init__(self, device, name, events=None, custom_url=None): + """Initialize configured device.""" + self._name = name + self._device = device + self._custom_url = custom_url + self._monitored_events = events + + @property + def name(self): + """Custom device name.""" + return self._name + + @property + def device(self): + """The configured device.""" + return self._device + + @property + def custom_url(self): + """Custom url for device.""" + return self._custom_url + + @property + def monitored_events(self): + """Get monitored events.""" + if self._monitored_events is None: + return [] + + return self._monitored_events class DoorbirdRequestView(HomeAssistantView): @@ -93,5 +174,7 @@ class DoorbirdRequestView(HomeAssistantView): def get(self, request, sensor): """Respond to requests from the device.""" hass = request.app['hass'] + hass.bus.async_fire('{}_{}'.format(DOMAIN, sensor)) + return 'OK' diff --git a/homeassistant/components/ecobee.py b/homeassistant/components/ecobee.py index 9c29cea704c325..3829c2caebd46b 100644 --- a/homeassistant/components/ecobee.py +++ b/homeassistant/components/ecobee.py @@ -48,7 +48,6 @@ def request_configuration(network, hass, config): return - # pylint: disable=unused-argument def ecobee_configuration_callback(callback_data): """Handle configuration callbacks.""" network.request_tokens() @@ -85,7 +84,7 @@ def setup_ecobee(hass, network, config): discovery.load_platform(hass, 'weather', DOMAIN, {}, config) -class EcobeeData(object): +class EcobeeData: """Get the latest data and update the states.""" def __init__(self, config_file): @@ -106,7 +105,6 @@ def setup(hass, config): Will automatically load thermostat and sensor components to support devices discovered on the network. """ - # pylint: disable=global-statement, import-error global NETWORK if 'ecobee' in _CONFIGURING: diff --git a/homeassistant/components/egardia.py b/homeassistant/components/egardia.py index f350ea56bb4a56..b7da671bb15889 100644 --- a/homeassistant/components/egardia.py +++ b/homeassistant/components/egardia.py @@ -81,7 +81,7 @@ def setup(hass, config): device = hass.data[EGARDIA_DEVICE] = egardiadevice.EgardiaDevice( host, port, username, password, '', version) except requests.exceptions.RequestException: - _LOGGER.error("An error occurred accessing your Egardia device. " + + _LOGGER.error("An error occurred accessing your Egardia device. " "Please check config.") return False except egardiadevice.UnauthorizedError: @@ -108,7 +108,7 @@ def handle_stop_event(event): hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, handle_stop_event) except IOError: - _LOGGER.error("Binding error occurred while starting " + + _LOGGER.error("Binding error occurred while starting " "EgardiaServer.") return False diff --git a/homeassistant/components/eight_sleep.py b/homeassistant/components/eight_sleep.py index 3478d5cd08e7e4..209fa7ba879330 100644 --- a/homeassistant/components/eight_sleep.py +++ b/homeassistant/components/eight_sleep.py @@ -4,7 +4,6 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/eight_sleep/ """ -import asyncio import logging from datetime import timedelta @@ -22,7 +21,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow -REQUIREMENTS = ['pyeight==0.0.8'] +REQUIREMENTS = ['pyeight==0.0.9'] _LOGGER = logging.getLogger(__name__) @@ -86,8 +85,7 @@ }, extra=vol.ALLOW_EXTRA) -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up the Eight Sleep component.""" from pyeight.eight import EightSleep @@ -107,31 +105,29 @@ def async_setup(hass, config): hass.data[DATA_EIGHT] = eight # Authenticate, build sensors - success = yield from eight.start() + success = await eight.start() if not success: # Authentication failed, cannot continue return False - @asyncio.coroutine - def async_update_heat_data(now): + async def async_update_heat_data(now): """Update heat data from eight in HEAT_SCAN_INTERVAL.""" - yield from eight.update_device_data() + await eight.update_device_data() async_dispatcher_send(hass, SIGNAL_UPDATE_HEAT) async_track_point_in_utc_time( hass, async_update_heat_data, utcnow() + HEAT_SCAN_INTERVAL) - @asyncio.coroutine - def async_update_user_data(now): + async def async_update_user_data(now): """Update user data from eight in USER_SCAN_INTERVAL.""" - yield from eight.update_user_data() + await eight.update_user_data() async_dispatcher_send(hass, SIGNAL_UPDATE_USER) async_track_point_in_utc_time( hass, async_update_user_data, utcnow() + USER_SCAN_INTERVAL) - yield from async_update_heat_data(None) - yield from async_update_user_data(None) + await async_update_heat_data(None) + await async_update_user_data(None) # Load sub components sensors = [] @@ -147,18 +143,17 @@ def async_update_user_data(now): # No users, cannot continue return False - hass.async_add_job(discovery.async_load_platform( + hass.async_create_task(discovery.async_load_platform( hass, 'sensor', DOMAIN, { CONF_SENSORS: sensors, }, config)) - hass.async_add_job(discovery.async_load_platform( + hass.async_create_task(discovery.async_load_platform( hass, 'binary_sensor', DOMAIN, { CONF_BINARY_SENSORS: binary_sensors, }, config)) - @asyncio.coroutine - def async_service_handler(service): + async def async_service_handler(service): """Handle eight sleep service calls.""" params = service.data.copy() @@ -170,7 +165,7 @@ def async_service_handler(service): side = sens.split('_')[1] userid = eight.fetch_userid(side) usrobj = eight.users[userid] - yield from usrobj.set_heating_level(target, duration) + await usrobj.set_heating_level(target, duration) async_dispatcher_send(hass, SIGNAL_UPDATE_HEAT) @@ -179,10 +174,9 @@ def async_service_handler(service): DOMAIN, SERVICE_HEAT_SET, async_service_handler, schema=SERVICE_EIGHT_SCHEMA) - @asyncio.coroutine - def stop_eight(event): + async def stop_eight(event): """Handle stopping eight api session.""" - yield from eight.stop() + await eight.stop() hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_eight) @@ -196,8 +190,7 @@ def __init__(self, eight): """Initialize the data object.""" self._eight = eight - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Register update dispatcher.""" @callback def async_eight_user_update(): @@ -220,8 +213,7 @@ def __init__(self, eight): """Initialize the data object.""" self._eight = eight - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Register update dispatcher.""" @callback def async_eight_heat_update(): diff --git a/homeassistant/components/emulated_hue/__init__.py b/homeassistant/components/emulated_hue/__init__.py index fd7f7147fdba01..8a67b933b9f736 100644 --- a/homeassistant/components/emulated_hue/__init__.py +++ b/homeassistant/components/emulated_hue/__init__.py @@ -6,6 +6,7 @@ """ import logging +from aiohttp import web import voluptuous as vol from homeassistant import util @@ -13,7 +14,6 @@ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, ) from homeassistant.components.http import REQUIREMENTS # NOQA -from homeassistant.components.http import HomeAssistantHTTP from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.deprecation import get_deprecated import homeassistant.helpers.config_validation as cv @@ -85,26 +85,17 @@ def setup(hass, yaml_config): """Activate the emulated_hue component.""" config = Config(hass, yaml_config.get(DOMAIN, {})) - server = HomeAssistantHTTP( - hass, - server_host=config.host_ip_addr, - server_port=config.listen_port, - api_password=None, - ssl_certificate=None, - ssl_key=None, - cors_origins=None, - use_x_forwarded_for=False, - trusted_networks=[], - login_threshold=0, - is_ban_enabled=False - ) - - server.register_view(DescriptionXmlView(config)) - server.register_view(HueUsernameView) - server.register_view(HueAllLightsStateView(config)) - server.register_view(HueOneLightStateView(config)) - server.register_view(HueOneLightChangeView(config)) - server.register_view(HueGroupView(config)) + app = web.Application() + app['hass'] = hass + handler = None + server = None + + DescriptionXmlView(config).register(app, app.router) + HueUsernameView().register(app, app.router) + HueAllLightsStateView(config).register(app, app.router) + HueOneLightStateView(config).register(app, app.router) + HueOneLightChangeView(config).register(app, app.router) + HueGroupView(config).register(app, app.router) upnp_listener = UPNPResponderThread( config.host_ip_addr, config.listen_port, @@ -114,21 +105,38 @@ def setup(hass, yaml_config): async def stop_emulated_hue_bridge(event): """Stop the emulated hue bridge.""" upnp_listener.stop() - await server.stop() + if server: + server.close() + await server.wait_closed() + await app.shutdown() + if handler: + await handler.shutdown(10) + await app.cleanup() async def start_emulated_hue_bridge(event): """Start the emulated hue bridge.""" upnp_listener.start() - await server.start() - hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, stop_emulated_hue_bridge) + nonlocal handler + nonlocal server + + handler = app.make_handler(loop=hass.loop) + + try: + server = await hass.loop.create_server( + handler, config.host_ip_addr, config.listen_port) + except OSError as error: + _LOGGER.error("Failed to create HTTP server at port %d: %s", + config.listen_port, error) + else: + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, stop_emulated_hue_bridge) hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_emulated_hue_bridge) return True -class Config(object): +class Config: """Hold configuration variables for the emulated hue bridge.""" def __init__(self, hass, conf): diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 2b74984e4ca2eb..f7fbe2e15e3f55 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -237,11 +237,11 @@ def put(self, request, username, entity_number): # Convert 0-100 to a fan speed if brightness == 0: data[ATTR_SPEED] = SPEED_OFF - elif brightness <= 33.3 and brightness > 0: + elif 0 < brightness <= 33.3: data[ATTR_SPEED] = SPEED_LOW - elif brightness <= 66.6 and brightness > 33.3: + elif 33.3 < brightness <= 66.6: data[ATTR_SPEED] = SPEED_MEDIUM - elif brightness <= 100 and brightness > 66.6: + elif 66.6 < brightness <= 100: data[ATTR_SPEED] = SPEED_HIGH if entity.domain in config.off_maps_to_on_domains: diff --git a/homeassistant/components/enocean.py b/homeassistant/components/enocean.py index 879f6a61899803..75e456f62bde4f 100644 --- a/homeassistant/components/enocean.py +++ b/homeassistant/components/enocean.py @@ -75,6 +75,7 @@ def callback(self, temp): _LOGGER.debug("Received radio packet: %s", temp) rxtype = None value = None + channel = 0 if temp.data[6] == 0x30: rxtype = "wallswitch" value = 1 @@ -84,8 +85,9 @@ def callback(self, temp): elif temp.data[4] == 0x0c: rxtype = "power" value = temp.data[3] + (temp.data[2] << 8) - elif temp.data[2] == 0x60: + elif temp.data[2] & 0x60 == 0x60: rxtype = "switch_status" + channel = temp.data[2] & 0x1F if temp.data[3] == 0xe4: value = 1 elif temp.data[3] == 0x80: @@ -104,7 +106,8 @@ def callback(self, temp): if temp.sender_int == self._combine_hex(device.dev_id): if value > 10: device.value_changed(1) - if rxtype == "switch_status" and device.stype == "switch": + if rxtype == "switch_status" and device.stype == "switch" and \ + channel == device.channel: if temp.sender_int == self._combine_hex(device.dev_id): device.value_changed(value) if rxtype == "dimmerstatus" and device.stype == "dimmer": diff --git a/homeassistant/components/envisalink.py b/homeassistant/components/envisalink.py index 5ffd97ef0e31d5..9b5b25c934cfe3 100644 --- a/homeassistant/components/envisalink.py +++ b/homeassistant/components/envisalink.py @@ -16,7 +16,7 @@ from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send -REQUIREMENTS = ['pyenvisalink==2.2'] +REQUIREMENTS = ['pyenvisalink==2.3'] _LOGGER = logging.getLogger(__name__) @@ -111,20 +111,24 @@ def async_setup(hass, config): def login_fail_callback(data): """Handle when the evl rejects our login.""" _LOGGER.error("The Envisalink rejected your credentials") - sync_connect.set_result(False) + if not sync_connect.done(): + sync_connect.set_result(False) @callback def connection_fail_callback(data): """Network failure callback.""" _LOGGER.error("Could not establish a connection with the Envisalink") - sync_connect.set_result(False) + if not sync_connect.done(): + sync_connect.set_result(False) @callback def connection_success_callback(data): """Handle a successful connection.""" _LOGGER.info("Established a connection with the Envisalink") - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_envisalink) - sync_connect.set_result(True) + if not sync_connect.done(): + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, + stop_envisalink) + sync_connect.set_result(True) @callback def zones_updated_callback(data): @@ -167,21 +171,21 @@ def stop_envisalink(event): # Load sub-components for Envisalink if partitions: - hass.async_add_job(async_load_platform( + hass.async_create_task(async_load_platform( hass, 'alarm_control_panel', 'envisalink', { CONF_PARTITIONS: partitions, CONF_CODE: code, CONF_PANIC: panic_type }, config )) - hass.async_add_job(async_load_platform( + hass.async_create_task(async_load_platform( hass, 'sensor', 'envisalink', { CONF_PARTITIONS: partitions, CONF_CODE: code }, config )) if zones: - hass.async_add_job(async_load_platform( + hass.async_create_task(async_load_platform( hass, 'binary_sensor', 'envisalink', { CONF_ZONES: zones }, config diff --git a/homeassistant/components/eufy.py b/homeassistant/components/eufy.py index 733aa0adbfe315..69d4905228ad27 100644 --- a/homeassistant/components/eufy.py +++ b/homeassistant/components/eufy.py @@ -15,7 +15,7 @@ import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['lakeside==0.5'] +REQUIREMENTS = ['lakeside==0.7'] _LOGGER = logging.getLogger(__name__) @@ -49,7 +49,6 @@ def setup(hass, config): """Set up Eufy devices.""" - # pylint: disable=import-error import lakeside if CONF_USERNAME in config[DOMAIN] and CONF_PASSWORD in config[DOMAIN]: diff --git a/homeassistant/components/fan/__init__.py b/homeassistant/components/fan/__init__.py index 66790d0268729d..db0e8c590fdabe 100644 --- a/homeassistant/components/fan/__init__.py +++ b/homeassistant/components/fan/__init__.py @@ -235,11 +235,11 @@ def async_handle_fan_service(service): class FanEntity(ToggleEntity): """Representation of a fan.""" - def set_speed(self: ToggleEntity, speed: str) -> None: + def set_speed(self, speed: str) -> None: """Set the speed of the fan.""" raise NotImplementedError() - def async_set_speed(self: ToggleEntity, speed: str): + def async_set_speed(self, speed: str): """Set the speed of the fan. This method must be run in the event loop and returns a coroutine. @@ -248,11 +248,11 @@ def async_set_speed(self: ToggleEntity, speed: str): return self.async_turn_off() return self.hass.async_add_job(self.set_speed, speed) - def set_direction(self: ToggleEntity, direction: str) -> None: + def set_direction(self, direction: str) -> None: """Set the direction of the fan.""" raise NotImplementedError() - def async_set_direction(self: ToggleEntity, direction: str): + def async_set_direction(self, direction: str): """Set the direction of the fan. This method must be run in the event loop and returns a coroutine. @@ -260,12 +260,12 @@ def async_set_direction(self: ToggleEntity, direction: str): return self.hass.async_add_job(self.set_direction, direction) # pylint: disable=arguments-differ - def turn_on(self: ToggleEntity, speed: str = None, **kwargs) -> None: + def turn_on(self, speed: str = None, **kwargs) -> None: """Turn on the fan.""" raise NotImplementedError() # pylint: disable=arguments-differ - def async_turn_on(self: ToggleEntity, speed: str = None, **kwargs): + def async_turn_on(self, speed: str = None, **kwargs): """Turn on the fan. This method must be run in the event loop and returns a coroutine. @@ -275,11 +275,11 @@ def async_turn_on(self: ToggleEntity, speed: str = None, **kwargs): return self.hass.async_add_job( ft.partial(self.turn_on, speed, **kwargs)) - def oscillate(self: ToggleEntity, oscillating: bool) -> None: + def oscillate(self, oscillating: bool) -> None: """Oscillate the fan.""" pass - def async_oscillate(self: ToggleEntity, oscillating: bool): + def async_oscillate(self, oscillating: bool): """Oscillate the fan. This method must be run in the event loop and returns a coroutine. @@ -297,7 +297,7 @@ def speed(self) -> str: return None @property - def speed_list(self: ToggleEntity) -> list: + def speed_list(self) -> list: """Get the list of available speeds.""" return [] @@ -307,7 +307,7 @@ def current_direction(self) -> str: return None @property - def state_attributes(self: ToggleEntity) -> dict: + def state_attributes(self) -> dict: """Return optional state attributes.""" data = {} # type: dict @@ -322,6 +322,6 @@ def state_attributes(self: ToggleEntity) -> dict: return data @property - def supported_features(self: ToggleEntity) -> int: + def supported_features(self) -> int: """Flag supported features.""" return 0 diff --git a/homeassistant/components/fan/comfoconnect.py b/homeassistant/components/fan/comfoconnect.py index 12dc0b1104f29c..fd3265b8230377 100644 --- a/homeassistant/components/fan/comfoconnect.py +++ b/homeassistant/components/fan/comfoconnect.py @@ -31,7 +31,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): ccb = hass.data[DOMAIN] add_devices([ComfoConnectFan(hass, name=ccb.name, ccb=ccb)], True) - return class ComfoConnectFan(FanEntity): diff --git a/homeassistant/components/fan/demo.py b/homeassistant/components/fan/demo.py index b328ebb310174b..c03c492c834a11 100644 --- a/homeassistant/components/fan/demo.py +++ b/homeassistant/components/fan/demo.py @@ -13,7 +13,6 @@ LIMITED_SUPPORT = SUPPORT_SET_SPEED -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up the demo fan platform.""" add_devices_callback([ diff --git a/homeassistant/components/fan/dyson.py b/homeassistant/components/fan/dyson.py index 5b689ece6ed20c..3eb4646e6dcbdc 100644 --- a/homeassistant/components/fan/dyson.py +++ b/homeassistant/components/fan/dyson.py @@ -8,17 +8,19 @@ import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.components.dyson import DYSON_DEVICES from homeassistant.components.fan import ( DOMAIN, SUPPORT_OSCILLATE, SUPPORT_SET_SPEED, FanEntity) from homeassistant.const import CONF_ENTITY_ID -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import ToggleEntity _LOGGER = logging.getLogger(__name__) CONF_NIGHT_MODE = 'night_mode' +ATTR_IS_NIGHT_MODE = 'is_night_mode' +ATTR_IS_AUTO_MODE = 'is_auto_mode' + DEPENDENCIES = ['dyson'] DYSON_FAN_DEVICES = 'dyson_fan_devices' @@ -100,7 +102,7 @@ def name(self): """Return the display name of this fan.""" return self._device.name - def set_speed(self: ToggleEntity, speed: str) -> None: + def set_speed(self, speed: str) -> None: """Set the speed of the fan. Never called ??.""" from libpurecoollink.const import FanSpeed, FanMode @@ -113,7 +115,7 @@ def set_speed(self: ToggleEntity, speed: str) -> None: self._device.set_configuration( fan_mode=FanMode.FAN, fan_speed=fan_speed) - def turn_on(self: ToggleEntity, speed: str = None, **kwargs) -> None: + def turn_on(self, speed: str = None, **kwargs) -> None: """Turn on the fan.""" from libpurecoollink.const import FanSpeed, FanMode @@ -129,14 +131,14 @@ def turn_on(self: ToggleEntity, speed: str = None, **kwargs) -> None: # Speed not set, just turn on self._device.set_configuration(fan_mode=FanMode.FAN) - def turn_off(self: ToggleEntity, **kwargs) -> None: + def turn_off(self, **kwargs) -> None: """Turn off the fan.""" from libpurecoollink.const import FanMode _LOGGER.debug("Turn off fan %s", self.name) self._device.set_configuration(fan_mode=FanMode.OFF) - def oscillate(self: ToggleEntity, oscillating: bool) -> None: + def oscillate(self, oscillating: bool) -> None: """Turn on/off oscillating.""" from libpurecoollink.const import Oscillation @@ -159,7 +161,7 @@ def oscillating(self): def is_on(self): """Return true if the entity is on.""" if self._device.state: - return self._device.state.fan_state == "FAN" + return self._device.state.fan_mode == "FAN" return False @property @@ -183,7 +185,7 @@ def is_night_mode(self): """Return Night mode.""" return self._device.state.night_mode == "ON" - def night_mode(self: ToggleEntity, night_mode: bool) -> None: + def night_mode(self, night_mode: bool) -> None: """Turn fan in night mode.""" from libpurecoollink.const import NightMode @@ -198,7 +200,7 @@ def is_auto_mode(self): """Return auto mode.""" return self._device.state.fan_mode == "AUTO" - def auto_mode(self: ToggleEntity, auto_mode: bool) -> None: + def auto_mode(self, auto_mode: bool) -> None: """Turn fan in auto mode.""" from libpurecoollink.const import FanMode @@ -209,7 +211,7 @@ def auto_mode(self: ToggleEntity, auto_mode: bool) -> None: self._device.set_configuration(fan_mode=FanMode.FAN) @property - def speed_list(self: ToggleEntity) -> list: + def speed_list(self) -> list: """Get the list of available speeds.""" from libpurecoollink.const import FanSpeed @@ -230,6 +232,14 @@ def speed_list(self: ToggleEntity) -> list: return supported_speeds @property - def supported_features(self: ToggleEntity) -> int: + def supported_features(self) -> int: """Flag supported features.""" return SUPPORT_OSCILLATE | SUPPORT_SET_SPEED + + @property + def device_state_attributes(self) -> dict: + """Return optional state attributes.""" + return { + ATTR_IS_NIGHT_MODE: self.is_night_mode, + ATTR_IS_AUTO_MODE: self.is_auto_mode + } diff --git a/homeassistant/components/fan/insteon_local.py b/homeassistant/components/fan/insteon_local.py index b8a5c99add42cb..28b93c86ed7f97 100644 --- a/homeassistant/components/fan/insteon_local.py +++ b/homeassistant/components/fan/insteon_local.py @@ -7,11 +7,10 @@ import logging from datetime import timedelta +from homeassistant import util from homeassistant.components.fan import ( ATTR_SPEED, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, SUPPORT_SET_SPEED, FanEntity) -from homeassistant.helpers.entity import ToggleEntity -import homeassistant.util as util _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) @@ -68,7 +67,7 @@ def speed(self) -> str: return self._speed @property - def speed_list(self: ToggleEntity) -> list: + def speed_list(self) -> list: """Get the list of available speeds.""" return [SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] @@ -91,21 +90,18 @@ def supported_features(self): """Flag supported features.""" return SUPPORT_INSTEON_LOCAL - def turn_on(self: ToggleEntity, speed: str = None, **kwargs) -> None: + def turn_on(self, speed: str = None, **kwargs) -> None: """Turn device on.""" if speed is None: - if ATTR_SPEED in kwargs: - speed = kwargs[ATTR_SPEED] - else: - speed = SPEED_MEDIUM + speed = kwargs.get(ATTR_SPEED, SPEED_MEDIUM) self.set_speed(speed) - def turn_off(self: ToggleEntity, **kwargs) -> None: + def turn_off(self, **kwargs) -> None: """Turn device off.""" self.node.off() - def set_speed(self: ToggleEntity, speed: str) -> None: + def set_speed(self, speed: str) -> None: """Set the speed of the fan.""" if self.node.on(speed): self._speed = speed diff --git a/homeassistant/components/fan/isy994.py b/homeassistant/components/fan/isy994.py index 847ca3b325b2bc..97a5f9c3bd69d2 100644 --- a/homeassistant/components/fan/isy994.py +++ b/homeassistant/components/fan/isy994.py @@ -30,7 +30,6 @@ STATE_TO_VALUE[VALUE_TO_STATE[key]] = key -# pylint: disable=unused-argument def setup_platform(hass, config: ConfigType, add_devices: Callable[[list], None], discovery_info=None): """Set up the ISY994 fan platform.""" diff --git a/homeassistant/components/fan/mqtt.py b/homeassistant/components/fan/mqtt.py index 6fa506edec66fe..5faa735801dbd3 100644 --- a/homeassistant/components/fan/mqtt.py +++ b/homeassistant/components/fan/mqtt.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.core import callback -import homeassistant.components.mqtt as mqtt +from homeassistant.components import mqtt from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, CONF_STATE, STATE_ON, STATE_OFF, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON) diff --git a/homeassistant/components/fan/services.yaml b/homeassistant/components/fan/services.yaml index a74f67b83fb332..039cc33f748ff7 100644 --- a/homeassistant/components/fan/services.yaml +++ b/homeassistant/components/fan/services.yaml @@ -51,8 +51,8 @@ set_direction: description: Name(s) of the entities to toggle example: 'fan.living_room' direction: - description: The direction to rotate - example: 'left' + description: The direction to rotate. Either 'forward' or 'reverse' + example: 'forward' dyson_set_night_mode: description: Set the fan in night mode. diff --git a/homeassistant/components/fan/template.py b/homeassistant/components/fan/template.py index 31b335eb2bca5c..74fb73dae1d258 100644 --- a/homeassistant/components/fan/template.py +++ b/homeassistant/components/fan/template.py @@ -8,22 +8,17 @@ import voluptuous as vol -from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.components.fan import ( + SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, SUPPORT_SET_SPEED, SUPPORT_OSCILLATE, + FanEntity, ATTR_SPEED, ATTR_OSCILLATING, ENTITY_ID_FORMAT, + SUPPORT_DIRECTION, DIRECTION_FORWARD, DIRECTION_REVERSE, ATTR_DIRECTION) from homeassistant.const import ( CONF_FRIENDLY_NAME, CONF_VALUE_TEMPLATE, CONF_ENTITY_ID, STATE_ON, STATE_OFF, MATCH_ALL, EVENT_HOMEASSISTANT_START, STATE_UNKNOWN) - +from homeassistant.core import callback from homeassistant.exceptions import TemplateError -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.config_validation import PLATFORM_SCHEMA -from homeassistant.helpers.entity import ToggleEntity -from homeassistant.components.fan import (SPEED_LOW, SPEED_MEDIUM, - SPEED_HIGH, SUPPORT_SET_SPEED, - SUPPORT_OSCILLATE, FanEntity, - ATTR_SPEED, ATTR_OSCILLATING, - ENTITY_ID_FORMAT) - from homeassistant.helpers.entity import async_generate_entity_id from homeassistant.helpers.script import Script @@ -33,25 +28,30 @@ CONF_SPEED_LIST = 'speeds' CONF_SPEED_TEMPLATE = 'speed_template' CONF_OSCILLATING_TEMPLATE = 'oscillating_template' +CONF_DIRECTION_TEMPLATE = 'direction_template' CONF_ON_ACTION = 'turn_on' CONF_OFF_ACTION = 'turn_off' CONF_SET_SPEED_ACTION = 'set_speed' CONF_SET_OSCILLATING_ACTION = 'set_oscillating' +CONF_SET_DIRECTION_ACTION = 'set_direction' _VALID_STATES = [STATE_ON, STATE_OFF] _VALID_OSC = [True, False] +_VALID_DIRECTIONS = [DIRECTION_FORWARD, DIRECTION_REVERSE] FAN_SCHEMA = vol.Schema({ vol.Optional(CONF_FRIENDLY_NAME): cv.string, vol.Required(CONF_VALUE_TEMPLATE): cv.template, vol.Optional(CONF_SPEED_TEMPLATE): cv.template, vol.Optional(CONF_OSCILLATING_TEMPLATE): cv.template, + vol.Optional(CONF_DIRECTION_TEMPLATE): cv.template, vol.Required(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, vol.Required(CONF_OFF_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_SET_SPEED_ACTION): cv.SCRIPT_SCHEMA, vol.Optional(CONF_SET_OSCILLATING_ACTION): cv.SCRIPT_SCHEMA, + vol.Optional(CONF_SET_DIRECTION_ACTION): cv.SCRIPT_SCHEMA, vol.Optional( CONF_SPEED_LIST, @@ -61,7 +61,7 @@ vol.Optional(CONF_ENTITY_ID): cv.entity_ids }) -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ +PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ vol.Required(CONF_FANS): vol.Schema({cv.slug: FAN_SCHEMA}), }) @@ -80,18 +80,21 @@ async def async_setup_platform( oscillating_template = device_config.get( CONF_OSCILLATING_TEMPLATE ) + direction_template = device_config.get(CONF_DIRECTION_TEMPLATE) on_action = device_config[CONF_ON_ACTION] off_action = device_config[CONF_OFF_ACTION] set_speed_action = device_config.get(CONF_SET_SPEED_ACTION) set_oscillating_action = device_config.get(CONF_SET_OSCILLATING_ACTION) + set_direction_action = device_config.get(CONF_SET_DIRECTION_ACTION) speed_list = device_config[CONF_SPEED_LIST] entity_ids = set() manual_entity_ids = device_config.get(CONF_ENTITY_ID) - for template in (state_template, speed_template, oscillating_template): + for template in (state_template, speed_template, oscillating_template, + direction_template): if template is None: continue template.hass = hass @@ -114,8 +117,9 @@ async def async_setup_platform( TemplateFan( hass, device, friendly_name, state_template, speed_template, oscillating_template, - on_action, off_action, set_speed_action, - set_oscillating_action, speed_list, entity_ids + direction_template, on_action, off_action, set_speed_action, + set_oscillating_action, set_direction_action, speed_list, + entity_ids ) ) @@ -127,8 +131,9 @@ class TemplateFan(FanEntity): def __init__(self, hass, device_id, friendly_name, state_template, speed_template, oscillating_template, - on_action, off_action, set_speed_action, - set_oscillating_action, speed_list, entity_ids): + direction_template, on_action, off_action, set_speed_action, + set_oscillating_action, set_direction_action, speed_list, + entity_ids): """Initialize the fan.""" self.hass = hass self.entity_id = async_generate_entity_id( @@ -138,6 +143,7 @@ def __init__(self, hass, device_id, friendly_name, self._template = state_template self._speed_template = speed_template self._oscillating_template = oscillating_template + self._direction_template = direction_template self._supported_features = 0 self._on_script = Script(hass, on_action) @@ -151,9 +157,14 @@ def __init__(self, hass, device_id, friendly_name, if set_oscillating_action: self._set_oscillating_script = Script(hass, set_oscillating_action) + self._set_direction_script = None + if set_direction_action: + self._set_direction_script = Script(hass, set_direction_action) + self._state = STATE_OFF self._speed = None self._oscillating = None + self._direction = None self._template.hass = self.hass if self._speed_template: @@ -162,6 +173,9 @@ def __init__(self, hass, device_id, friendly_name, if self._oscillating_template: self._oscillating_template.hass = self.hass self._supported_features |= SUPPORT_OSCILLATE + if self._direction_template: + self._direction_template.hass = self.hass + self._supported_features |= SUPPORT_DIRECTION self._entities = entity_ids # List of valid speeds @@ -178,7 +192,7 @@ def supported_features(self) -> int: return self._supported_features @property - def speed_list(self: ToggleEntity) -> list: + def speed_list(self) -> list: """Get the list of available speeds.""" return self._speed_list @@ -197,6 +211,11 @@ def oscillating(self): """Return the oscillation state.""" return self._oscillating + @property + def direction(self): + """Return the oscillation state.""" + return self._direction + @property def should_poll(self): """Return the polling state.""" @@ -227,8 +246,7 @@ async def async_set_speed(self, speed: str) -> None: await self._set_speed_script.async_run({ATTR_SPEED: speed}) else: _LOGGER.error( - 'Received invalid speed: %s. ' + - 'Expected: %s.', + 'Received invalid speed: %s. Expected: %s.', speed, self._speed_list) async def async_oscillate(self, oscillating: bool) -> None: @@ -236,10 +254,28 @@ async def async_oscillate(self, oscillating: bool) -> None: if self._set_oscillating_script is None: return - await self._set_oscillating_script.async_run( - {ATTR_OSCILLATING: oscillating} - ) - self._oscillating = oscillating + if oscillating in _VALID_OSC: + self._oscillating = oscillating + await self._set_oscillating_script.async_run( + {ATTR_OSCILLATING: oscillating}) + else: + _LOGGER.error( + 'Received invalid oscillating value: %s. Expected: %s.', + oscillating, ', '.join(_VALID_OSC)) + + async def async_set_direction(self, direction: str) -> None: + """Set the direction of the fan.""" + if self._set_direction_script is None: + return + + if direction in _VALID_DIRECTIONS: + self._direction = direction + await self._set_direction_script.async_run( + {ATTR_DIRECTION: direction}) + else: + _LOGGER.error( + 'Received invalid direction: %s. Expected: %s.', + direction, ', '.join(_VALID_DIRECTIONS)) async def async_added_to_hass(self): """Register callbacks.""" @@ -276,8 +312,7 @@ async def async_update(self): self._state = None else: _LOGGER.error( - 'Received invalid fan is_on state: %s. ' + - 'Expected: %s.', + 'Received invalid fan is_on state: %s. Expected: %s.', state, ', '.join(_VALID_STATES)) self._state = None @@ -297,8 +332,7 @@ async def async_update(self): self._speed = None else: _LOGGER.error( - 'Received invalid speed: %s. ' + - 'Expected: %s.', + 'Received invalid speed: %s. Expected: %s.', speed, self._speed_list) self._speed = None @@ -308,6 +342,7 @@ async def async_update(self): oscillating = self._oscillating_template.async_render() except TemplateError as ex: _LOGGER.error(ex) + oscillating = None self._state = None # Validate osc @@ -319,6 +354,26 @@ async def async_update(self): self._oscillating = None else: _LOGGER.error( - 'Received invalid oscillating: %s. ' + - 'Expected: True/False.', oscillating) + 'Received invalid oscillating: %s. Expected: True/False.', + oscillating) self._oscillating = None + + # Update direction if 'direction_template' is configured + if self._direction_template is not None: + try: + direction = self._direction_template.async_render() + except TemplateError as ex: + _LOGGER.error(ex) + direction = None + self._state = None + + # Validate speed + if direction in _VALID_DIRECTIONS: + self._direction = direction + elif direction == STATE_UNKNOWN: + self._direction = None + else: + _LOGGER.error( + 'Received invalid direction: %s. Expected: %s.', + direction, ', '.join(_VALID_DIRECTIONS)) + self._direction = None diff --git a/homeassistant/components/fan/tuya.py b/homeassistant/components/fan/tuya.py new file mode 100644 index 00000000000000..f19a9e5a5f7745 --- /dev/null +++ b/homeassistant/components/fan/tuya.py @@ -0,0 +1,99 @@ +""" +Support for Tuya fans. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/fan.tuya/ +""" + +from homeassistant.components.fan import ( + ENTITY_ID_FORMAT, FanEntity, SUPPORT_OSCILLATE, SUPPORT_SET_SPEED) +from homeassistant.components.tuya import DATA_TUYA, TuyaDevice +from homeassistant.const import STATE_OFF + +DEPENDENCIES = ['tuya'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Tuya fan platform.""" + if discovery_info is None: + return + tuya = hass.data[DATA_TUYA] + dev_ids = discovery_info.get('dev_ids') + devices = [] + for dev_id in dev_ids: + device = tuya.get_device_by_id(dev_id) + if device is None: + continue + devices.append(TuyaFanDevice(device)) + add_devices(devices) + + +class TuyaFanDevice(TuyaDevice, FanEntity): + """Tuya fan devices.""" + + def __init__(self, tuya): + """Init Tuya fan device.""" + super().__init__(tuya) + self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) + self.speeds = [STATE_OFF] + + async def async_added_to_hass(self): + """Create fan list when add to hass.""" + await super().async_added_to_hass() + self.speeds.extend(self.tuya.speed_list()) + + def set_speed(self, speed: str) -> None: + """Set the speed of the fan.""" + if speed == STATE_OFF: + self.turn_off() + else: + self.tuya.set_speed(speed) + + def turn_on(self, speed: str = None, **kwargs) -> None: + """Turn on the fan.""" + if speed is not None: + self.set_speed(speed) + else: + self.tuya.turn_on() + + def turn_off(self, **kwargs) -> None: + """Turn the entity off.""" + self.tuya.turn_off() + + def oscillate(self, oscillating) -> None: + """Oscillate the fan.""" + self.tuya.oscillate(oscillating) + + @property + def oscillating(self): + """Return current oscillating status.""" + if self.supported_features & SUPPORT_OSCILLATE == 0: + return None + if self.speed == STATE_OFF: + return False + return self.tuya.oscillating() + + @property + def is_on(self): + """Return true if the entity is on.""" + return self.tuya.state() + + @property + def speed(self) -> str: + """Return the current speed.""" + if self.is_on: + return self.tuya.speed() + return STATE_OFF + + @property + def speed_list(self) -> list: + """Get the list of available speeds.""" + return self.speeds + + @property + def supported_features(self) -> int: + """Flag supported features.""" + supports = SUPPORT_SET_SPEED + if self.tuya.support_oscillate(): + supports = supports | SUPPORT_OSCILLATE + return supports diff --git a/homeassistant/components/fan/velbus.py b/homeassistant/components/fan/velbus.py deleted file mode 100644 index e8208d1c9907a9..00000000000000 --- a/homeassistant/components/fan/velbus.py +++ /dev/null @@ -1,187 +0,0 @@ -""" -Support for Velbus platform. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/fan.velbus/ -""" -import asyncio -import logging -import voluptuous as vol - -from homeassistant.components.fan import ( - SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, FanEntity, SUPPORT_SET_SPEED, - PLATFORM_SCHEMA) -from homeassistant.components.velbus import DOMAIN -from homeassistant.const import CONF_NAME, CONF_DEVICES, STATE_OFF -import homeassistant.helpers.config_validation as cv - -DEPENDENCIES = ['velbus'] - -_LOGGER = logging.getLogger(__name__) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, [ - { - vol.Required('module'): cv.positive_int, - vol.Required('channel_low'): cv.positive_int, - vol.Required('channel_medium'): cv.positive_int, - vol.Required('channel_high'): cv.positive_int, - vol.Required(CONF_NAME): cv.string, - } - ]) -}) - - -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up Fans.""" - velbus = hass.data[DOMAIN] - add_devices(VelbusFan(fan, velbus) for fan in config[CONF_DEVICES]) - - -class VelbusFan(FanEntity): - """Representation of a Velbus Fan.""" - - def __init__(self, fan, velbus): - """Initialize a Velbus light.""" - self._velbus = velbus - self._name = fan[CONF_NAME] - self._module = fan['module'] - self._channel_low = fan['channel_low'] - self._channel_medium = fan['channel_medium'] - self._channel_high = fan['channel_high'] - self._channels = [self._channel_low, self._channel_medium, - self._channel_high] - self._channels_state = [False, False, False] - self._speed = STATE_OFF - - @asyncio.coroutine - def async_added_to_hass(self): - """Add listener for Velbus messages on bus.""" - def _init_velbus(): - """Initialize Velbus on startup.""" - self._velbus.subscribe(self._on_message) - self.get_status() - - yield from self.hass.async_add_job(_init_velbus) - - def _on_message(self, message): - import velbus - if isinstance(message, velbus.RelayStatusMessage) and \ - message.address == self._module and \ - message.channel in self._channels: - if message.channel == self._channel_low: - self._channels_state[0] = message.is_on() - elif message.channel == self._channel_medium: - self._channels_state[1] = message.is_on() - elif message.channel == self._channel_high: - self._channels_state[2] = message.is_on() - self._calculate_speed() - self.schedule_update_ha_state() - - def _calculate_speed(self): - if self._is_off(): - self._speed = STATE_OFF - elif self._is_low(): - self._speed = SPEED_LOW - elif self._is_medium(): - self._speed = SPEED_MEDIUM - elif self._is_high(): - self._speed = SPEED_HIGH - - def _is_off(self): - return self._channels_state[0] is False and \ - self._channels_state[1] is False and \ - self._channels_state[2] is False - - def _is_low(self): - return self._channels_state[0] is True and \ - self._channels_state[1] is False and \ - self._channels_state[2] is False - - def _is_medium(self): - return self._channels_state[0] is True and \ - self._channels_state[1] is True and \ - self._channels_state[2] is False - - def _is_high(self): - return self._channels_state[0] is True and \ - self._channels_state[1] is False and \ - self._channels_state[2] is True - - @property - def name(self): - """Return the display name of this light.""" - return self._name - - @property - def should_poll(self): - """Disable polling.""" - return False - - @property - def speed(self): - """Return the current speed.""" - return self._speed - - @property - def speed_list(self): - """Get the list of available speeds.""" - return [STATE_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH] - - def turn_on(self, speed=None, **kwargs): - """Turn on the entity.""" - if speed is None: - speed = SPEED_MEDIUM - self.set_speed(speed) - - def turn_off(self, **kwargs): - """Turn off the entity.""" - self.set_speed(STATE_OFF) - - def set_speed(self, speed): - """Set the speed of the fan.""" - channels_off = [] - channels_on = [] - if speed == STATE_OFF: - channels_off = self._channels - elif speed == SPEED_LOW: - channels_off = [self._channel_medium, self._channel_high] - channels_on = [self._channel_low] - elif speed == SPEED_MEDIUM: - channels_off = [self._channel_high] - channels_on = [self._channel_low, self._channel_medium] - elif speed == SPEED_HIGH: - channels_off = [self._channel_medium] - channels_on = [self._channel_low, self._channel_high] - for channel in channels_off: - self._relay_off(channel) - for channel in channels_on: - self._relay_on(channel) - self.schedule_update_ha_state() - - def _relay_on(self, channel): - import velbus - message = velbus.SwitchRelayOnMessage() - message.set_defaults(self._module) - message.relay_channels = [channel] - self._velbus.send(message) - - def _relay_off(self, channel): - import velbus - message = velbus.SwitchRelayOffMessage() - message.set_defaults(self._module) - message.relay_channels = [channel] - self._velbus.send(message) - - def get_status(self): - """Retrieve current status.""" - import velbus - message = velbus.ModuleStatusRequestMessage() - message.set_defaults(self._module) - message.channels = self._channels - self._velbus.send(message) - - @property - def supported_features(self): - """Flag supported features.""" - return SUPPORT_SET_SPEED diff --git a/homeassistant/components/fan/wink.py b/homeassistant/components/fan/wink.py index 0cebd9cb9f8312..4eebacbbbf2e3f 100644 --- a/homeassistant/components/fan/wink.py +++ b/homeassistant/components/fan/wink.py @@ -11,7 +11,6 @@ SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, STATE_UNKNOWN, SUPPORT_DIRECTION, SUPPORT_SET_SPEED, FanEntity) from homeassistant.components.wink import DOMAIN, WinkDevice -from homeassistant.helpers.entity import ToggleEntity _LOGGER = logging.getLogger(__name__) @@ -39,19 +38,19 @@ def async_added_to_hass(self): """Call when entity is added to hass.""" self.hass.data[DOMAIN]['entities']['fan'].append(self) - def set_direction(self: ToggleEntity, direction: str) -> None: + def set_direction(self, direction: str) -> None: """Set the direction of the fan.""" self.wink.set_fan_direction(direction) - def set_speed(self: ToggleEntity, speed: str) -> None: + def set_speed(self, speed: str) -> None: """Set the speed of the fan.""" self.wink.set_state(True, speed) - def turn_on(self: ToggleEntity, speed: str = None, **kwargs) -> None: + def turn_on(self, speed: str = None, **kwargs) -> None: """Turn on the fan.""" self.wink.set_state(True, speed) - def turn_off(self: ToggleEntity, **kwargs) -> None: + def turn_off(self, **kwargs) -> None: """Turn off the fan.""" self.wink.set_state(False) @@ -82,7 +81,7 @@ def current_direction(self): return self.wink.current_fan_direction() @property - def speed_list(self: ToggleEntity) -> list: + def speed_list(self) -> list: """Get the list of available speeds.""" wink_supported_speeds = self.wink.fan_speeds() supported_speeds = [] @@ -99,6 +98,6 @@ def speed_list(self: ToggleEntity) -> list: return supported_speeds @property - def supported_features(self: ToggleEntity) -> int: + def supported_features(self) -> int: """Flag supported features.""" return SUPPORTED_FEATURES diff --git a/homeassistant/components/fan/xiaomi_miio.py b/homeassistant/components/fan/xiaomi_miio.py index 2acc3895f3e5a4..1616d38881626d 100644 --- a/homeassistant/components/fan/xiaomi_miio.py +++ b/homeassistant/components/fan/xiaomi_miio.py @@ -49,7 +49,7 @@ 'zhimi.humidifier.ca1']), }) -REQUIREMENTS = ['python-miio==0.3.9', 'construct==2.9.41'] +REQUIREMENTS = ['python-miio==0.4.0', 'construct==2.9.41'] ATTR_MODEL = 'model' @@ -314,7 +314,6 @@ } -# pylint: disable=unused-argument async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the miio fan device from config.""" diff --git a/homeassistant/components/fan/zha.py b/homeassistant/components/fan/zha.py index 3288a788e1f969..983bc3a79d753b 100644 --- a/homeassistant/components/fan/zha.py +++ b/homeassistant/components/fan/zha.py @@ -10,7 +10,6 @@ from homeassistant.components.fan import ( DOMAIN, FanEntity, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, SUPPORT_SET_SPEED) -from homeassistant.const import STATE_UNKNOWN DEPENDENCIES = ['zha'] @@ -72,7 +71,7 @@ def speed(self) -> str: @property def is_on(self) -> bool: """Return true if entity is on.""" - if self._state == STATE_UNKNOWN: + if self._state is None: return False return self._state != SPEED_OFF @@ -90,7 +89,7 @@ def async_turn_off(self, **kwargs) -> None: yield from self.async_set_speed(SPEED_OFF) @asyncio.coroutine - def async_set_speed(self: FanEntity, speed: str) -> None: + def async_set_speed(self, speed: str) -> None: """Set the speed of the fan.""" yield from self._endpoint.fan.write_attributes({ 'fan_mode': SPEED_TO_VALUE[speed]}) @@ -103,7 +102,7 @@ def async_update(self): """Retrieve latest state.""" result = yield from zha.safe_read(self._endpoint.fan, ['fan_mode']) new_value = result.get('fan_mode', None) - self._state = VALUE_TO_SPEED.get(new_value, STATE_UNKNOWN) + self._state = VALUE_TO_SPEED.get(new_value, None) @property def should_poll(self) -> bool: diff --git a/homeassistant/components/fan/zwave.py b/homeassistant/components/fan/zwave.py index 364306ff8ddd2a..645cb033e13346 100644 --- a/homeassistant/components/fan/zwave.py +++ b/homeassistant/components/fan/zwave.py @@ -11,7 +11,7 @@ DOMAIN, FanEntity, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, SUPPORT_SET_SPEED) from homeassistant.components import zwave -from homeassistant.components.zwave import async_setup_platform # noqa # pylint: disable=unused-import +from homeassistant.components.zwave import async_setup_platform # noqa pylint: disable=unused-import _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/feedreader.py b/homeassistant/components/feedreader.py index 2c0e146491a60c..782fd8ac8ddce2 100644 --- a/homeassistant/components/feedreader.py +++ b/homeassistant/components/feedreader.py @@ -4,7 +4,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/feedreader/ """ -from datetime import datetime +from datetime import datetime, timedelta from logging import getLogger from os.path import exists from threading import Lock @@ -12,8 +12,8 @@ import voluptuous as vol -from homeassistant.const import EVENT_HOMEASSISTANT_START -from homeassistant.helpers.event import track_utc_time_change +from homeassistant.const import EVENT_HOMEASSISTANT_START, CONF_SCAN_INTERVAL +from homeassistant.helpers.event import track_time_interval import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['feedparser==5.2.1'] @@ -21,16 +21,22 @@ _LOGGER = getLogger(__name__) CONF_URLS = 'urls' +CONF_MAX_ENTRIES = 'max_entries' + +DEFAULT_MAX_ENTRIES = 20 +DEFAULT_SCAN_INTERVAL = timedelta(hours=1) DOMAIN = 'feedreader' EVENT_FEEDREADER = 'feedreader' -MAX_ENTRIES = 20 - CONFIG_SCHEMA = vol.Schema({ DOMAIN: { vol.Required(CONF_URLS): vol.All(cv.ensure_list, [cv.url]), + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): + cv.time_period, + vol.Optional(CONF_MAX_ENTRIES, default=DEFAULT_MAX_ENTRIES): + cv.positive_int } }, extra=vol.ALLOW_EXTRA) @@ -38,33 +44,50 @@ def setup(hass, config): """Set up the Feedreader component.""" urls = config.get(DOMAIN)[CONF_URLS] + scan_interval = config.get(DOMAIN).get(CONF_SCAN_INTERVAL) + max_entries = config.get(DOMAIN).get(CONF_MAX_ENTRIES) data_file = hass.config.path("{}.pickle".format(DOMAIN)) storage = StoredData(data_file) - feeds = [FeedManager(url, hass, storage) for url in urls] + feeds = [FeedManager(url, scan_interval, max_entries, hass, storage) for + url in urls] return len(feeds) > 0 -class FeedManager(object): +class FeedManager: """Abstraction over Feedparser module.""" - def __init__(self, url, hass, storage): - """Initialize the FeedManager object, poll every hour.""" + def __init__(self, url, scan_interval, max_entries, hass, storage): + """Initialize the FeedManager object, poll as per scan interval.""" self._url = url + self._scan_interval = scan_interval + self._max_entries = max_entries self._feed = None self._hass = hass self._firstrun = True self._storage = storage self._last_entry_timestamp = None + self._last_update_successful = False self._has_published_parsed = False + self._event_type = EVENT_FEEDREADER + self._feed_id = url hass.bus.listen_once( EVENT_HOMEASSISTANT_START, lambda _: self._update()) - track_utc_time_change( - hass, lambda now: self._update(), minute=0, second=0) + self._init_regular_updates(hass) def _log_no_entries(self): """Send no entries log at debug level.""" _LOGGER.debug("No new entries to be published in feed %s", self._url) + def _init_regular_updates(self, hass): + """Schedule regular updates at the top of the clock.""" + track_time_interval(hass, lambda now: self._update(), + self._scan_interval) + + @property + def last_update_successful(self): + """Return True if the last feed update was successful.""" + return self._last_update_successful + def _update(self): """Update the feed and publish new entries to the event bus.""" import feedparser @@ -76,26 +99,39 @@ def _update(self): else self._feed.get('modified')) if not self._feed: _LOGGER.error("Error fetching feed data from %s", self._url) + self._last_update_successful = False else: + # The 'bozo' flag really only indicates that there was an issue + # during the initial parsing of the XML, but it doesn't indicate + # whether this is an unrecoverable error. In this case the + # feedparser lib is trying a less strict parsing approach. + # If an error is detected here, log error message but continue + # processing the feed entries if present. if self._feed.bozo != 0: - _LOGGER.error("Error parsing feed %s", self._url) + _LOGGER.error("Error parsing feed %s: %s", self._url, + self._feed.bozo_exception) # Using etag and modified, if there's no new data available, # the entries list will be empty - elif self._feed.entries: + if self._feed.entries: _LOGGER.debug("%s entri(es) available in feed %s", len(self._feed.entries), self._url) - if len(self._feed.entries) > MAX_ENTRIES: - _LOGGER.debug("Processing only the first %s entries " - "in feed %s", MAX_ENTRIES, self._url) - self._feed.entries = self._feed.entries[0:MAX_ENTRIES] + self._filter_entries() self._publish_new_entries() if self._has_published_parsed: self._storage.put_timestamp( - self._url, self._last_entry_timestamp) + self._feed_id, self._last_entry_timestamp) else: self._log_no_entries() + self._last_update_successful = True _LOGGER.info("Fetch from feed %s completed", self._url) + def _filter_entries(self): + """Filter the entries provided and return the ones to keep.""" + if len(self._feed.entries) > self._max_entries: + _LOGGER.debug("Processing only the first %s entries " + "in feed %s", self._max_entries, self._url) + self._feed.entries = self._feed.entries[0:self._max_entries] + def _update_and_fire_entry(self, entry): """Update last_entry_timestamp and fire entry.""" # We are lucky, `published_parsed` data available, let's make use of @@ -109,12 +145,12 @@ def _update_and_fire_entry(self, entry): _LOGGER.debug("No published_parsed info available for entry %s", entry.title) entry.update({'feed_url': self._url}) - self._hass.bus.fire(EVENT_FEEDREADER, entry) + self._hass.bus.fire(self._event_type, entry) def _publish_new_entries(self): """Publish new entries to the event bus.""" new_entries = False - self._last_entry_timestamp = self._storage.get_timestamp(self._url) + self._last_entry_timestamp = self._storage.get_timestamp(self._feed_id) if self._last_entry_timestamp: self._firstrun = False else: @@ -134,7 +170,7 @@ def _publish_new_entries(self): self._firstrun = False -class StoredData(object): +class StoredData: """Abstraction over pickle data storage.""" def __init__(self, data_file): @@ -153,25 +189,25 @@ def _fetch_data(self): with self._lock, open(self._data_file, 'rb') as myfile: self._data = pickle.load(myfile) or {} self._cache_outdated = False - except: # noqa: E722 # pylint: disable=bare-except + except: # noqa: E722 pylint: disable=bare-except _LOGGER.error("Error loading data from pickled file %s", self._data_file) - def get_timestamp(self, url): - """Return stored timestamp for given url.""" + def get_timestamp(self, feed_id): + """Return stored timestamp for given feed id (usually the url).""" self._fetch_data() - return self._data.get(url) + return self._data.get(feed_id) - def put_timestamp(self, url, timestamp): - """Update timestamp for given URL.""" + def put_timestamp(self, feed_id, timestamp): + """Update timestamp for given feed id (usually the url).""" self._fetch_data() with self._lock, open(self._data_file, 'wb') as myfile: - self._data.update({url: timestamp}) + self._data.update({feed_id: timestamp}) _LOGGER.debug("Overwriting feed %s timestamp in storage file %s", - url, self._data_file) + feed_id, self._data_file) try: pickle.dump(self._data, myfile) - except: # noqa: E722 # pylint: disable=bare-except + except: # noqa: E722 pylint: disable=bare-except _LOGGER.error( "Error saving pickled data to %s", self._data_file) self._cache_outdated = True diff --git a/homeassistant/components/ffmpeg.py b/homeassistant/components/ffmpeg.py index e083affe92bccd..9aaae16ee21e35 100644 --- a/homeassistant/components/ffmpeg.py +++ b/homeassistant/components/ffmpeg.py @@ -116,7 +116,7 @@ def async_service_handle(service): return True -class FFmpegManager(object): +class FFmpegManager: """Helper for ha-ffmpeg.""" def __init__(self, hass, ffmpeg_bin, run_test): diff --git a/homeassistant/components/folder_watcher.py b/homeassistant/components/folder_watcher.py index 441106476328f9..098b34ac948cfc 100644 --- a/homeassistant/components/folder_watcher.py +++ b/homeassistant/components/folder_watcher.py @@ -43,7 +43,7 @@ def setup(hass, config): def create_event_handler(patterns, hass): - """"Return the Watchdog EventHandler object.""" + """Return the Watchdog EventHandler object.""" from watchdog.events import PatternMatchingEventHandler class EventHandler(PatternMatchingEventHandler): diff --git a/homeassistant/components/fritzbox.py b/homeassistant/components/fritzbox.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 0d267077991daa..a436cc483ae1e0 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -5,7 +5,6 @@ https://home-assistant.io/components/frontend/ """ import asyncio -import hashlib import json import logging import os @@ -22,15 +21,16 @@ from homeassistant.config import find_config_file, load_yaml_config_file from homeassistant.const import CONF_NAME, EVENT_THEMES_UPDATED from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.translation import async_get_translations from homeassistant.loader import bind_hass +from homeassistant.util.yaml import load_yaml -REQUIREMENTS = ['home-assistant-frontend==20180509.0'] +REQUIREMENTS = ['home-assistant-frontend==20180820.0'] DOMAIN = 'frontend' -DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log'] - -URL_PANEL_COMPONENT_FP = '/frontend/panels/{}-{}.html' +DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', + 'auth', 'onboarding'] CONF_THEMES = 'themes' CONF_EXTRA_HTML_URL = 'extra_html_url' @@ -51,7 +51,7 @@ 'lang': 'en-US', 'name': 'Home Assistant', 'short_name': 'Assistant', - 'start_url': '/states', + 'start_url': '/?homescreen=1', 'theme_color': DEFAULT_THEME_COLOR } @@ -99,9 +99,22 @@ SCHEMA_GET_PANELS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_GET_PANELS, }) +WS_TYPE_GET_THEMES = 'frontend/get_themes' +SCHEMA_GET_THEMES = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_GET_THEMES, +}) +WS_TYPE_GET_TRANSLATIONS = 'frontend/get_translations' +SCHEMA_GET_TRANSLATIONS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_GET_TRANSLATIONS, + vol.Required('language'): str, +}) +WS_TYPE_GET_LOVELACE_UI = 'frontend/lovelace_config' +SCHEMA_GET_LOVELACE_UI = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required('type'): WS_TYPE_GET_LOVELACE_UI, +}) -class AbstractPanel: +class Panel: """Abstract class for panels.""" # Name of the webcomponent @@ -113,30 +126,20 @@ class AbstractPanel: # Title to show in the sidebar (optional) sidebar_title = None - # Url to the webcomponent (depending on JS version) - webcomponent_url_es5 = None - webcomponent_url_latest = None - # Url to show the panel in the frontend frontend_url_path = None # Config to pass to the webcomponent config = None - @asyncio.coroutine - def async_register(self, hass): - """Register panel with HASS.""" - panels = hass.data.get(DATA_PANELS) - if panels is None: - panels = hass.data[DATA_PANELS] = {} - - if self.frontend_url_path in panels: - _LOGGER.warning("Overwriting component %s", self.frontend_url_path) - - if DATA_FINALIZE_PANEL in hass.data: - yield from hass.data[DATA_FINALIZE_PANEL](self) - - panels[self.frontend_url_path] = self + def __init__(self, component_name, sidebar_title, sidebar_icon, + frontend_url_path, config): + """Initialize a built-in panel.""" + self.component_name = component_name + self.sidebar_title = sidebar_title + self.sidebar_icon = sidebar_icon + self.frontend_url_path = frontend_url_path or component_name + self.config = config @callback def async_register_index_routes(self, router, index_view): @@ -147,134 +150,37 @@ def async_register_index_routes(self, router, index_view): 'get', '/{}/{{extra:.+}}'.format(self.frontend_url_path), index_view.get) + @callback def to_response(self, hass, request): """Panel as dictionary.""" - result = { + return { 'component_name': self.component_name, 'icon': self.sidebar_icon, 'title': self.sidebar_title, - 'url_path': self.frontend_url_path, 'config': self.config, + 'url_path': self.frontend_url_path, } - if _is_latest(hass.data[DATA_JS_VERSION], request): - result['url'] = self.webcomponent_url_latest - else: - result['url'] = self.webcomponent_url_es5 - return result - - -class BuiltInPanel(AbstractPanel): - """Panel that is part of hass_frontend.""" - - def __init__(self, component_name, sidebar_title, sidebar_icon, - frontend_url_path, config): - """Initialize a built-in panel.""" - self.component_name = component_name - self.sidebar_title = sidebar_title - self.sidebar_icon = sidebar_icon - self.frontend_url_path = frontend_url_path or component_name - self.config = config - - @asyncio.coroutine - def async_finalize(self, hass, frontend_repository_path): - """Finalize this panel for usage. - - If frontend_repository_path is set, will be prepended to path of - built-in components. - """ - if frontend_repository_path is None: - import hass_frontend - import hass_frontend_es5 - - self.webcomponent_url_latest = \ - '/frontend_latest/panels/ha-panel-{}-{}.html'.format( - self.component_name, - hass_frontend.FINGERPRINTS[self.component_name]) - self.webcomponent_url_es5 = \ - '/frontend_es5/panels/ha-panel-{}-{}.html'.format( - self.component_name, - hass_frontend_es5.FINGERPRINTS[self.component_name]) - else: - # Dev mode - self.webcomponent_url_es5 = self.webcomponent_url_latest = \ - '/home-assistant-polymer/panels/{}/ha-panel-{}.html'.format( - self.component_name, self.component_name) - - -class ExternalPanel(AbstractPanel): - """Panel that is added by a custom component.""" - - REGISTERED_COMPONENTS = set() - - def __init__(self, component_name, path, md5, sidebar_title, sidebar_icon, - frontend_url_path, config): - """Initialize an external panel.""" - self.component_name = component_name - self.path = path - self.md5 = md5 - self.sidebar_title = sidebar_title - self.sidebar_icon = sidebar_icon - self.frontend_url_path = frontend_url_path or component_name - self.config = config - - @asyncio.coroutine - def async_finalize(self, hass, frontend_repository_path): - """Finalize this panel for usage. - - frontend_repository_path is set, will be prepended to path of built-in - components. - """ - try: - if self.md5 is None: - self.md5 = yield from hass.async_add_job( - _fingerprint, self.path) - except OSError: - _LOGGER.error('Cannot find or access %s at %s', - self.component_name, self.path) - hass.data[DATA_PANELS].pop(self.frontend_url_path) - return - - self.webcomponent_url_es5 = self.webcomponent_url_latest = \ - URL_PANEL_COMPONENT_FP.format(self.component_name, self.md5) - - if self.component_name not in self.REGISTERED_COMPONENTS: - hass.http.register_static_path( - self.webcomponent_url_latest, self.path, - # if path is None, we're in prod mode, so cache static assets - frontend_repository_path is None) - self.REGISTERED_COMPONENTS.add(self.component_name) @bind_hass -@asyncio.coroutine -def async_register_built_in_panel(hass, component_name, sidebar_title=None, - sidebar_icon=None, frontend_url_path=None, - config=None): +async def async_register_built_in_panel(hass, component_name, + sidebar_title=None, sidebar_icon=None, + frontend_url_path=None, config=None): """Register a built-in panel.""" - panel = BuiltInPanel(component_name, sidebar_title, sidebar_icon, - frontend_url_path, config) - yield from panel.async_register(hass) + panel = Panel(component_name, sidebar_title, sidebar_icon, + frontend_url_path, config) + panels = hass.data.get(DATA_PANELS) + if panels is None: + panels = hass.data[DATA_PANELS] = {} -@bind_hass -@asyncio.coroutine -def async_register_panel(hass, component_name, path, md5=None, - sidebar_title=None, sidebar_icon=None, - frontend_url_path=None, config=None): - """Register a panel for the frontend. - - component_name: name of the web component - path: path to the HTML of the web component - (required unless url is provided) - md5: the md5 hash of the web component (for versioning in URL, optional) - sidebar_title: title to show in the sidebar (optional) - sidebar_icon: icon to show next to title in sidebar (optional) - url_path: name to use in the URL (defaults to component_name) - config: config to be passed into the web component - """ - panel = ExternalPanel(component_name, path, md5, sidebar_title, - sidebar_icon, frontend_url_path, config) - yield from panel.async_register(hass) + if panel.frontend_url_path in panels: + _LOGGER.warning("Overwriting component %s", panel.frontend_url_path) + + if DATA_FINALIZE_PANEL in hass.data: + hass.data[DATA_FINALIZE_PANEL](panel) + + panels[panel.frontend_url_path] = panel @bind_hass @@ -293,11 +199,18 @@ def add_manifest_json_key(key, val): MANIFEST_JSON[key] = val -@asyncio.coroutine -def async_setup(hass, config): +async def async_setup(hass, config): """Set up the serving of the frontend.""" hass.components.websocket_api.async_register_command( - WS_TYPE_GET_PANELS, websocket_handle_get_panels, SCHEMA_GET_PANELS) + WS_TYPE_GET_PANELS, websocket_get_panels, SCHEMA_GET_PANELS) + hass.components.websocket_api.async_register_command( + WS_TYPE_GET_THEMES, websocket_get_themes, SCHEMA_GET_THEMES) + hass.components.websocket_api.async_register_command( + WS_TYPE_GET_TRANSLATIONS, websocket_get_translations, + SCHEMA_GET_TRANSLATIONS) + hass.components.websocket_api.async_register_command( + WS_TYPE_GET_LOVELACE_UI, websocket_lovelace_config, + SCHEMA_GET_LOVELACE_UI) hass.http.register_view(ManifestJSONView) conf = config.get(DOMAIN, {}) @@ -307,73 +220,54 @@ def async_setup(hass, config): hass.data[DATA_JS_VERSION] = js_version = conf.get(CONF_JS_VERSION) if is_dev: - for subpath in ["src", "build-translations", "build-temp", "build", - "hass_frontend", "bower_components", "panels", - "hassio"]: - hass.http.register_static_path( - "/home-assistant-polymer/{}".format(subpath), - os.path.join(repo_path, subpath), - False) - - hass.http.register_static_path( - "/static/translations", - os.path.join(repo_path, "build-translations/output"), False) - sw_path_es5 = os.path.join(repo_path, "build-es5/service_worker.js") - sw_path_latest = os.path.join(repo_path, "build/service_worker.js") - static_path = os.path.join(repo_path, 'hass_frontend') - frontend_es5_path = os.path.join(repo_path, 'build-es5') - frontend_latest_path = os.path.join(repo_path, 'build') + hass_frontend_path = os.path.join(repo_path, 'hass_frontend') + hass_frontend_es5_path = os.path.join(repo_path, 'hass_frontend_es5') else: import hass_frontend import hass_frontend_es5 - sw_path_es5 = os.path.join(hass_frontend_es5.where(), - "service_worker.js") - sw_path_latest = os.path.join(hass_frontend.where(), - "service_worker.js") - # /static points to dir with files that are JS-type agnostic. - # ES5 files are served from /frontend_es5. - # ES6 files are served from /frontend_latest. - static_path = hass_frontend.where() - frontend_es5_path = hass_frontend_es5.where() - frontend_latest_path = static_path + hass_frontend_path = hass_frontend.where() + hass_frontend_es5_path = hass_frontend_es5.where() hass.http.register_static_path( - "/service_worker_es5.js", sw_path_es5, False) + "/service_worker_es5.js", + os.path.join(hass_frontend_es5_path, "service_worker.js"), False) hass.http.register_static_path( - "/service_worker.js", sw_path_latest, False) + "/service_worker.js", + os.path.join(hass_frontend_path, "service_worker.js"), False) hass.http.register_static_path( - "/robots.txt", os.path.join(static_path, "robots.txt"), not is_dev) - hass.http.register_static_path("/static", static_path, not is_dev) + "/robots.txt", + os.path.join(hass_frontend_path, "robots.txt"), False) + hass.http.register_static_path("/static", hass_frontend_path, not is_dev) hass.http.register_static_path( - "/frontend_latest", frontend_latest_path, not is_dev) + "/frontend_latest", hass_frontend_path, not is_dev) hass.http.register_static_path( - "/frontend_es5", frontend_es5_path, not is_dev) + "/frontend_es5", hass_frontend_es5_path, not is_dev) local = hass.config.path('www') if os.path.isdir(local): hass.http.register_static_path("/local", local, not is_dev) - index_view = IndexView(repo_path, js_version) + index_view = IndexView(repo_path, js_version, hass.auth.active) hass.http.register_view(index_view) + hass.http.register_view(AuthorizeView(repo_path, js_version)) - @asyncio.coroutine - def finalize_panel(panel): + @callback + def async_finalize_panel(panel): """Finalize setup of a panel.""" - yield from panel.async_finalize(hass, repo_path) panel.async_register_index_routes(hass.http.app.router, index_view) - yield from asyncio.wait([ - async_register_built_in_panel(hass, panel) - for panel in ('dev-event', 'dev-info', 'dev-service', 'dev-state', - 'dev-template', 'dev-mqtt', 'kiosk')], loop=hass.loop) + await asyncio.wait( + [async_register_built_in_panel(hass, panel) for panel in ( + 'dev-event', 'dev-info', 'dev-service', 'dev-state', + 'dev-template', 'dev-mqtt', 'kiosk', 'lovelace', 'profile')], + loop=hass.loop) - hass.data[DATA_FINALIZE_PANEL] = finalize_panel + hass.data[DATA_FINALIZE_PANEL] = async_finalize_panel # Finalize registration of panels that registered before frontend was setup # This includes the built-in panels from line above. - yield from asyncio.wait( - [finalize_panel(panel) for panel in hass.data[DATA_PANELS].values()], - loop=hass.loop) + for panel in hass.data[DATA_PANELS].values(): + async_finalize_panel(panel) if DATA_EXTRA_HTML_URL not in hass.data: hass.data[DATA_EXTRA_HTML_URL] = set() @@ -385,16 +279,14 @@ def finalize_panel(panel): for url in conf.get(CONF_EXTRA_HTML_URL_ES5, []): add_extra_html_url(hass, url, True) - async_setup_themes(hass, conf.get(CONF_THEMES)) - - hass.http.register_view(TranslationsView) + _async_setup_themes(hass, conf.get(CONF_THEMES)) return True -def async_setup_themes(hass, themes): +@callback +def _async_setup_themes(hass, themes): """Set up themes data and services.""" - hass.http.register_view(ThemesView) hass.data[DATA_DEFAULT_THEME] = DEFAULT_THEME if themes is None: hass.data[DATA_THEMES] = {} @@ -443,6 +335,35 @@ def reload_themes(_): hass.services.async_register(DOMAIN, SERVICE_RELOAD_THEMES, reload_themes) +class AuthorizeView(HomeAssistantView): + """Serve the frontend.""" + + url = '/auth/authorize' + name = 'auth:authorize' + requires_auth = False + + def __init__(self, repo_path, js_option): + """Initialize the frontend view.""" + self.repo_path = repo_path + self.js_option = js_option + + async def get(self, request: web.Request): + """Redirect to the authorize page.""" + latest = self.repo_path is not None or \ + _is_latest(self.js_option, request) + + if latest: + location = '/frontend_latest/authorize.html' + else: + location = '/frontend_es5/authorize.html' + + location += '?{}'.format(request.query_string) + + return web.Response(status=302, headers={ + 'location': location + }) + + class IndexView(HomeAssistantView): """Serve the frontend.""" @@ -451,16 +372,17 @@ class IndexView(HomeAssistantView): requires_auth = False extra_urls = ['/states', '/states/{extra}'] - def __init__(self, repo_path, js_option): + def __init__(self, repo_path, js_option, auth_active): """Initialize the frontend view.""" self.repo_path = repo_path self.js_option = js_option + self.auth_active = auth_active self._template_cache = {} def get_template(self, latest): """Get template.""" if self.repo_path is not None: - root = self.repo_path + root = os.path.join(self.repo_path, 'hass_frontend') elif latest: import hass_frontend root = hass_frontend.where() @@ -480,43 +402,42 @@ def get_template(self, latest): return tpl - @asyncio.coroutine - def get(self, request, extra=None): + async def get(self, request, extra=None): """Serve the index view.""" hass = request.app['hass'] latest = self.repo_path is not None or \ _is_latest(self.js_option, request) - if request.path == '/': - panel = 'states' - else: - panel = request.path.split('/')[1] + if not hass.components.onboarding.async_is_onboarded(): + if latest: + location = '/frontend_latest/onboarding.html' + else: + location = '/frontend_es5/onboarding.html' - if panel == 'states': - panel_url = '' - elif latest: - panel_url = hass.data[DATA_PANELS][panel].webcomponent_url_latest - else: - panel_url = hass.data[DATA_PANELS][panel].webcomponent_url_es5 + return web.Response(status=302, headers={ + 'location': location + }) no_auth = '1' if hass.config.api.api_password and not request[KEY_AUTHENTICATED]: # do not try to auto connect on load no_auth = '0' - template = yield from hass.async_add_job(self.get_template, latest) + use_oauth = '1' if self.auth_active else '0' + + template = await hass.async_add_job(self.get_template, latest) extra_key = DATA_EXTRA_HTML_URL if latest else DATA_EXTRA_HTML_URL_ES5 - resp = template.render( + template_params = dict( no_auth=no_auth, - panel_url=panel_url, - panels=hass.data[DATA_PANELS], theme_color=MANIFEST_JSON['theme_color'], extra_urls=hass.data[extra_key], + use_oauth=use_oauth ) - return web.Response(text=resp, content_type='text/html') + return web.Response(text=template.render(**template_params), + content_type='text/html') class ManifestJSONView(HomeAssistantView): @@ -526,54 +447,13 @@ class ManifestJSONView(HomeAssistantView): url = '/manifest.json' name = 'manifestjson' - @asyncio.coroutine + @callback def get(self, request): # pylint: disable=no-self-use """Return the manifest.json.""" msg = json.dumps(MANIFEST_JSON, sort_keys=True) return web.Response(text=msg, content_type="application/manifest+json") -class ThemesView(HomeAssistantView): - """View to return defined themes.""" - - requires_auth = False - url = '/api/themes' - name = 'api:themes' - - @callback - def get(self, request): - """Return themes.""" - hass = request.app['hass'] - - return self.json({ - 'themes': hass.data[DATA_THEMES], - 'default_theme': hass.data[DATA_DEFAULT_THEME], - }) - - -class TranslationsView(HomeAssistantView): - """View to return backend defined translations.""" - - url = '/api/translations/{language}' - name = 'api:translations' - - @asyncio.coroutine - def get(self, request, language): - """Return translations.""" - hass = request.app['hass'] - - resources = yield from async_get_translations(hass, language) - return self.json({ - 'resources': resources, - }) - - -def _fingerprint(path): - """Fingerprint a file.""" - with open(path) as fil: - return hashlib.md5(fil.read().encode('utf-8')).hexdigest() - - def _is_latest(js_option, request): """ Return whether we should serve latest untranspiled code. @@ -607,7 +487,7 @@ def _is_latest(js_option, request): @callback -def websocket_handle_get_panels(hass, connection, msg): +def websocket_get_panels(hass, connection, msg): """Handle get panels command. Async friendly. @@ -620,3 +500,58 @@ def websocket_handle_get_panels(hass, connection, msg): connection.to_write.put_nowait(websocket_api.result_message( msg['id'], panels)) + + +@callback +def websocket_get_themes(hass, connection, msg): + """Handle get themes command. + + Async friendly. + """ + connection.to_write.put_nowait(websocket_api.result_message(msg['id'], { + 'themes': hass.data[DATA_THEMES], + 'default_theme': hass.data[DATA_DEFAULT_THEME], + })) + + +@callback +def websocket_get_translations(hass, connection, msg): + """Handle get translations command. + + Async friendly. + """ + async def send_translations(): + """Send a translation.""" + resources = await async_get_translations(hass, msg['language']) + connection.send_message_outside(websocket_api.result_message( + msg['id'], { + 'resources': resources, + } + )) + + hass.async_add_job(send_translations()) + + +def websocket_lovelace_config(hass, connection, msg): + """Send lovelace UI config over websocket config.""" + async def send_exp_config(): + """Send lovelace frontend config.""" + error = None + try: + config = await hass.async_add_job( + load_yaml, hass.config.path('ui-lovelace.yaml')) + message = websocket_api.result_message( + msg['id'], config + ) + except FileNotFoundError: + error = ('file_not_found', + 'Could not find ui-lovelace.yaml in your config dir.') + except HomeAssistantError as err: + error = 'load_error', str(err) + + if error is not None: + message = websocket_api.error_message(msg['id'], *error) + + connection.send_message_outside(message) + + hass.async_add_job(send_exp_config()) diff --git a/homeassistant/components/frontend/www_static/images/logo_tellduslive.png b/homeassistant/components/frontend/www_static/images/logo_tellduslive.png deleted file mode 100644 index 7ea78f8ef3aad4..00000000000000 Binary files a/homeassistant/components/frontend/www_static/images/logo_tellduslive.png and /dev/null differ diff --git a/homeassistant/components/gc100.py b/homeassistant/components/gc100.py index bc627d4441796d..0d4b19da03009d 100644 --- a/homeassistant/components/gc100.py +++ b/homeassistant/components/gc100.py @@ -31,7 +31,7 @@ }, extra=vol.ALLOW_EXTRA) -# pylint: disable=no-member, import-self +# pylint: disable=no-member def setup(hass, base_config): """Set up the gc100 component.""" import gc100 @@ -53,7 +53,7 @@ def cleanup_gc100(event): return True -class GC100Device(object): +class GC100Device: """The GC100 component.""" def __init__(self, hass, gc_device): diff --git a/homeassistant/components/google.py b/homeassistant/components/google.py index b41d4ea33a20b1..e37b3ba7ff7e57 100644 --- a/homeassistant/components/google.py +++ b/homeassistant/components/google.py @@ -25,6 +25,7 @@ REQUIREMENTS = [ 'google-api-python-client==1.6.4', + 'httplib2==0.10.3', 'oauth2client==4.0.0', ] @@ -197,7 +198,7 @@ def _found_calendar(call): def _scan_for_calendars(service): """Scan for new calendars.""" service = calendar_service.get() - cal_list = service.calendarList() # pylint: disable=no-member + cal_list = service.calendarList() calendars = cal_list.list().execute()['items'] for calendar in calendars: calendar['track'] = track_new_found_calendars @@ -230,7 +231,7 @@ def do_setup(hass, config): return True -class GoogleCalendarService(object): +class GoogleCalendarService: """Calendar service interface to Google.""" def __init__(self, token_file): diff --git a/homeassistant/components/google_assistant/__init__.py b/homeassistant/components/google_assistant/__init__.py index 1c6d11a7c99216..567a6d842339ca 100644 --- a/homeassistant/components/google_assistant/__init__.py +++ b/homeassistant/components/google_assistant/__init__.py @@ -13,9 +13,8 @@ import voluptuous as vol # Typing imports -# pylint: disable=using-constant-test,unused-import,ungrouped-imports -from homeassistant.core import HomeAssistant # NOQA -from typing import Dict, Any # NOQA +from homeassistant.core import HomeAssistant +from typing import Dict, Any from homeassistant.const import CONF_NAME from homeassistant.helpers import config_validation as cv diff --git a/homeassistant/components/google_assistant/auth.py b/homeassistant/components/google_assistant/auth.py index a21dd0e673859d..e80b2282066b73 100644 --- a/homeassistant/components/google_assistant/auth.py +++ b/homeassistant/components/google_assistant/auth.py @@ -3,12 +3,11 @@ import logging # Typing imports -# pylint: disable=using-constant-test,unused-import,ungrouped-imports # if False: -from aiohttp.web import Request, Response # NOQA -from typing import Dict, Any # NOQA +from aiohttp.web import Request, Response +from typing import Dict, Any -from homeassistant.core import HomeAssistant # NOQA +from homeassistant.core import HomeAssistant from homeassistant.components.http import HomeAssistantView from homeassistant.const import ( HTTP_BAD_REQUEST, diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index 0ea5f7d9fa4379..05bc3cbd01c0a4 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -7,13 +7,11 @@ import logging from aiohttp.hdrs import AUTHORIZATION -from aiohttp.web import Request, Response # NOQA +from aiohttp.web import Request, Response # Typing imports -# pylint: disable=using-constant-test,unused-import,ungrouped-imports from homeassistant.components.http import HomeAssistantView -from homeassistant.core import HomeAssistant, callback # NOQA -from homeassistant.helpers.entity import Entity # NOQA +from homeassistant.core import callback from .const import ( GOOGLE_ASSISTANT_API_ENDPOINT, diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 27d993aee76abd..63a3e641170b1b 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -1,16 +1,8 @@ """Support for Google Assistant Smart Home API.""" -import collections +from collections.abc import Mapping from itertools import product import logging -# Typing imports -# pylint: disable=using-constant-test,unused-import,ungrouped-imports -# if False: -from aiohttp.web import Request, Response # NOQA -from typing import Dict, Tuple, Any, Optional # NOQA -from homeassistant.helpers.entity import Entity # NOQA -from homeassistant.core import HomeAssistant # NOQA -from homeassistant.util.unit_system import UnitSystem # NOQA from homeassistant.util.decorator import Registry from homeassistant.core import callback @@ -58,7 +50,7 @@ def deep_update(target, source): """Update a nested dictionary with another nested dictionary.""" for key, value in source.items(): - if isinstance(value, collections.Mapping): + if isinstance(value, Mapping): target[key] = deep_update(target.get(key, {}), value) else: target[key] = value diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index 2f60f226042bb7..1d369eb87dad9e 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -106,9 +106,9 @@ def supported(domain, features): """Test if state is supported.""" if domain == light.DOMAIN: return features & light.SUPPORT_BRIGHTNESS - elif domain == cover.DOMAIN: + if domain == cover.DOMAIN: return features & cover.SUPPORT_SET_POSITION - elif domain == media_player.DOMAIN: + if domain == media_player.DOMAIN: return features & media_player.SUPPORT_VOLUME_SET return False @@ -304,10 +304,12 @@ def supported(domain, features): def sync_attributes(self): """Return color temperature attributes for a sync request.""" attrs = self.state.attributes + # Max Kelvin is Min Mireds K = 1000000 / mireds + # Min Kevin is Max Mireds K = 1000000 / mireds return { - 'temperatureMinK': color_util.color_temperature_mired_to_kelvin( - attrs.get(light.ATTR_MIN_MIREDS)), 'temperatureMaxK': color_util.color_temperature_mired_to_kelvin( + attrs.get(light.ATTR_MIN_MIREDS)), + 'temperatureMinK': color_util.color_temperature_mired_to_kelvin( attrs.get(light.ATTR_MAX_MIREDS)), } diff --git a/homeassistant/components/graphite.py b/homeassistant/components/graphite.py index e4626d0f016e0c..2b768bc3786875 100644 --- a/homeassistant/components/graphite.py +++ b/homeassistant/components/graphite.py @@ -137,8 +137,8 @@ def run(self): _LOGGER.debug("Event processing thread stopped") self._queue.task_done() return - elif (event.event_type == EVENT_STATE_CHANGED and - event.data.get('new_state')): + if event.event_type == EVENT_STATE_CHANGED and \ + event.data.get('new_state'): _LOGGER.debug("Processing STATE_CHANGED event for %s", event.data['entity_id']) try: diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index f70a2d293516b1..a33e91f3aa959e 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -14,7 +14,7 @@ ATTR_ENTITY_ID, CONF_ICON, CONF_NAME, STATE_CLOSED, STATE_HOME, STATE_NOT_HOME, STATE_OFF, STATE_ON, STATE_OPEN, STATE_LOCKED, STATE_UNLOCKED, STATE_OK, STATE_PROBLEM, STATE_UNKNOWN, - ATTR_ASSUMED_STATE, SERVICE_RELOAD) + ATTR_ASSUMED_STATE, SERVICE_RELOAD, ATTR_NAME, ATTR_ICON) from homeassistant.core import callback from homeassistant.loader import bind_hass from homeassistant.helpers.entity import Entity, async_generate_entity_id @@ -35,8 +35,6 @@ ATTR_AUTO = 'auto' ATTR_CONTROL = 'control' ATTR_ENTITIES = 'entities' -ATTR_ICON = 'icon' -ATTR_NAME = 'name' ATTR_OBJECT_ID = 'object_id' ATTR_ORDER = 'order' ATTR_VIEW = 'view' diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index 87251a2745c57c..e0356017e3ebbe 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -13,12 +13,13 @@ from homeassistant.components import SERVICE_CHECK_CONFIG from homeassistant.const import ( - SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP) + ATTR_NAME, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP) from homeassistant.core import DOMAIN as HASS_DOMAIN from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.loader import bind_hass from homeassistant.util.dt import utcnow + from .handler import HassIO from .http import HassIOView @@ -26,6 +27,17 @@ DOMAIN = 'hassio' DEPENDENCIES = ['http'] +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 + +CONF_FRONTEND_REPO = 'development_repo' + +CONFIG_SCHEMA = vol.Schema({ + vol.Optional(DOMAIN): vol.Schema({ + vol.Optional(CONF_FRONTEND_REPO): cv.isdir, + }), +}, extra=vol.ALLOW_EXTRA) + DATA_HOMEASSISTANT_VERSION = 'hassio_hass_version' HASSIO_UPDATE_INTERVAL = timedelta(minutes=55) @@ -47,7 +59,6 @@ ATTR_ADDONS = 'addons' ATTR_FOLDERS = 'folders' ATTR_HOMEASSISTANT = 'homeassistant' -ATTR_NAME = 'name' ATTR_PASSWORD = 'password' SCHEMA_NO_DATA = vol.Schema({}) @@ -131,7 +142,7 @@ def async_check_config(hass): if not result: return "Hass.io config check API error" - elif result['result'] == "error": + if result['result'] == "error": return result['message'] return None @@ -142,7 +153,13 @@ def async_setup(hass, config): try: host = os.environ['HASSIO'] except KeyError: - _LOGGER.error("No Hass.io supervisor detect") + _LOGGER.error("Missing HASSIO environment variable.") + return False + + try: + os.environ['HASSIO_TOKEN'] + except KeyError: + _LOGGER.error("Missing HASSIO_TOKEN environment variable.") return False websession = hass.helpers.aiohttp_client.async_get_clientsession() @@ -152,14 +169,50 @@ def async_setup(hass, config): _LOGGER.error("Not connected with Hass.io") return False + store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + data = yield from store.async_load() + + if data is None: + data = {} + + refresh_token = None + if 'hassio_user' in data: + user = yield from hass.auth.async_get_user(data['hassio_user']) + if user and user.refresh_tokens: + refresh_token = list(user.refresh_tokens.values())[0] + + if refresh_token is None: + user = yield from hass.auth.async_create_system_user('Hass.io') + refresh_token = yield from hass.auth.async_create_refresh_token(user) + data['hassio_user'] = user.id + yield from store.async_save(data) + + # This overrides the normal API call that would be forwarded + development_repo = config.get(DOMAIN, {}).get(CONF_FRONTEND_REPO) + if development_repo is not None: + hass.http.register_static_path( + '/api/hassio/app', + os.path.join(development_repo, 'hassio/build'), False) + hass.http.register_view(HassIOView(host, websession)) if 'frontend' in hass.config.components: - yield from hass.components.frontend.async_register_built_in_panel( - 'hassio', 'Hass.io', 'mdi:home-assistant') + yield from hass.components.panel_custom.async_register_panel( + frontend_url_path='hassio', + webcomponent_name='hassio-main', + sidebar_title='Hass.io', + sidebar_icon='hass:home-assistant', + js_url='/api/hassio/app/entrypoint.js', + embed_iframe=True, + ) + + # Temporary. No refresh token tells supervisor to use API password. + if hass.auth.active: + token = refresh_token.token + else: + token = None - if 'http' in config: - yield from hassio.update_hass_api(config['http']) + yield from hassio.update_hass_api(config.get('http', {}), token) if 'homeassistant' in config: yield from hassio.update_hass_timezone(config['homeassistant']) diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index a954aaccbd4cc5..d75529a99b03fb 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -23,21 +23,19 @@ def _api_bool(funct): """Return a boolean.""" - @asyncio.coroutine - def _wrapper(*argv, **kwargs): + async def _wrapper(*argv, **kwargs): """Wrap function.""" - data = yield from funct(*argv, **kwargs) + data = await funct(*argv, **kwargs) return data and data['result'] == "ok" return _wrapper def _api_data(funct): - """Return a api data.""" - @asyncio.coroutine - def _wrapper(*argv, **kwargs): + """Return data of an api.""" + async def _wrapper(*argv, **kwargs): """Wrap function.""" - data = yield from funct(*argv, **kwargs) + data = await funct(*argv, **kwargs) if data and data['result'] == "ok": return data['data'] return None @@ -45,7 +43,7 @@ def _wrapper(*argv, **kwargs): return _wrapper -class HassIO(object): +class HassIO: """Small API wrapper for Hass.io.""" def __init__(self, loop, websession, ip): @@ -94,24 +92,23 @@ def check_homeassistant_config(self): return self.send_command("/homeassistant/check", timeout=300) @_api_bool - def update_hass_api(self, http_config): - """Update Home Assistant API data on Hass.io. - - This method return a coroutine. - """ + async def update_hass_api(self, http_config, refresh_token): + """Update Home Assistant API data on Hass.io.""" port = http_config.get(CONF_SERVER_PORT) or SERVER_PORT options = { 'ssl': CONF_SSL_CERTIFICATE in http_config, 'port': port, 'password': http_config.get(CONF_API_PASSWORD), 'watchdog': True, + 'refresh_token': refresh_token, } if CONF_SERVER_HOST in http_config: options['watchdog'] = False _LOGGER.warning("Don't use 'server_host' options with Hass.io") - return self.send_command("/homeassistant/options", payload=options) + return await self.send_command("/homeassistant/options", + payload=options) @_api_bool def update_hass_timezone(self, core_config): diff --git a/homeassistant/components/hassio/http.py b/homeassistant/components/hassio/http.py index 9dd6427ec38b1b..c51d45cc3396eb 100644 --- a/homeassistant/components/hassio/http.py +++ b/homeassistant/components/hassio/http.py @@ -36,7 +36,7 @@ } NO_AUTH = { - re.compile(r'^app-(es5|latest)/(index|hassio-app).html$'), + re.compile(r'^app/.*$'), re.compile(r'^addons/[^/]*/logo$') } diff --git a/homeassistant/components/history.py b/homeassistant/components/history.py index c27e394ce28e52..21d4cdc6e56087 100644 --- a/homeassistant/components/history.py +++ b/homeassistant/components/history.py @@ -274,7 +274,7 @@ async def async_setup(hass, config): hass.http.register_view(HistoryPeriodView(filters, use_include_order)) await hass.components.frontend.async_register_built_in_panel( - 'history', 'history', 'mdi:poll-box') + 'history', 'history', 'hass:poll-box') return True @@ -353,7 +353,7 @@ async def get(self, request, datetime=None): return await hass.async_add_job(self.json, result) -class Filters(object): +class Filters: """Container for the configured include and exclude filters.""" def __init__(self): diff --git a/homeassistant/components/homekit/__init__.py b/homeassistant/components/homekit/__init__.py index c31093a5eb8ebe..ad2f8b4ac6d053 100644 --- a/homeassistant/components/homekit/__init__.py +++ b/homeassistant/components/homekit/__init__.py @@ -9,28 +9,29 @@ import voluptuous as vol -from homeassistant.components.cover import ( - SUPPORT_CLOSE, SUPPORT_OPEN, SUPPORT_SET_POSITION) +from homeassistant.components import cover from homeassistant.const import ( - ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, - ATTR_DEVICE_CLASS, CONF_IP_ADDRESS, CONF_PORT, TEMP_CELSIUS, - TEMP_FAHRENHEIT, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, - DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE) + ATTR_DEVICE_CLASS, ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, + CONF_IP_ADDRESS, CONF_NAME, CONF_PORT, CONF_TYPE, DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, + TEMP_CELSIUS, TEMP_FAHRENHEIT) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entityfilter import FILTER_SCHEMA from homeassistant.util import get_local_ip from homeassistant.util.decorator import Registry from .const import ( - DOMAIN, HOMEKIT_FILE, CONF_AUTO_START, CONF_ENTITY_CONFIG, CONF_FILTER, - DEFAULT_PORT, DEFAULT_AUTO_START, SERVICE_HOMEKIT_START, - DEVICE_CLASS_CO2, DEVICE_CLASS_PM25) + BRIDGE_NAME, CONF_AUTO_START, CONF_ENTITY_CONFIG, CONF_FEATURE_LIST, + CONF_FILTER, DEFAULT_AUTO_START, DEFAULT_PORT, DEVICE_CLASS_CO2, + DEVICE_CLASS_PM25, DOMAIN, HOMEKIT_FILE, SERVICE_HOMEKIT_START, + TYPE_OUTLET, TYPE_SWITCH) from .util import ( - validate_entity_config, show_setup_message) + show_setup_message, validate_entity_config, validate_media_player_features) TYPES = Registry() _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['HAP-python==2.0.0'] +REQUIREMENTS = ['HAP-python==2.2.2'] # #### Driver Status #### STATUS_READY = 0 @@ -38,9 +39,13 @@ STATUS_STOPPED = 2 STATUS_WAIT = 3 +SWITCH_TYPES = {TYPE_OUTLET: 'Outlet', + TYPE_SWITCH: 'Switch'} CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.All({ + vol.Optional(CONF_NAME, default=BRIDGE_NAME): + vol.All(cv.string, vol.Length(min=3, max=25)), vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_IP_ADDRESS): vol.All(ipaddress.ip_address, cv.string), @@ -56,13 +61,15 @@ async def async_setup(hass, config): _LOGGER.debug('Begin setup HomeKit') conf = config[DOMAIN] + name = conf[CONF_NAME] port = conf[CONF_PORT] ip_address = conf.get(CONF_IP_ADDRESS) auto_start = conf[CONF_AUTO_START] entity_filter = conf[CONF_FILTER] entity_config = conf[CONF_ENTITY_CONFIG] - homekit = HomeKit(hass, port, ip_address, entity_filter, entity_config) + homekit = HomeKit(hass, name, port, ip_address, entity_filter, + entity_config) await hass.async_add_job(homekit.setup) if auto_start: @@ -84,7 +91,7 @@ def handle_homekit_service_start(service): return True -def get_accessory(hass, state, aid, config): +def get_accessory(hass, driver, state, aid, config): """Take state and return an accessory object if supported.""" if not aid: _LOGGER.warning('The entitiy "%s" is not supported, since it ' @@ -93,7 +100,7 @@ def get_accessory(hass, state, aid, config): return None a_type = None - config = config or {} + name = config.get(CONF_NAME, state.name) if state.domain == 'alarm_control_panel': a_type = 'SecuritySystem' @@ -105,26 +112,35 @@ def get_accessory(hass, state, aid, config): a_type = 'Thermostat' elif state.domain == 'cover': - features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) device_class = state.attributes.get(ATTR_DEVICE_CLASS) + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) if device_class == 'garage' and \ - features & (SUPPORT_OPEN | SUPPORT_CLOSE): + features & (cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE): a_type = 'GarageDoorOpener' - elif features & SUPPORT_SET_POSITION: + elif features & cover.SUPPORT_SET_POSITION: a_type = 'WindowCovering' - elif features & (SUPPORT_OPEN | SUPPORT_CLOSE): + elif features & (cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE): a_type = 'WindowCoveringBasic' + elif state.domain == 'fan': + a_type = 'Fan' + elif state.domain == 'light': a_type = 'Light' elif state.domain == 'lock': a_type = 'Lock' + elif state.domain == 'media_player': + feature_list = config.get(CONF_FEATURE_LIST) + if feature_list and \ + validate_media_player_features(state, feature_list): + a_type = 'MediaPlayer' + elif state.domain == 'sensor': - unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) device_class = state.attributes.get(ATTR_DEVICE_CLASS) + unit = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if device_class == DEVICE_CLASS_TEMPERATURE or \ unit in (TEMP_CELSIUS, TEMP_FAHRENHEIT): @@ -140,20 +156,24 @@ def get_accessory(hass, state, aid, config): elif device_class == DEVICE_CLASS_ILLUMINANCE or unit in ('lm', 'lx'): a_type = 'LightSensor' - elif state.domain in ('switch', 'remote', 'input_boolean', 'script'): + elif state.domain == 'switch': + switch_type = config.get(CONF_TYPE, TYPE_SWITCH) + a_type = SWITCH_TYPES[switch_type] + + elif state.domain in ('automation', 'input_boolean', 'remote', 'script'): a_type = 'Switch' if a_type is None: return None _LOGGER.debug('Add "%s" as "%s"', state.entity_id, a_type) - return TYPES[a_type](hass, state.name, state.entity_id, aid, config=config) + return TYPES[a_type](hass, driver, name, state.entity_id, aid, config) def generate_aid(entity_id): """Generate accessory aid with zlib adler32.""" aid = adler32(entity_id.encode('utf-8')) - if aid == 0 or aid == 1: + if aid in (0, 1): return None return aid @@ -161,9 +181,11 @@ def generate_aid(entity_id): class HomeKit(): """Class to handle all actions between HomeKit and Home Assistant.""" - def __init__(self, hass, port, ip_address, entity_filter, entity_config): + def __init__(self, hass, name, port, ip_address, entity_filter, + entity_config): """Initialize a HomeKit object.""" self.hass = hass + self._name = name self._port = port self._ip_address = ip_address self._filter = entity_filter @@ -182,8 +204,9 @@ def setup(self): ip_addr = self._ip_address or get_local_ip() path = self.hass.config.path(HOMEKIT_FILE) - self.bridge = HomeBridge(self.hass) - self.driver = HomeDriver(self.bridge, self._port, ip_addr, path) + self.driver = HomeDriver(self.hass, address=ip_addr, + port=self._port, persist_file=path) + self.bridge = HomeBridge(self.hass, self.driver, self._name) def add_bridge_accessory(self, state): """Try adding accessory to bridge if configured beforehand.""" @@ -191,7 +214,7 @@ def add_bridge_accessory(self, state): return aid = generate_aid(state.entity_id) conf = self._config.pop(state.entity_id, {}) - acc = get_accessory(self.hass, state, aid, conf) + acc = get_accessory(self.hass, self.driver, state, aid, conf) if acc is not None: self.bridge.add_accessory(acc) @@ -203,15 +226,16 @@ def start(self, *args): # pylint: disable=unused-variable from . import ( # noqa F401 - type_covers, type_lights, type_locks, type_security_systems, - type_sensors, type_switches, type_thermostats) + type_covers, type_fans, type_lights, type_locks, + type_media_players, type_security_systems, type_sensors, + type_switches, type_thermostats) for state in self.hass.states.all(): self.add_bridge_accessory(state) - self.bridge.set_driver(self.driver) + self.driver.add_accessory(self.bridge) - if not self.bridge.paired: - show_setup_message(self.hass, self.bridge) + if not self.driver.state.paired: + show_setup_message(self.hass, self.driver.state.pincode) _LOGGER.debug('Driver start') self.hass.add_job(self.driver.start) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index c47c3f8fbe73ae..a7e895f49e2a2a 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -1,6 +1,6 @@ """Extend the basic Accessory and Bridge functions.""" from datetime import timedelta -from functools import wraps +from functools import partial, wraps from inspect import getmodule import logging @@ -8,7 +8,8 @@ from pyhap.accessory_driver import AccessoryDriver from pyhap.const import CATEGORY_OTHER -from homeassistant.const import __version__ +from homeassistant.const import ( + __version__, ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL) from homeassistant.core import callback as ha_callback from homeassistant.core import split_entity_id from homeassistant.helpers.event import ( @@ -16,10 +17,11 @@ from homeassistant.util import dt as dt_util from .const import ( - DEBOUNCE_TIMEOUT, BRIDGE_MODEL, BRIDGE_NAME, - BRIDGE_SERIAL_NUMBER, MANUFACTURER) + BRIDGE_MODEL, BRIDGE_SERIAL_NUMBER, CHAR_BATTERY_LEVEL, + CHAR_CHARGING_STATE, CHAR_STATUS_LOW_BATTERY, DEBOUNCE_TIMEOUT, + MANUFACTURER, SERV_BATTERY_SERVICE) from .util import ( - show_setup_message, dismiss_setup_message) + convert_to_float, show_setup_message, dismiss_setup_message) _LOGGER = logging.getLogger(__name__) @@ -27,35 +29,25 @@ def debounce(func): """Decorator function. Debounce callbacks form HomeKit.""" @ha_callback - def call_later_listener(*args): + def call_later_listener(self, *args): """Callback listener called from call_later.""" - # pylint: disable=unsubscriptable-object - nonlocal lastargs, remove_listener - hass = lastargs['hass'] - hass.async_add_job(func, *lastargs['args']) - lastargs = remove_listener = None + debounce_params = self.debounce.pop(func.__name__, None) + if debounce_params: + self.hass.async_add_job(func, self, *debounce_params[1:]) @wraps(func) - def wrapper(*args): - """Wrapper starts async timer. - - The accessory must have 'self.hass' and 'self.entity_id' as attributes. - """ - # pylint: disable=not-callable - hass = args[0].hass - nonlocal lastargs, remove_listener - if remove_listener: - remove_listener() - lastargs = remove_listener = None - lastargs = {'hass': hass, 'args': [*args]} + def wrapper(self, *args): + """Wrapper starts async timer.""" + debounce_params = self.debounce.pop(func.__name__, None) + if debounce_params: + debounce_params[0]() # remove listener remove_listener = track_point_in_utc_time( - hass, call_later_listener, + self.hass, partial(call_later_listener, self), dt_util.utcnow() + timedelta(seconds=DEBOUNCE_TIMEOUT)) - logger.debug('%s: Start %s timeout', args[0].entity_id, + self.debounce[func.__name__] = (remove_listener, *args) + logger.debug('%s: Start %s timeout', self.entity_id, func.__name__.replace('set_', '')) - remove_listener = None - lastargs = None name = getmodule(func).__name__ logger = logging.getLogger(name) return wrapper @@ -64,46 +56,94 @@ def wrapper(*args): class HomeAccessory(Accessory): """Adapter class for Accessory.""" - def __init__(self, hass, name, entity_id, aid, category=CATEGORY_OTHER): + def __init__(self, hass, driver, name, entity_id, aid, config, + category=CATEGORY_OTHER): """Initialize a Accessory object.""" - super().__init__(name, aid=aid) - domain = split_entity_id(entity_id)[0].replace("_", " ").title() + super().__init__(driver, name, aid=aid) + model = split_entity_id(entity_id)[0].replace("_", " ").title() self.set_info_service( firmware_revision=__version__, manufacturer=MANUFACTURER, - model=domain, serial_number=entity_id) + model=model, serial_number=entity_id) self.category = category + self.config = config self.entity_id = entity_id self.hass = hass - - def run(self): - """Method called by accessory after driver is started.""" + self.debounce = {} + self._support_battery_level = False + self._support_battery_charging = True + + """Add battery service if available""" + battery_level = self.hass.states.get(self.entity_id).attributes \ + .get(ATTR_BATTERY_LEVEL) + if battery_level is None: + return + _LOGGER.debug('%s: Found battery level attribute', self.entity_id) + self._support_battery_level = True + serv_battery = self.add_preload_service(SERV_BATTERY_SERVICE) + self._char_battery = serv_battery.configure_char( + CHAR_BATTERY_LEVEL, value=0) + self._char_charging = serv_battery.configure_char( + CHAR_CHARGING_STATE, value=2) + self._char_low_battery = serv_battery.configure_char( + CHAR_STATUS_LOW_BATTERY, value=0) + + async def run(self): + """Method called by accessory after driver is started. + + Run inside the HAP-python event loop. + """ state = self.hass.states.get(self.entity_id) - self.update_state_callback(new_state=state) + self.hass.add_job(self.update_state_callback, None, None, state) async_track_state_change( self.hass, self.entity_id, self.update_state_callback) + @ha_callback def update_state_callback(self, entity_id=None, old_state=None, new_state=None): """Callback from state change listener.""" _LOGGER.debug('New_state: %s', new_state) if new_state is None: return - self.update_state(new_state) + if self._support_battery_level: + self.hass.async_add_job(self.update_battery, new_state) + self.hass.async_add_job(self.update_state, new_state) + + def update_battery(self, new_state): + """Update battery service if available. + + Only call this function if self._support_battery_level is True. + """ + battery_level = convert_to_float( + new_state.attributes.get(ATTR_BATTERY_LEVEL)) + self._char_battery.set_value(battery_level) + self._char_low_battery.set_value(battery_level < 20) + _LOGGER.debug('%s: Updated battery level to %d', self.entity_id, + battery_level) + if not self._support_battery_charging: + return + charging = new_state.attributes.get(ATTR_BATTERY_CHARGING) + if charging is None: + self._support_battery_charging = False + return + hk_charging = 1 if charging is True else 0 + self._char_charging.set_value(hk_charging) + _LOGGER.debug('%s: Updated battery charging to %d', self.entity_id, + hk_charging) def update_state(self, new_state): """Method called on state change to update HomeKit value. Overridden by accessory types. """ - pass + raise NotImplementedError() class HomeBridge(Bridge): """Adapter class for Bridge.""" - def __init__(self, hass, name=BRIDGE_NAME): + def __init__(self, hass, driver, name): """Initialize a Bridge object.""" - super().__init__(name) + super().__init__(driver, name) self.set_info_service( firmware_revision=__version__, manufacturer=MANUFACTURER, model=BRIDGE_MODEL, serial_number=BRIDGE_SERIAL_NUMBER) @@ -113,20 +153,23 @@ def setup_message(self): """Prevent print of pyhap setup message to terminal.""" pass - def add_paired_client(self, client_uuid, client_public): - """Override super function to dismiss setup message if paired.""" - super().add_paired_client(client_uuid, client_public) - dismiss_setup_message(self.hass) - - def remove_paired_client(self, client_uuid): - """Override super function to show setup message if unpaired.""" - super().remove_paired_client(client_uuid) - show_setup_message(self.hass, self) - class HomeDriver(AccessoryDriver): """Adapter class for AccessoryDriver.""" - def __init__(self, *args, **kwargs): + def __init__(self, hass, **kwargs): """Initialize a AccessoryDriver object.""" - super().__init__(*args, **kwargs) + super().__init__(**kwargs) + self.hass = hass + + def pair(self, client_uuid, client_public): + """Override super function to dismiss setup message if paired.""" + success = super().pair(client_uuid, client_public) + if success: + dismiss_setup_message(self.hass) + return success + + def unpair(self, client_uuid): + """Override super function to show setup message if unpaired.""" + super().unpair(client_uuid) + show_setup_message(self.hass, self.state.pincode) diff --git a/homeassistant/components/homekit/const.py b/homeassistant/components/homekit/const.py index ce46e84a2ef23c..33d2c0bfb85004 100644 --- a/homeassistant/components/homekit/const.py +++ b/homeassistant/components/homekit/const.py @@ -1,71 +1,88 @@ """Constants used be the HomeKit component.""" -# #### MISC #### +# #### Misc #### DEBOUNCE_TIMEOUT = 0.5 DOMAIN = 'homekit' HOMEKIT_FILE = '.homekit.state' HOMEKIT_NOTIFY_ID = 4663548 -# #### CONFIG #### +# #### Config #### CONF_AUTO_START = 'auto_start' CONF_ENTITY_CONFIG = 'entity_config' +CONF_FEATURE = 'feature' +CONF_FEATURE_LIST = 'feature_list' CONF_FILTER = 'filter' -# #### CONFIG DEFAULTS #### +# #### Config Defaults #### DEFAULT_AUTO_START = True DEFAULT_PORT = 51827 -# #### HOMEKIT COMPONENT SERVICES #### +# #### Features #### +FEATURE_ON_OFF = 'on_off' +FEATURE_PLAY_PAUSE = 'play_pause' +FEATURE_PLAY_STOP = 'play_stop' +FEATURE_TOGGLE_MUTE = 'toggle_mute' + +# #### HomeKit Component Services #### SERVICE_HOMEKIT_START = 'start' -# #### STRING CONSTANTS #### +# #### String Constants #### BRIDGE_MODEL = 'Bridge' BRIDGE_NAME = 'Home Assistant Bridge' BRIDGE_SERIAL_NUMBER = 'homekit.bridge' MANUFACTURER = 'Home Assistant' +# #### Switch Types #### +TYPE_OUTLET = 'outlet' +TYPE_SWITCH = 'switch' + # #### Services #### SERV_ACCESSORY_INFO = 'AccessoryInformation' SERV_AIR_QUALITY_SENSOR = 'AirQualitySensor' +SERV_BATTERY_SERVICE = 'BatteryService' SERV_CARBON_DIOXIDE_SENSOR = 'CarbonDioxideSensor' SERV_CARBON_MONOXIDE_SENSOR = 'CarbonMonoxideSensor' SERV_CONTACT_SENSOR = 'ContactSensor' +SERV_FANV2 = 'Fanv2' SERV_GARAGE_DOOR_OPENER = 'GarageDoorOpener' -SERV_HUMIDITY_SENSOR = 'HumiditySensor' # CurrentRelativeHumidity +SERV_HUMIDITY_SENSOR = 'HumiditySensor' SERV_LEAK_SENSOR = 'LeakSensor' SERV_LIGHT_SENSOR = 'LightSensor' -SERV_LIGHTBULB = 'Lightbulb' # On | Brightness, Hue, Saturation, Name +SERV_LIGHTBULB = 'Lightbulb' SERV_LOCK = 'LockMechanism' SERV_MOTION_SENSOR = 'MotionSensor' SERV_OCCUPANCY_SENSOR = 'OccupancySensor' +SERV_OUTLET = 'Outlet' SERV_SECURITY_SYSTEM = 'SecuritySystem' SERV_SMOKE_SENSOR = 'SmokeSensor' SERV_SWITCH = 'Switch' SERV_TEMPERATURE_SENSOR = 'TemperatureSensor' SERV_THERMOSTAT = 'Thermostat' SERV_WINDOW_COVERING = 'WindowCovering' -# CurrentPosition, TargetPosition, PositionState # #### Characteristics #### +CHAR_ACTIVE = 'Active' CHAR_AIR_PARTICULATE_DENSITY = 'AirParticulateDensity' CHAR_AIR_QUALITY = 'AirQuality' -CHAR_BRIGHTNESS = 'Brightness' # Int | [0, 100] +CHAR_BATTERY_LEVEL = 'BatteryLevel' +CHAR_BRIGHTNESS = 'Brightness' CHAR_CARBON_DIOXIDE_DETECTED = 'CarbonDioxideDetected' CHAR_CARBON_DIOXIDE_LEVEL = 'CarbonDioxideLevel' CHAR_CARBON_DIOXIDE_PEAK_LEVEL = 'CarbonDioxidePeakLevel' CHAR_CARBON_MONOXIDE_DETECTED = 'CarbonMonoxideDetected' +CHAR_CHARGING_STATE = 'ChargingState' CHAR_COLOR_TEMPERATURE = 'ColorTemperature' CHAR_CONTACT_SENSOR_STATE = 'ContactSensorState' CHAR_COOLING_THRESHOLD_TEMPERATURE = 'CoolingThresholdTemperature' CHAR_CURRENT_AMBIENT_LIGHT_LEVEL = 'CurrentAmbientLightLevel' CHAR_CURRENT_DOOR_STATE = 'CurrentDoorState' CHAR_CURRENT_HEATING_COOLING = 'CurrentHeatingCoolingState' -CHAR_CURRENT_POSITION = 'CurrentPosition' # Int | [0, 100] -CHAR_CURRENT_HUMIDITY = 'CurrentRelativeHumidity' # percent +CHAR_CURRENT_POSITION = 'CurrentPosition' +CHAR_CURRENT_HUMIDITY = 'CurrentRelativeHumidity' CHAR_CURRENT_SECURITY_STATE = 'SecuritySystemCurrentState' CHAR_CURRENT_TEMPERATURE = 'CurrentTemperature' CHAR_FIRMWARE_REVISION = 'FirmwareRevision' CHAR_HEATING_THRESHOLD_TEMPERATURE = 'HeatingThresholdTemperature' -CHAR_HUE = 'Hue' # arcdegress | [0, 360] +CHAR_HUE = 'Hue' CHAR_LEAK_DETECTED = 'LeakDetected' CHAR_LOCK_CURRENT_STATE = 'LockCurrentState' CHAR_LOCK_TARGET_STATE = 'LockTargetState' @@ -75,33 +92,36 @@ CHAR_MOTION_DETECTED = 'MotionDetected' CHAR_NAME = 'Name' CHAR_OCCUPANCY_DETECTED = 'OccupancyDetected' -CHAR_ON = 'On' # boolean +CHAR_OUTLET_IN_USE = 'OutletInUse' +CHAR_ON = 'On' CHAR_POSITION_STATE = 'PositionState' -CHAR_SATURATION = 'Saturation' # percent +CHAR_ROTATION_DIRECTION = 'RotationDirection' +CHAR_SATURATION = 'Saturation' CHAR_SERIAL_NUMBER = 'SerialNumber' CHAR_SMOKE_DETECTED = 'SmokeDetected' +CHAR_STATUS_LOW_BATTERY = 'StatusLowBattery' +CHAR_SWING_MODE = 'SwingMode' CHAR_TARGET_DOOR_STATE = 'TargetDoorState' CHAR_TARGET_HEATING_COOLING = 'TargetHeatingCoolingState' -CHAR_TARGET_POSITION = 'TargetPosition' # Int | [0, 100] +CHAR_TARGET_POSITION = 'TargetPosition' CHAR_TARGET_SECURITY_STATE = 'SecuritySystemTargetState' CHAR_TARGET_TEMPERATURE = 'TargetTemperature' CHAR_TEMP_DISPLAY_UNITS = 'TemperatureDisplayUnits' # #### Properties #### +PROP_MAX_VALUE = 'maxValue' +PROP_MIN_VALUE = 'minValue' PROP_CELSIUS = {'minValue': -273, 'maxValue': 999} -# #### Device Class #### +# #### Device Classes #### DEVICE_CLASS_CO2 = 'co2' DEVICE_CLASS_DOOR = 'door' DEVICE_CLASS_GARAGE_DOOR = 'garage_door' DEVICE_CLASS_GAS = 'gas' -DEVICE_CLASS_HUMIDITY = 'humidity' -DEVICE_CLASS_LIGHT = 'light' DEVICE_CLASS_MOISTURE = 'moisture' DEVICE_CLASS_MOTION = 'motion' DEVICE_CLASS_OCCUPANCY = 'occupancy' DEVICE_CLASS_OPENING = 'opening' DEVICE_CLASS_PM25 = 'pm25' DEVICE_CLASS_SMOKE = 'smoke' -DEVICE_CLASS_TEMPERATURE = 'temperature' DEVICE_CLASS_WINDOW = 'window' diff --git a/homeassistant/components/homekit/type_covers.py b/homeassistant/components/homekit/type_covers.py index 3de87cf63e83ed..cf0620a4e30d50 100644 --- a/homeassistant/components/homekit/type_covers.py +++ b/homeassistant/components/homekit/type_covers.py @@ -1,21 +1,21 @@ """Class to hold all cover accessories.""" import logging -from pyhap.const import CATEGORY_WINDOW_COVERING, CATEGORY_GARAGE_DOOR_OPENER +from pyhap.const import CATEGORY_GARAGE_DOOR_OPENER, CATEGORY_WINDOW_COVERING from homeassistant.components.cover import ( ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN, SUPPORT_STOP) from homeassistant.const import ( - ATTR_ENTITY_ID, SERVICE_SET_COVER_POSITION, STATE_OPEN, STATE_CLOSED, - SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, SERVICE_STOP_COVER, - ATTR_SUPPORTED_FEATURES) + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, SERVICE_CLOSE_COVER, + SERVICE_OPEN_COVER, SERVICE_SET_COVER_POSITION, SERVICE_STOP_COVER, + STATE_CLOSED, STATE_OPEN) from . import TYPES -from .accessories import HomeAccessory, debounce +from .accessories import debounce, HomeAccessory from .const import ( - SERV_WINDOW_COVERING, CHAR_CURRENT_POSITION, - CHAR_TARGET_POSITION, CHAR_POSITION_STATE, - SERV_GARAGE_DOOR_OPENER, CHAR_CURRENT_DOOR_STATE, CHAR_TARGET_DOOR_STATE) + CHAR_CURRENT_DOOR_STATE, CHAR_CURRENT_POSITION, CHAR_POSITION_STATE, + CHAR_TARGET_DOOR_STATE, CHAR_TARGET_POSITION, + SERV_GARAGE_DOOR_OPENER, SERV_WINDOW_COVERING) _LOGGER = logging.getLogger(__name__) @@ -28,7 +28,7 @@ class GarageDoorOpener(HomeAccessory): and support no more than open, close, and stop. """ - def __init__(self, *args, config): + def __init__(self, *args): """Initialize a GarageDoorOpener accessory object.""" super().__init__(*args, category=CATEGORY_GARAGE_DOOR_OPENER) self.flag_target_state = False @@ -44,12 +44,13 @@ def set_state(self, value): _LOGGER.debug('%s: Set state to %d', self.entity_id, value) self.flag_target_state = True + params = {ATTR_ENTITY_ID: self.entity_id} if value == 0: self.char_current_state.set_value(3) - self.hass.components.cover.open_cover(self.entity_id) + self.hass.services.call(DOMAIN, SERVICE_OPEN_COVER, params) elif value == 1: self.char_current_state.set_value(2) - self.hass.components.cover.close_cover(self.entity_id) + self.hass.services.call(DOMAIN, SERVICE_CLOSE_COVER, params) def update_state(self, new_state): """Update cover state after state changed.""" @@ -69,7 +70,7 @@ class WindowCovering(HomeAccessory): The cover entity must support: set_cover_position. """ - def __init__(self, *args, config): + def __init__(self, *args): """Initialize a WindowCovering accessory object.""" super().__init__(*args, category=CATEGORY_WINDOW_COVERING) self.homekit_target = None @@ -108,7 +109,7 @@ class WindowCoveringBasic(HomeAccessory): stop_cover (optional). """ - def __init__(self, *args, config): + def __init__(self, *args): """Initialize a WindowCovering accessory object.""" super().__init__(*args, category=CATEGORY_WINDOW_COVERING) features = self.hass.states.get(self.entity_id) \ @@ -141,8 +142,8 @@ def move_cover(self, value): else: service, position = (SERVICE_CLOSE_COVER, 0) - self.hass.services.call(DOMAIN, service, - {ATTR_ENTITY_ID: self.entity_id}) + params = {ATTR_ENTITY_ID: self.entity_id} + self.hass.services.call(DOMAIN, service, params) # Snap the current/target position to the expected final position. self.char_current_position.set_value(position) diff --git a/homeassistant/components/homekit/type_fans.py b/homeassistant/components/homekit/type_fans.py new file mode 100644 index 00000000000000..aa44b11fefbd8e --- /dev/null +++ b/homeassistant/components/homekit/type_fans.py @@ -0,0 +1,112 @@ +"""Class to hold all light accessories.""" +import logging + +from pyhap.const import CATEGORY_FAN + +from homeassistant.components.fan import ( + ATTR_DIRECTION, ATTR_OSCILLATING, DIRECTION_FORWARD, DIRECTION_REVERSE, + DOMAIN, SERVICE_OSCILLATE, SERVICE_SET_DIRECTION, SUPPORT_DIRECTION, + SUPPORT_OSCILLATE) +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, SERVICE_TURN_OFF, + SERVICE_TURN_ON, STATE_OFF, STATE_ON) + +from . import TYPES +from .accessories import HomeAccessory +from .const import ( + CHAR_ACTIVE, CHAR_ROTATION_DIRECTION, CHAR_SWING_MODE, SERV_FANV2) + +_LOGGER = logging.getLogger(__name__) + + +@TYPES.register('Fan') +class Fan(HomeAccessory): + """Generate a Fan accessory for a fan entity. + + Currently supports: state, speed, oscillate, direction. + """ + + def __init__(self, *args): + """Initialize a new Light accessory object.""" + super().__init__(*args, category=CATEGORY_FAN) + self._flag = {CHAR_ACTIVE: False, + CHAR_ROTATION_DIRECTION: False, + CHAR_SWING_MODE: False} + self._state = 0 + + self.chars = [] + features = self.hass.states.get(self.entity_id) \ + .attributes.get(ATTR_SUPPORTED_FEATURES) + if features & SUPPORT_DIRECTION: + self.chars.append(CHAR_ROTATION_DIRECTION) + if features & SUPPORT_OSCILLATE: + self.chars.append(CHAR_SWING_MODE) + + serv_fan = self.add_preload_service(SERV_FANV2, self.chars) + self.char_active = serv_fan.configure_char( + CHAR_ACTIVE, value=0, setter_callback=self.set_state) + + if CHAR_ROTATION_DIRECTION in self.chars: + self.char_direction = serv_fan.configure_char( + CHAR_ROTATION_DIRECTION, value=0, + setter_callback=self.set_direction) + + if CHAR_SWING_MODE in self.chars: + self.char_swing = serv_fan.configure_char( + CHAR_SWING_MODE, value=0, setter_callback=self.set_oscillating) + + def set_state(self, value): + """Set state if call came from HomeKit.""" + _LOGGER.debug('%s: Set state to %d', self.entity_id, value) + self._flag[CHAR_ACTIVE] = True + service = SERVICE_TURN_ON if value == 1 else SERVICE_TURN_OFF + params = {ATTR_ENTITY_ID: self.entity_id} + self.hass.services.call(DOMAIN, service, params) + + def set_direction(self, value): + """Set state if call came from HomeKit.""" + _LOGGER.debug('%s: Set direction to %d', self.entity_id, value) + self._flag[CHAR_ROTATION_DIRECTION] = True + direction = DIRECTION_REVERSE if value == 1 else DIRECTION_FORWARD + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_DIRECTION: direction} + self.hass.services.call(DOMAIN, SERVICE_SET_DIRECTION, params) + + def set_oscillating(self, value): + """Set state if call came from HomeKit.""" + _LOGGER.debug('%s: Set oscillating to %d', self.entity_id, value) + self._flag[CHAR_SWING_MODE] = True + oscillating = True if value == 1 else False + params = {ATTR_ENTITY_ID: self.entity_id, + ATTR_OSCILLATING: oscillating} + self.hass.services.call(DOMAIN, SERVICE_OSCILLATE, params) + + def update_state(self, new_state): + """Update fan after state change.""" + # Handle State + state = new_state.state + if state in (STATE_ON, STATE_OFF): + self._state = 1 if state == STATE_ON else 0 + if not self._flag[CHAR_ACTIVE] and \ + self.char_active.value != self._state: + self.char_active.set_value(self._state) + self._flag[CHAR_ACTIVE] = False + + # Handle Direction + if CHAR_ROTATION_DIRECTION in self.chars: + direction = new_state.attributes.get(ATTR_DIRECTION) + if not self._flag[CHAR_ROTATION_DIRECTION] and \ + direction in (DIRECTION_FORWARD, DIRECTION_REVERSE): + hk_direction = 1 if direction == DIRECTION_REVERSE else 0 + if self.char_direction.value != hk_direction: + self.char_direction.set_value(hk_direction) + self._flag[CHAR_ROTATION_DIRECTION] = False + + # Handle Oscillating + if CHAR_SWING_MODE in self.chars: + oscillating = new_state.attributes.get(ATTR_OSCILLATING) + if not self._flag[CHAR_SWING_MODE] and \ + oscillating in (True, False): + hk_oscillating = 1 if oscillating else 0 + if self.char_swing.value != hk_oscillating: + self.char_swing.set_value(hk_oscillating) + self._flag[CHAR_SWING_MODE] = False diff --git a/homeassistant/components/homekit/type_lights.py b/homeassistant/components/homekit/type_lights.py index 3efb0e99df6ca9..da01279960270f 100644 --- a/homeassistant/components/homekit/type_lights.py +++ b/homeassistant/components/homekit/type_lights.py @@ -4,15 +4,18 @@ from pyhap.const import CATEGORY_LIGHTBULB from homeassistant.components.light import ( - ATTR_HS_COLOR, ATTR_COLOR_TEMP, ATTR_BRIGHTNESS, ATTR_MIN_MIREDS, - ATTR_MAX_MIREDS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP, SUPPORT_BRIGHTNESS) -from homeassistant.const import ATTR_SUPPORTED_FEATURES, STATE_ON, STATE_OFF + ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP, ATTR_HS_COLOR, + ATTR_MAX_MIREDS, ATTR_MIN_MIREDS, DOMAIN, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR, SUPPORT_COLOR_TEMP) +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, SERVICE_TURN_ON, + SERVICE_TURN_OFF, STATE_OFF, STATE_ON) from . import TYPES -from .accessories import HomeAccessory, debounce +from .accessories import debounce, HomeAccessory from .const import ( - SERV_LIGHTBULB, CHAR_COLOR_TEMPERATURE, - CHAR_BRIGHTNESS, CHAR_HUE, CHAR_ON, CHAR_SATURATION) + CHAR_BRIGHTNESS, CHAR_COLOR_TEMPERATURE, CHAR_HUE, CHAR_ON, + CHAR_SATURATION, SERV_LIGHTBULB, PROP_MAX_VALUE, PROP_MIN_VALUE) _LOGGER = logging.getLogger(__name__) @@ -26,7 +29,7 @@ class Light(HomeAccessory): Currently supports: state, brightness, color temperature, rgb_color. """ - def __init__(self, *args, config): + def __init__(self, *args): """Initialize a new Light accessory object.""" super().__init__(*args, category=CATEGORY_LIGHTBULB) self._flag = {CHAR_ON: False, CHAR_BRIGHTNESS: False, @@ -61,7 +64,8 @@ def __init__(self, *args, config): .attributes.get(ATTR_MAX_MIREDS, 500) self.char_color_temperature = serv_light.configure_char( CHAR_COLOR_TEMPERATURE, value=min_mireds, - properties={'minValue': min_mireds, 'maxValue': max_mireds}, + properties={PROP_MIN_VALUE: min_mireds, + PROP_MAX_VALUE: max_mireds}, setter_callback=self.set_color_temperature) if CHAR_HUE in self.chars: self.char_hue = serv_light.configure_char( @@ -77,28 +81,27 @@ def set_state(self, value): _LOGGER.debug('%s: Set state to %d', self.entity_id, value) self._flag[CHAR_ON] = True - - if value == 1: - self.hass.components.light.turn_on(self.entity_id) - elif value == 0: - self.hass.components.light.turn_off(self.entity_id) + params = {ATTR_ENTITY_ID: self.entity_id} + service = SERVICE_TURN_ON if value == 1 else SERVICE_TURN_OFF + self.hass.services.call(DOMAIN, service, params) @debounce def set_brightness(self, value): """Set brightness if call came from HomeKit.""" _LOGGER.debug('%s: Set brightness to %d', self.entity_id, value) self._flag[CHAR_BRIGHTNESS] = True - if value != 0: - self.hass.components.light.turn_on( - self.entity_id, brightness_pct=value) - else: - self.hass.components.light.turn_off(self.entity_id) + if value == 0: + self.set_state(0) # Turn off light + return + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_BRIGHTNESS_PCT: value} + self.hass.services.call(DOMAIN, SERVICE_TURN_ON, params) def set_color_temperature(self, value): """Set color temperature if call came from HomeKit.""" _LOGGER.debug('%s: Set color temp to %s', self.entity_id, value) self._flag[CHAR_COLOR_TEMPERATURE] = True - self.hass.components.light.turn_on(self.entity_id, color_temp=value) + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_COLOR_TEMP: value} + self.hass.services.call(DOMAIN, SERVICE_TURN_ON, params) def set_saturation(self, value): """Set saturation if call came from HomeKit.""" @@ -116,15 +119,14 @@ def set_hue(self, value): def set_color(self): """Set color if call came from HomeKit.""" - # Handle Color if self._features & SUPPORT_COLOR and self._flag[CHAR_HUE] and \ self._flag[CHAR_SATURATION]: color = (self._hue, self._saturation) _LOGGER.debug('%s: Set hs_color to %s', self.entity_id, color) self._flag.update({ CHAR_HUE: False, CHAR_SATURATION: False, RGB_COLOR: True}) - self.hass.components.light.turn_on( - self.entity_id, hs_color=color) + params = {ATTR_ENTITY_ID: self.entity_id, ATTR_HS_COLOR: color} + self.hass.services.call(DOMAIN, SERVICE_TURN_ON, params) def update_state(self, new_state): """Update light after state change.""" diff --git a/homeassistant/components/homekit/type_locks.py b/homeassistant/components/homekit/type_locks.py index e7f18d44805a9c..05ab6c6f822f05 100644 --- a/homeassistant/components/homekit/type_locks.py +++ b/homeassistant/components/homekit/type_locks.py @@ -4,12 +4,12 @@ from pyhap.const import CATEGORY_DOOR_LOCK from homeassistant.components.lock import ( - ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNLOCKED, STATE_UNKNOWN) + ATTR_ENTITY_ID, DOMAIN, STATE_LOCKED, STATE_UNLOCKED, STATE_UNKNOWN) +from homeassistant.const import ATTR_CODE from . import TYPES from .accessories import HomeAccessory -from .const import ( - SERV_LOCK, CHAR_LOCK_CURRENT_STATE, CHAR_LOCK_TARGET_STATE) +from .const import CHAR_LOCK_CURRENT_STATE, CHAR_LOCK_TARGET_STATE, SERV_LOCK _LOGGER = logging.getLogger(__name__) @@ -29,9 +29,10 @@ class Lock(HomeAccessory): The lock entity must support: unlock and lock. """ - def __init__(self, *args, config): + def __init__(self, *args): """Initialize a Lock accessory object.""" super().__init__(*args, category=CATEGORY_DOOR_LOCK) + self._code = self.config.get(ATTR_CODE) self.flag_target_state = False serv_lock_mechanism = self.add_preload_service(SERV_LOCK) @@ -51,7 +52,9 @@ def set_state(self, value): service = STATE_TO_SERVICE[hass_value] params = {ATTR_ENTITY_ID: self.entity_id} - self.hass.services.call('lock', service, params) + if self._code: + params[ATTR_CODE] = self._code + self.hass.services.call(DOMAIN, service, params) def update_state(self, new_state): """Update lock after state changed.""" diff --git a/homeassistant/components/homekit/type_media_players.py b/homeassistant/components/homekit/type_media_players.py new file mode 100644 index 00000000000000..ec41b9fd618ed4 --- /dev/null +++ b/homeassistant/components/homekit/type_media_players.py @@ -0,0 +1,142 @@ +"""Class to hold all media player accessories.""" +import logging + +from pyhap.const import CATEGORY_SWITCH + +from homeassistant.const import ( + ATTR_ENTITY_ID, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, + SERVICE_MEDIA_STOP, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_MUTE, + STATE_OFF, STATE_PLAYING, STATE_UNKNOWN) +from homeassistant.components.media_player import ( + ATTR_MEDIA_VOLUME_MUTED, DOMAIN) + +from . import TYPES +from .accessories import HomeAccessory +from .const import ( + CHAR_NAME, CHAR_ON, CONF_FEATURE_LIST, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, + FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, SERV_SWITCH) + +_LOGGER = logging.getLogger(__name__) + +MODE_FRIENDLY_NAME = {FEATURE_ON_OFF: 'Power', + FEATURE_PLAY_PAUSE: 'Play/Pause', + FEATURE_PLAY_STOP: 'Play/Stop', + FEATURE_TOGGLE_MUTE: 'Mute'} + + +@TYPES.register('MediaPlayer') +class MediaPlayer(HomeAccessory): + """Generate a Media Player accessory.""" + + def __init__(self, *args): + """Initialize a Switch accessory object.""" + super().__init__(*args, category=CATEGORY_SWITCH) + self._flag = {FEATURE_ON_OFF: False, FEATURE_PLAY_PAUSE: False, + FEATURE_PLAY_STOP: False, FEATURE_TOGGLE_MUTE: False} + self.chars = {FEATURE_ON_OFF: None, FEATURE_PLAY_PAUSE: None, + FEATURE_PLAY_STOP: None, FEATURE_TOGGLE_MUTE: None} + feature_list = self.config[CONF_FEATURE_LIST] + + if FEATURE_ON_OFF in feature_list: + name = self.generate_service_name(FEATURE_ON_OFF) + serv_on_off = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + serv_on_off.configure_char(CHAR_NAME, value=name) + self.chars[FEATURE_ON_OFF] = serv_on_off.configure_char( + CHAR_ON, value=False, setter_callback=self.set_on_off) + + if FEATURE_PLAY_PAUSE in feature_list: + name = self.generate_service_name(FEATURE_PLAY_PAUSE) + serv_play_pause = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + serv_play_pause.configure_char(CHAR_NAME, value=name) + self.chars[FEATURE_PLAY_PAUSE] = serv_play_pause.configure_char( + CHAR_ON, value=False, setter_callback=self.set_play_pause) + + if FEATURE_PLAY_STOP in feature_list: + name = self.generate_service_name(FEATURE_PLAY_STOP) + serv_play_stop = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + serv_play_stop.configure_char(CHAR_NAME, value=name) + self.chars[FEATURE_PLAY_STOP] = serv_play_stop.configure_char( + CHAR_ON, value=False, setter_callback=self.set_play_stop) + + if FEATURE_TOGGLE_MUTE in feature_list: + name = self.generate_service_name(FEATURE_TOGGLE_MUTE) + serv_toggle_mute = self.add_preload_service(SERV_SWITCH, CHAR_NAME) + serv_toggle_mute.configure_char(CHAR_NAME, value=name) + self.chars[FEATURE_TOGGLE_MUTE] = serv_toggle_mute.configure_char( + CHAR_ON, value=False, setter_callback=self.set_toggle_mute) + + def generate_service_name(self, mode): + """Generate name for individual service.""" + return '{} {}'.format(self.display_name, MODE_FRIENDLY_NAME[mode]) + + def set_on_off(self, value): + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set switch state for "on_off" to %s', + self.entity_id, value) + self._flag[FEATURE_ON_OFF] = True + service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF + params = {ATTR_ENTITY_ID: self.entity_id} + self.hass.services.call(DOMAIN, service, params) + + def set_play_pause(self, value): + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set switch state for "play_pause" to %s', + self.entity_id, value) + self._flag[FEATURE_PLAY_PAUSE] = True + service = SERVICE_MEDIA_PLAY if value else SERVICE_MEDIA_PAUSE + params = {ATTR_ENTITY_ID: self.entity_id} + self.hass.services.call(DOMAIN, service, params) + + def set_play_stop(self, value): + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set switch state for "play_stop" to %s', + self.entity_id, value) + self._flag[FEATURE_PLAY_STOP] = True + service = SERVICE_MEDIA_PLAY if value else SERVICE_MEDIA_STOP + params = {ATTR_ENTITY_ID: self.entity_id} + self.hass.services.call(DOMAIN, service, params) + + def set_toggle_mute(self, value): + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set switch state for "toggle_mute" to %s', + self.entity_id, value) + self._flag[FEATURE_TOGGLE_MUTE] = True + params = {ATTR_ENTITY_ID: self.entity_id, + ATTR_MEDIA_VOLUME_MUTED: value} + self.hass.services.call(DOMAIN, SERVICE_VOLUME_MUTE, params) + + def update_state(self, new_state): + """Update switch state after state changed.""" + current_state = new_state.state + + if self.chars[FEATURE_ON_OFF]: + hk_state = current_state not in (STATE_OFF, STATE_UNKNOWN, 'None') + if not self._flag[FEATURE_ON_OFF]: + _LOGGER.debug('%s: Set current state for "on_off" to %s', + self.entity_id, hk_state) + self.chars[FEATURE_ON_OFF].set_value(hk_state) + self._flag[FEATURE_ON_OFF] = False + + if self.chars[FEATURE_PLAY_PAUSE]: + hk_state = current_state == STATE_PLAYING + if not self._flag[FEATURE_PLAY_PAUSE]: + _LOGGER.debug('%s: Set current state for "play_pause" to %s', + self.entity_id, hk_state) + self.chars[FEATURE_PLAY_PAUSE].set_value(hk_state) + self._flag[FEATURE_PLAY_PAUSE] = False + + if self.chars[FEATURE_PLAY_STOP]: + hk_state = current_state == STATE_PLAYING + if not self._flag[FEATURE_PLAY_STOP]: + _LOGGER.debug('%s: Set current state for "play_stop" to %s', + self.entity_id, hk_state) + self.chars[FEATURE_PLAY_STOP].set_value(hk_state) + self._flag[FEATURE_PLAY_STOP] = False + + if self.chars[FEATURE_TOGGLE_MUTE]: + current_state = new_state.attributes.get(ATTR_MEDIA_VOLUME_MUTED) + if not self._flag[FEATURE_TOGGLE_MUTE]: + _LOGGER.debug('%s: Set current state for "toggle_mute" to %s', + self.entity_id, current_state) + self.chars[FEATURE_TOGGLE_MUTE].set_value(current_state) + self._flag[FEATURE_TOGGLE_MUTE] = False diff --git a/homeassistant/components/homekit/type_security_systems.py b/homeassistant/components/homekit/type_security_systems.py index ab16f921e99a80..a7d36720cab60e 100644 --- a/homeassistant/components/homekit/type_security_systems.py +++ b/homeassistant/components/homekit/type_security_systems.py @@ -3,16 +3,18 @@ from pyhap.const import CATEGORY_ALARM_SYSTEM +from homeassistant.components.alarm_control_panel import DOMAIN from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, - STATE_ALARM_TRIGGERED, ATTR_ENTITY_ID, ATTR_CODE) + ATTR_ENTITY_ID, ATTR_CODE, SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_HOME, + SERVICE_ALARM_ARM_NIGHT, SERVICE_ALARM_DISARM, STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_TRIGGERED, + STATE_ALARM_DISARMED) from . import TYPES from .accessories import HomeAccessory from .const import ( - SERV_SECURITY_SYSTEM, CHAR_CURRENT_SECURITY_STATE, - CHAR_TARGET_SECURITY_STATE) + CHAR_CURRENT_SECURITY_STATE, CHAR_TARGET_SECURITY_STATE, + SERV_SECURITY_SYSTEM) _LOGGER = logging.getLogger(__name__) @@ -22,20 +24,21 @@ STATE_ALARM_DISARMED: 3, STATE_ALARM_TRIGGERED: 4} HOMEKIT_TO_HASS = {c: s for s, c in HASS_TO_HOMEKIT.items()} -STATE_TO_SERVICE = {STATE_ALARM_ARMED_HOME: 'alarm_arm_home', - STATE_ALARM_ARMED_AWAY: 'alarm_arm_away', - STATE_ALARM_ARMED_NIGHT: 'alarm_arm_night', - STATE_ALARM_DISARMED: 'alarm_disarm'} +STATE_TO_SERVICE = { + STATE_ALARM_ARMED_AWAY: SERVICE_ALARM_ARM_AWAY, + STATE_ALARM_ARMED_HOME: SERVICE_ALARM_ARM_HOME, + STATE_ALARM_ARMED_NIGHT: SERVICE_ALARM_ARM_NIGHT, + STATE_ALARM_DISARMED: SERVICE_ALARM_DISARM} @TYPES.register('SecuritySystem') class SecuritySystem(HomeAccessory): """Generate an SecuritySystem accessory for an alarm control panel.""" - def __init__(self, *args, config): + def __init__(self, *args): """Initialize a SecuritySystem accessory object.""" super().__init__(*args, category=CATEGORY_ALARM_SYSTEM) - self._alarm_code = config.get(ATTR_CODE) + self._alarm_code = self.config.get(ATTR_CODE) self.flag_target_state = False serv_alarm = self.add_preload_service(SERV_SECURITY_SYSTEM) @@ -56,7 +59,7 @@ def set_security_state(self, value): params = {ATTR_ENTITY_ID: self.entity_id} if self._alarm_code: params[ATTR_CODE] = self._alarm_code - self.hass.services.call('alarm_control_panel', service, params) + self.hass.services.call(DOMAIN, service, params) def update_state(self, new_state): """Update security state after state changed.""" diff --git a/homeassistant/components/homekit/type_sensors.py b/homeassistant/components/homekit/type_sensors.py index 393b6beffd6f5a..d4c2cb58209c5b 100644 --- a/homeassistant/components/homekit/type_sensors.py +++ b/homeassistant/components/homekit/type_sensors.py @@ -4,26 +4,26 @@ from pyhap.const import CATEGORY_SENSOR from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS, - ATTR_DEVICE_CLASS, STATE_ON, STATE_HOME) + ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, STATE_ON, STATE_HOME, + TEMP_CELSIUS) from . import TYPES from .accessories import HomeAccessory from .const import ( - SERV_HUMIDITY_SENSOR, SERV_TEMPERATURE_SENSOR, - CHAR_CURRENT_HUMIDITY, CHAR_CURRENT_TEMPERATURE, PROP_CELSIUS, - SERV_AIR_QUALITY_SENSOR, CHAR_AIR_QUALITY, CHAR_AIR_PARTICULATE_DENSITY, - CHAR_CARBON_DIOXIDE_LEVEL, CHAR_CARBON_DIOXIDE_PEAK_LEVEL, - SERV_LIGHT_SENSOR, CHAR_CURRENT_AMBIENT_LIGHT_LEVEL, - DEVICE_CLASS_CO2, SERV_CARBON_DIOXIDE_SENSOR, CHAR_CARBON_DIOXIDE_DETECTED, - DEVICE_CLASS_GAS, SERV_CARBON_MONOXIDE_SENSOR, - CHAR_CARBON_MONOXIDE_DETECTED, - DEVICE_CLASS_MOISTURE, SERV_LEAK_SENSOR, CHAR_LEAK_DETECTED, - DEVICE_CLASS_MOTION, SERV_MOTION_SENSOR, CHAR_MOTION_DETECTED, - DEVICE_CLASS_OCCUPANCY, SERV_OCCUPANCY_SENSOR, CHAR_OCCUPANCY_DETECTED, - DEVICE_CLASS_OPENING, SERV_CONTACT_SENSOR, CHAR_CONTACT_SENSOR_STATE, - DEVICE_CLASS_DOOR, DEVICE_CLASS_GARAGE_DOOR, DEVICE_CLASS_WINDOW, - DEVICE_CLASS_SMOKE, SERV_SMOKE_SENSOR, CHAR_SMOKE_DETECTED) + CHAR_AIR_PARTICULATE_DENSITY, CHAR_AIR_QUALITY, + CHAR_CARBON_DIOXIDE_DETECTED, CHAR_CARBON_DIOXIDE_LEVEL, + CHAR_CARBON_DIOXIDE_PEAK_LEVEL, CHAR_CARBON_MONOXIDE_DETECTED, + CHAR_CONTACT_SENSOR_STATE, CHAR_CURRENT_AMBIENT_LIGHT_LEVEL, + CHAR_CURRENT_HUMIDITY, CHAR_CURRENT_TEMPERATURE, CHAR_LEAK_DETECTED, + CHAR_MOTION_DETECTED, CHAR_OCCUPANCY_DETECTED, CHAR_SMOKE_DETECTED, + DEVICE_CLASS_CO2, DEVICE_CLASS_DOOR, DEVICE_CLASS_GARAGE_DOOR, + DEVICE_CLASS_GAS, DEVICE_CLASS_MOISTURE, DEVICE_CLASS_MOTION, + DEVICE_CLASS_OCCUPANCY, DEVICE_CLASS_OPENING, DEVICE_CLASS_SMOKE, + DEVICE_CLASS_WINDOW, PROP_CELSIUS, SERV_AIR_QUALITY_SENSOR, + SERV_CARBON_DIOXIDE_SENSOR, SERV_CARBON_MONOXIDE_SENSOR, + SERV_CONTACT_SENSOR, SERV_HUMIDITY_SENSOR, SERV_LEAK_SENSOR, + SERV_LIGHT_SENSOR, SERV_MOTION_SENSOR, SERV_OCCUPANCY_SENSOR, + SERV_SMOKE_SENSOR, SERV_TEMPERATURE_SENSOR) from .util import ( convert_to_float, temperature_to_homekit, density_to_air_quality) @@ -51,7 +51,7 @@ class TemperatureSensor(HomeAccessory): Sensor entity must return temperature in °C, °F. """ - def __init__(self, *args, config): + def __init__(self, *args): """Initialize a TemperatureSensor accessory object.""" super().__init__(*args, category=CATEGORY_SENSOR) serv_temp = self.add_preload_service(SERV_TEMPERATURE_SENSOR) @@ -74,7 +74,7 @@ def update_state(self, new_state): class HumiditySensor(HomeAccessory): """Generate a HumiditySensor accessory as humidity sensor.""" - def __init__(self, *args, config): + def __init__(self, *args): """Initialize a HumiditySensor accessory object.""" super().__init__(*args, category=CATEGORY_SENSOR) serv_humidity = self.add_preload_service(SERV_HUMIDITY_SENSOR) @@ -94,7 +94,7 @@ def update_state(self, new_state): class AirQualitySensor(HomeAccessory): """Generate a AirQualitySensor accessory as air quality sensor.""" - def __init__(self, *args, config): + def __init__(self, *args): """Initialize a AirQualitySensor accessory object.""" super().__init__(*args, category=CATEGORY_SENSOR) @@ -108,7 +108,7 @@ def __init__(self, *args, config): def update_state(self, new_state): """Update accessory after state change.""" density = convert_to_float(new_state.state) - if density is not None: + if density: self.char_density.set_value(density) self.char_quality.set_value(density_to_air_quality(density)) _LOGGER.debug('%s: Set to %d', self.entity_id, density) @@ -118,7 +118,7 @@ def update_state(self, new_state): class CarbonDioxideSensor(HomeAccessory): """Generate a CarbonDioxideSensor accessory as CO2 sensor.""" - def __init__(self, *args, config): + def __init__(self, *args): """Initialize a CarbonDioxideSensor accessory object.""" super().__init__(*args, category=CATEGORY_SENSOR) @@ -134,7 +134,7 @@ def __init__(self, *args, config): def update_state(self, new_state): """Update accessory after state change.""" co2 = convert_to_float(new_state.state) - if co2 is not None: + if co2: self.char_co2.set_value(co2) if co2 > self.char_peak.value: self.char_peak.set_value(co2) @@ -146,7 +146,7 @@ def update_state(self, new_state): class LightSensor(HomeAccessory): """Generate a LightSensor accessory as light sensor.""" - def __init__(self, *args, config): + def __init__(self, *args): """Initialize a LightSensor accessory object.""" super().__init__(*args, category=CATEGORY_SENSOR) @@ -157,7 +157,7 @@ def __init__(self, *args, config): def update_state(self, new_state): """Update accessory after state change.""" luminance = convert_to_float(new_state.state) - if luminance is not None: + if luminance: self.char_light.set_value(luminance) _LOGGER.debug('%s: Set to %d', self.entity_id, luminance) @@ -166,7 +166,7 @@ def update_state(self, new_state): class BinarySensor(HomeAccessory): """Generate a BinarySensor accessory as binary sensor.""" - def __init__(self, *args, config): + def __init__(self, *args): """Initialize a BinarySensor accessory object.""" super().__init__(*args, category=CATEGORY_SENSOR) device_class = self.hass.states.get(self.entity_id).attributes \ @@ -181,6 +181,6 @@ def __init__(self, *args, config): def update_state(self, new_state): """Update accessory after state change.""" state = new_state.state - detected = (state == STATE_ON) or (state == STATE_HOME) + detected = state in (STATE_ON, STATE_HOME) self.char_detected.set_value(detected) _LOGGER.debug('%s: Set to %d', self.entity_id, detected) diff --git a/homeassistant/components/homekit/type_switches.py b/homeassistant/components/homekit/type_switches.py index 68a4fcdab0a670..a5724057eee3e8 100644 --- a/homeassistant/components/homekit/type_switches.py +++ b/homeassistant/components/homekit/type_switches.py @@ -1,25 +1,60 @@ """Class to hold all switch accessories.""" import logging -from pyhap.const import CATEGORY_SWITCH +from pyhap.const import CATEGORY_OUTLET, CATEGORY_SWITCH +from homeassistant.components.switch import DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, STATE_ON) from homeassistant.core import split_entity_id from . import TYPES from .accessories import HomeAccessory -from .const import SERV_SWITCH, CHAR_ON +from .const import CHAR_ON, CHAR_OUTLET_IN_USE, SERV_OUTLET, SERV_SWITCH _LOGGER = logging.getLogger(__name__) +@TYPES.register('Outlet') +class Outlet(HomeAccessory): + """Generate an Outlet accessory.""" + + def __init__(self, *args): + """Initialize an Outlet accessory object.""" + super().__init__(*args, category=CATEGORY_OUTLET) + self.flag_target_state = False + + serv_outlet = self.add_preload_service(SERV_OUTLET) + self.char_on = serv_outlet.configure_char( + CHAR_ON, value=False, setter_callback=self.set_state) + self.char_outlet_in_use = serv_outlet.configure_char( + CHAR_OUTLET_IN_USE, value=True) + + def set_state(self, value): + """Move switch state to value if call came from HomeKit.""" + _LOGGER.debug('%s: Set switch state to %s', + self.entity_id, value) + self.flag_target_state = True + params = {ATTR_ENTITY_ID: self.entity_id} + service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF + self.hass.services.call(DOMAIN, service, params) + + def update_state(self, new_state): + """Update switch state after state changed.""" + current_state = (new_state.state == STATE_ON) + if not self.flag_target_state: + _LOGGER.debug('%s: Set current state to %s', + self.entity_id, current_state) + self.char_on.set_value(current_state) + self.flag_target_state = False + + @TYPES.register('Switch') class Switch(HomeAccessory): """Generate a Switch accessory.""" - def __init__(self, *args, config): - """Initialize a Switch accessory object to represent a remote.""" + def __init__(self, *args): + """Initialize a Switch accessory object.""" super().__init__(*args, category=CATEGORY_SWITCH) self._domain = split_entity_id(self.entity_id)[0] self.flag_target_state = False @@ -33,9 +68,9 @@ def set_state(self, value): _LOGGER.debug('%s: Set switch state to %s', self.entity_id, value) self.flag_target_state = True + params = {ATTR_ENTITY_ID: self.entity_id} service = SERVICE_TURN_ON if value else SERVICE_TURN_OFF - self.hass.services.call(self._domain, service, - {ATTR_ENTITY_ID: self.entity_id}) + self.hass.services.call(self._domain, service, params) def update_state(self, new_state): """Update switch state after state changed.""" diff --git a/homeassistant/components/homekit/type_thermostats.py b/homeassistant/components/homekit/type_thermostats.py index 15fd8160a7e961..8517122f6a88f4 100644 --- a/homeassistant/components/homekit/type_thermostats.py +++ b/homeassistant/components/homekit/type_thermostats.py @@ -4,22 +4,24 @@ from pyhap.const import CATEGORY_THERMOSTAT from homeassistant.components.climate import ( - ATTR_CURRENT_TEMPERATURE, ATTR_TEMPERATURE, - ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - ATTR_OPERATION_MODE, ATTR_OPERATION_LIST, - STATE_HEAT, STATE_COOL, STATE_AUTO, SUPPORT_ON_OFF, - SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW) + ATTR_CURRENT_TEMPERATURE, ATTR_MAX_TEMP, ATTR_MIN_TEMP, + ATTR_OPERATION_LIST, ATTR_OPERATION_MODE, + ATTR_TEMPERATURE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, + DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, + DOMAIN, SERVICE_SET_TEMPERATURE, SERVICE_SET_OPERATION_MODE, STATE_AUTO, + STATE_COOL, STATE_HEAT, SUPPORT_ON_OFF, SUPPORT_TARGET_TEMPERATURE_HIGH, + SUPPORT_TARGET_TEMPERATURE_LOW) from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT) from . import TYPES -from .accessories import HomeAccessory, debounce +from .accessories import debounce, HomeAccessory from .const import ( - SERV_THERMOSTAT, CHAR_CURRENT_HEATING_COOLING, - CHAR_TARGET_HEATING_COOLING, CHAR_CURRENT_TEMPERATURE, - CHAR_TARGET_TEMPERATURE, CHAR_TEMP_DISPLAY_UNITS, - CHAR_COOLING_THRESHOLD_TEMPERATURE, CHAR_HEATING_THRESHOLD_TEMPERATURE) + CHAR_COOLING_THRESHOLD_TEMPERATURE, CHAR_CURRENT_HEATING_COOLING, + CHAR_CURRENT_TEMPERATURE, CHAR_TARGET_HEATING_COOLING, + CHAR_HEATING_THRESHOLD_TEMPERATURE, CHAR_TARGET_TEMPERATURE, + CHAR_TEMP_DISPLAY_UNITS, PROP_MAX_VALUE, PROP_MIN_VALUE, SERV_THERMOSTAT) from .util import temperature_to_homekit, temperature_to_states _LOGGER = logging.getLogger(__name__) @@ -38,20 +40,21 @@ class Thermostat(HomeAccessory): """Generate a Thermostat accessory for a climate.""" - def __init__(self, *args, config): + def __init__(self, *args): """Initialize a Thermostat accessory object.""" super().__init__(*args, category=CATEGORY_THERMOSTAT) - self._unit = TEMP_CELSIUS + self._unit = self.hass.config.units.temperature_unit self.support_power_state = False self.heat_cool_flag_target_state = False self.temperature_flag_target_state = False self.coolingthresh_flag_target_state = False self.heatingthresh_flag_target_state = False + min_temp, max_temp = self.get_temperature_range() # Add additional characteristics if auto mode is supported self.chars = [] features = self.hass.states.get(self.entity_id) \ - .attributes.get(ATTR_SUPPORTED_FEATURES) + .attributes.get(ATTR_SUPPORTED_FEATURES, 0) if features & SUPPORT_ON_OFF: self.support_power_state = True if features & SUPPORT_TEMP_RANGE: @@ -72,6 +75,8 @@ def __init__(self, *args, config): CHAR_CURRENT_TEMPERATURE, value=21.0) self.char_target_temp = serv_thermostat.configure_char( CHAR_TARGET_TEMPERATURE, value=21.0, + properties={PROP_MIN_VALUE: min_temp, + PROP_MAX_VALUE: max_temp}, setter_callback=self.set_target_temperature) # Display units characteristic @@ -84,12 +89,30 @@ def __init__(self, *args, config): if CHAR_COOLING_THRESHOLD_TEMPERATURE in self.chars: self.char_cooling_thresh_temp = serv_thermostat.configure_char( CHAR_COOLING_THRESHOLD_TEMPERATURE, value=23.0, + properties={PROP_MIN_VALUE: min_temp, + PROP_MAX_VALUE: max_temp}, setter_callback=self.set_cooling_threshold) if CHAR_HEATING_THRESHOLD_TEMPERATURE in self.chars: self.char_heating_thresh_temp = serv_thermostat.configure_char( CHAR_HEATING_THRESHOLD_TEMPERATURE, value=19.0, + properties={PROP_MIN_VALUE: min_temp, + PROP_MAX_VALUE: max_temp}, setter_callback=self.set_heating_threshold) + def get_temperature_range(self): + """Return min and max temperature range.""" + max_temp = self.hass.states.get(self.entity_id) \ + .attributes.get(ATTR_MAX_TEMP) + max_temp = temperature_to_homekit(max_temp, self._unit) if max_temp \ + else DEFAULT_MAX_TEMP + + min_temp = self.hass.states.get(self.entity_id) \ + .attributes.get(ATTR_MIN_TEMP) + min_temp = temperature_to_homekit(min_temp, self._unit) if min_temp \ + else DEFAULT_MIN_TEMP + + return min_temp, max_temp + def set_heat_cool(self, value): """Move operation mode to value if call came from HomeKit.""" if value in HC_HOMEKIT_TO_HASS: @@ -99,12 +122,12 @@ def set_heat_cool(self, value): if self.support_power_state is True: params = {ATTR_ENTITY_ID: self.entity_id} if hass_value == STATE_OFF: - self.hass.services.call('climate', 'turn_off', params) + self.hass.services.call(DOMAIN, SERVICE_TURN_OFF, params) return - else: - self.hass.services.call('climate', 'turn_on', params) - self.hass.components.climate.set_operation_mode( - operation_mode=hass_value, entity_id=self.entity_id) + self.hass.services.call(DOMAIN, SERVICE_TURN_ON, params) + params = {ATTR_ENTITY_ID: self.entity_id, + ATTR_OPERATION_MODE: hass_value} + self.hass.services.call(DOMAIN, SERVICE_SET_OPERATION_MODE, params) @debounce def set_cooling_threshold(self, value): @@ -113,11 +136,11 @@ def set_cooling_threshold(self, value): self.entity_id, value) self.coolingthresh_flag_target_state = True low = self.char_heating_thresh_temp.value - low = temperature_to_states(low, self._unit) - value = temperature_to_states(value, self._unit) - self.hass.components.climate.set_temperature( - entity_id=self.entity_id, target_temp_high=value, - target_temp_low=low) + params = { + ATTR_ENTITY_ID: self.entity_id, + ATTR_TARGET_TEMP_HIGH: temperature_to_states(value, self._unit), + ATTR_TARGET_TEMP_LOW: temperature_to_states(low, self._unit)} + self.hass.services.call(DOMAIN, SERVICE_SET_TEMPERATURE, params) @debounce def set_heating_threshold(self, value): @@ -125,13 +148,12 @@ def set_heating_threshold(self, value): _LOGGER.debug('%s: Set heating threshold temperature to %.2f°C', self.entity_id, value) self.heatingthresh_flag_target_state = True - # Home assistant always wants to set low and high at the same time high = self.char_cooling_thresh_temp.value - high = temperature_to_states(high, self._unit) - value = temperature_to_states(value, self._unit) - self.hass.components.climate.set_temperature( - entity_id=self.entity_id, target_temp_high=high, - target_temp_low=value) + params = { + ATTR_ENTITY_ID: self.entity_id, + ATTR_TARGET_TEMP_HIGH: temperature_to_states(high, self._unit), + ATTR_TARGET_TEMP_LOW: temperature_to_states(value, self._unit)} + self.hass.services.call(DOMAIN, SERVICE_SET_TEMPERATURE, params) @debounce def set_target_temperature(self, value): @@ -139,15 +161,13 @@ def set_target_temperature(self, value): _LOGGER.debug('%s: Set target temperature to %.2f°C', self.entity_id, value) self.temperature_flag_target_state = True - value = temperature_to_states(value, self._unit) - self.hass.components.climate.set_temperature( - temperature=value, entity_id=self.entity_id) + params = { + ATTR_ENTITY_ID: self.entity_id, + ATTR_TEMPERATURE: temperature_to_states(value, self._unit)} + self.hass.services.call(DOMAIN, SERVICE_SET_TEMPERATURE, params) def update_state(self, new_state): """Update security state after state changed.""" - self._unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT, - TEMP_CELSIUS) - # Update current temperature current_temp = new_state.attributes.get(ATTR_CURRENT_TEMPERATURE) if isinstance(current_temp, (int, float)): diff --git a/homeassistant/components/homekit/util.py b/homeassistant/components/homekit/util.py index 29fe3c8f265673..23a907d43f738c 100644 --- a/homeassistant/components/homekit/util.py +++ b/homeassistant/components/homekit/util.py @@ -3,39 +3,111 @@ import voluptuous as vol +from homeassistant.components import media_player from homeassistant.core import split_entity_id from homeassistant.const import ( - ATTR_CODE, TEMP_CELSIUS) + ATTR_CODE, ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_TYPE, TEMP_CELSIUS) import homeassistant.helpers.config_validation as cv import homeassistant.util.temperature as temp_util -from .const import HOMEKIT_NOTIFY_ID +from .const import ( + CONF_FEATURE, CONF_FEATURE_LIST, HOMEKIT_NOTIFY_ID, FEATURE_ON_OFF, + FEATURE_PLAY_PAUSE, FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE, TYPE_OUTLET, + TYPE_SWITCH) _LOGGER = logging.getLogger(__name__) +BASIC_INFO_SCHEMA = vol.Schema({ + vol.Optional(CONF_NAME): cv.string, +}) + +FEATURE_SCHEMA = BASIC_INFO_SCHEMA.extend({ + vol.Optional(CONF_FEATURE_LIST, default=None): cv.ensure_list, +}) + + +CODE_SCHEMA = BASIC_INFO_SCHEMA.extend({ + vol.Optional(ATTR_CODE, default=None): vol.Any(None, cv.string), +}) + +MEDIA_PLAYER_SCHEMA = vol.Schema({ + vol.Required(CONF_FEATURE): vol.All( + cv.string, vol.In((FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, + FEATURE_PLAY_STOP, FEATURE_TOGGLE_MUTE))), +}) + +SWITCH_TYPE_SCHEMA = BASIC_INFO_SCHEMA.extend({ + vol.Optional(CONF_TYPE, default=TYPE_SWITCH): vol.All( + cv.string, vol.In((TYPE_OUTLET, TYPE_SWITCH))), +}) + + def validate_entity_config(values): """Validate config entry for CONF_ENTITY.""" entities = {} - for key, config in values.items(): - entity = cv.entity_id(key) - params = {} + for entity_id, config in values.items(): + entity = cv.entity_id(entity_id) + domain, _ = split_entity_id(entity) + if not isinstance(config, dict): - raise vol.Invalid('The configuration for "{}" must be ' - ' an dictionary.'.format(entity)) + raise vol.Invalid('The configuration for {} must be ' + ' a dictionary.'.format(entity)) + + if domain in ('alarm_control_panel', 'lock'): + config = CODE_SCHEMA(config) + + elif domain == media_player.DOMAIN: + config = FEATURE_SCHEMA(config) + feature_list = {} + for feature in config[CONF_FEATURE_LIST]: + params = MEDIA_PLAYER_SCHEMA(feature) + key = params.pop(CONF_FEATURE) + if key in feature_list: + raise vol.Invalid('A feature can be added only once for {}' + .format(entity)) + feature_list[key] = params + config[CONF_FEATURE_LIST] = feature_list + + elif domain == 'switch': + config = SWITCH_TYPE_SCHEMA(config) + + else: + config = BASIC_INFO_SCHEMA(config) + + entities[entity] = config + return entities - domain, _ = split_entity_id(entity) - if domain == 'alarm_control_panel': - code = config.get(ATTR_CODE) - params[ATTR_CODE] = cv.string(code) if code else None +def validate_media_player_features(state, feature_list): + """Validate features for media players.""" + features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - entities[entity] = params - return entities + supported_modes = [] + if features & (media_player.SUPPORT_TURN_ON | + media_player.SUPPORT_TURN_OFF): + supported_modes.append(FEATURE_ON_OFF) + if features & (media_player.SUPPORT_PLAY | media_player.SUPPORT_PAUSE): + supported_modes.append(FEATURE_PLAY_PAUSE) + if features & (media_player.SUPPORT_PLAY | media_player.SUPPORT_STOP): + supported_modes.append(FEATURE_PLAY_STOP) + if features & media_player.SUPPORT_VOLUME_MUTE: + supported_modes.append(FEATURE_TOGGLE_MUTE) + + error_list = [] + for feature in feature_list: + if feature not in supported_modes: + error_list.append(feature) + + if error_list: + _LOGGER.error("%s does not support features: %s", + state.entity_id, error_list) + return False + return True -def show_setup_message(hass, bridge): +def show_setup_message(hass, pincode): """Display persistent notification with setup information.""" - pin = bridge.pincode.decode() + pin = pincode.decode() _LOGGER.info('Pincode: %s', pin) message = 'To setup Home Assistant in the Home App, enter the ' \ 'following code:\n### {}'.format(pin) @@ -70,10 +142,10 @@ def density_to_air_quality(density): """Map PM2.5 density to HomeKit AirQuality level.""" if density <= 35: return 1 - elif density <= 75: + if density <= 75: return 2 - elif density <= 115: + if density <= 115: return 3 - elif density <= 150: + if density <= 150: return 4 return 5 diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index e36e7439e09d63..5e24fe82340626 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['homekit==0.6'] +REQUIREMENTS = ['homekit==0.10'] DOMAIN = 'homekit_controller' HOMEKIT_DIR = '.homekit' @@ -23,8 +23,15 @@ HOMEKIT_ACCESSORY_DISPATCH = { 'lightbulb': 'light', 'outlet': 'switch', + 'thermostat': 'climate', } +HOMEKIT_IGNORE = [ + 'BSB002', + 'Home Assistant Bridge', + 'TRADFRI gateway' +] + KNOWN_ACCESSORIES = "{}-accessories".format(DOMAIN) KNOWN_DEVICES = "{}-devices".format(DOMAIN) @@ -37,6 +44,7 @@ def homekit_http_send(self, message_body=None, encode_chunked=False): Appends an extra \r\n to the buffer. A message_body may be specified, to be appended to the request. """ + # pylint: disable=protected-access self._buffer.extend((b"", b"")) msg = b"\r\n".join(self._buffer) del self._buffer[:] @@ -218,8 +226,12 @@ def update_characteristics(self, characteristics): """Synchronise a HomeKit device state with Home Assistant.""" raise NotImplementedError + def put_characteristics(self, characteristics): + """Control a HomeKit device state from Home Assistant.""" + body = json.dumps({'characteristics': characteristics}) + self._securecon.put('/characteristics', body) + -# pylint: too-many-function-args def setup(hass, config): """Set up for Homekit devices.""" def discovery_dispatch(service, discovery_info): @@ -231,6 +243,9 @@ def discovery_dispatch(service, discovery_info): hkid = discovery_info['properties']['id'] config_num = int(discovery_info['properties']['c#']) + if model in HOMEKIT_IGNORE: + return + # Only register a device once, but rescan if the config has changed if hkid in hass.data[KNOWN_DEVICES]: device = hass.data[KNOWN_DEVICES][hkid] diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 0291cc28fed413..f737e2ad7d2371 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -13,17 +13,19 @@ import voluptuous as vol from homeassistant.const import ( - EVENT_HOMEASSISTANT_STOP, CONF_USERNAME, CONF_PASSWORD, CONF_PLATFORM, - CONF_HOSTS, CONF_HOST, ATTR_ENTITY_ID, STATE_UNKNOWN) + ATTR_ENTITY_ID, ATTR_NAME, CONF_HOST, CONF_HOSTS, CONF_PASSWORD, + CONF_PLATFORM, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN) from homeassistant.helpers import discovery -from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity from homeassistant.loader import bind_hass -REQUIREMENTS = ['pyhomematic==0.1.42'] -DOMAIN = 'homematic' +REQUIREMENTS = ['pyhomematic==0.1.46'] + _LOGGER = logging.getLogger(__name__) +DOMAIN = 'homematic' + SCAN_INTERVAL_HUB = timedelta(seconds=300) SCAN_INTERVAL_VARIABLES = timedelta(seconds=30) @@ -38,7 +40,6 @@ ATTR_DISCOVER_DEVICES = 'devices' ATTR_PARAM = 'param' ATTR_CHANNEL = 'channel' -ATTR_NAME = 'name' ATTR_ADDRESS = 'address' ATTR_VALUE = 'value' ATTR_INTERFACE = 'interface' @@ -60,7 +61,8 @@ HM_DEVICE_TYPES = { DISCOVER_SWITCHES: [ 'Switch', 'SwitchPowermeter', 'IOSwitch', 'IPSwitch', 'RFSiren', - 'IPSwitchPowermeter', 'HMWIOSwitch', 'Rain', 'EcoLogic'], + 'IPSwitchPowermeter', 'HMWIOSwitch', 'Rain', 'EcoLogic', + 'IPKeySwitchPowermeter'], DISCOVER_LIGHTS: ['Dimmer', 'KeyDimmer', 'IPKeyDimmer'], DISCOVER_SENSORS: [ 'SwitchPowermeter', 'Motion', 'MotionV2', 'RemoteMotion', 'MotionIP', @@ -70,7 +72,8 @@ 'TemperatureSensor', 'CO2Sensor', 'IPSwitchPowermeter', 'HMWIOSwitch', 'FillingLevel', 'ValveDrive', 'EcoLogic', 'IPThermostatWall', 'IPSmoke', 'RFSiren', 'PresenceIP', 'IPAreaThermostat', - 'IPWeatherSensor'], + 'IPWeatherSensor', 'RotaryHandleSensorIP', 'IPPassageSensor', + 'IPKeySwitchPowermeter'], DISCOVER_CLIMATE: [ 'Thermostat', 'ThermostatWall', 'MAXThermostat', 'ThermostatWall2', 'MAXWallThermostat', 'IPThermostat', 'IPThermostatWall', @@ -79,7 +82,8 @@ 'ShutterContact', 'Smoke', 'SmokeV2', 'Motion', 'MotionV2', 'MotionIP', 'RemoteMotion', 'WeatherSensor', 'TiltSensor', 'IPShutterContact', 'HMWIOSwitch', 'MaxShutterContact', 'Rain', - 'WiredSensor', 'PresenceIP', 'IPWeatherSensor'], + 'WiredSensor', 'PresenceIP', 'IPWeatherSensor', 'IPPassageSensor', + 'SmartwareMotion'], DISCOVER_COVER: ['Blind', 'KeyBlind', 'IPKeyBlind', 'IPKeyBlindTilt'], DISCOVER_LOCKS: ['KeyMatic'] } @@ -97,6 +101,7 @@ 'LOWBAT': ['battery', {0: 'High', 1: 'Low'}], 'LOW_BAT': ['battery', {0: 'High', 1: 'Low'}], 'ERROR': ['sabotage', {0: 'No', 1: 'Yes'}], + 'SABOTAGE': ['sabotage', {0: 'No', 1: 'Yes'}], 'RSSI_DEVICE': ['rssi', {}], 'VALVE_STATE': ['valve', {}], 'BATTERY_STATE': ['battery', {}], @@ -112,7 +117,7 @@ 'CURRENT': ['current', {}], 'VOLTAGE': ['voltage', {}], 'OPERATING_VOLTAGE': ['voltage', {}], - 'WORKING': ['working', {0: 'No', 1: 'Yes'}], + 'WORKING': ['working', {0: 'No', 1: 'Yes'}] } HM_PRESS_EVENTS = [ @@ -146,6 +151,7 @@ CONF_CALLBACK_IP = 'callback_ip' CONF_CALLBACK_PORT = 'callback_port' CONF_RESOLVENAMES = 'resolvenames' +CONF_JSONPORT = 'jsonport' CONF_VARIABLES = 'variables' CONF_DEVICES = 'devices' CONF_PRIMARY = 'primary' @@ -153,6 +159,7 @@ DEFAULT_LOCAL_IP = '0.0.0.0' DEFAULT_LOCAL_PORT = 0 DEFAULT_RESOLVENAMES = False +DEFAULT_JSONPORT = 80 DEFAULT_PORT = 2001 DEFAULT_PATH = '' DEFAULT_USERNAME = 'Admin' @@ -176,6 +183,7 @@ vol.Optional(CONF_PATH, default=DEFAULT_PATH): cv.string, vol.Optional(CONF_RESOLVENAMES, default=DEFAULT_RESOLVENAMES): vol.In(CONF_RESOLVENAMES_OPTIONS), + vol.Optional(CONF_JSONPORT, default=DEFAULT_JSONPORT): cv.port, vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, vol.Optional(CONF_CALLBACK_IP): cv.string, @@ -297,6 +305,7 @@ def setup(hass, config): 'port': rconfig.get(CONF_PORT), 'path': rconfig.get(CONF_PATH), 'resolvenames': rconfig.get(CONF_RESOLVENAMES), + 'jsonport': rconfig.get(CONF_JSONPORT), 'username': rconfig.get(CONF_USERNAME), 'password': rconfig.get(CONF_PASSWORD), 'callbackip': rconfig.get(CONF_CALLBACK_IP), diff --git a/homeassistant/components/homematicip_cloud.py b/homeassistant/components/homematicip_cloud.py deleted file mode 100644 index 0b15d7a3dfed22..00000000000000 --- a/homeassistant/components/homematicip_cloud.py +++ /dev/null @@ -1,258 +0,0 @@ -""" -Support for HomematicIP components. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/homematicip_cloud/ -""" - -import asyncio -import logging - -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.helpers.discovery import async_load_platform -from homeassistant.helpers.entity import Entity -from homeassistant.core import callback - -REQUIREMENTS = ['homematicip==0.9.2.4'] - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = 'homematicip_cloud' - -COMPONENTS = [ - 'sensor' -] - -CONF_NAME = 'name' -CONF_ACCESSPOINT = 'accesspoint' -CONF_AUTHTOKEN = 'authtoken' - -CONFIG_SCHEMA = vol.Schema({ - vol.Optional(DOMAIN, default=[]): vol.All(cv.ensure_list, [vol.Schema({ - vol.Optional(CONF_NAME): vol.Any(cv.string), - vol.Required(CONF_ACCESSPOINT): cv.string, - vol.Required(CONF_AUTHTOKEN): cv.string, - })]), -}, extra=vol.ALLOW_EXTRA) - -HMIP_ACCESS_POINT = 'Access Point' -HMIP_HUB = 'HmIP-HUB' - -ATTR_HOME_ID = 'home_id' -ATTR_HOME_NAME = 'home_name' -ATTR_DEVICE_ID = 'device_id' -ATTR_DEVICE_LABEL = 'device_label' -ATTR_STATUS_UPDATE = 'status_update' -ATTR_FIRMWARE_STATE = 'firmware_state' -ATTR_UNREACHABLE = 'unreachable' -ATTR_LOW_BATTERY = 'low_battery' -ATTR_MODEL_TYPE = 'model_type' -ATTR_GROUP_TYPE = 'group_type' -ATTR_DEVICE_RSSI = 'device_rssi' -ATTR_DUTY_CYCLE = 'duty_cycle' -ATTR_CONNECTED = 'connected' -ATTR_SABOTAGE = 'sabotage' -ATTR_OPERATION_LOCK = 'operation_lock' - - -async def async_setup(hass, config): - """Set up the HomematicIP component.""" - from homematicip.base.base_connection import HmipConnectionError - - hass.data.setdefault(DOMAIN, {}) - accesspoints = config.get(DOMAIN, []) - for conf in accesspoints: - _websession = async_get_clientsession(hass) - _hmip = HomematicipConnector(hass, conf, _websession) - try: - await _hmip.init() - except HmipConnectionError: - _LOGGER.error('Failed to connect to the HomematicIP server, %s.', - conf.get(CONF_ACCESSPOINT)) - return False - - home = _hmip.home - home.name = conf.get(CONF_NAME) - home.label = HMIP_ACCESS_POINT - home.modelType = HMIP_HUB - - hass.data[DOMAIN][home.id] = home - _LOGGER.info('Connected to the HomematicIP server, %s.', - conf.get(CONF_ACCESSPOINT)) - homeid = {ATTR_HOME_ID: home.id} - for component in COMPONENTS: - hass.async_add_job(async_load_platform(hass, component, DOMAIN, - homeid, config)) - - hass.loop.create_task(_hmip.connect()) - return True - - -class HomematicipConnector: - """Manages HomematicIP http and websocket connection.""" - - def __init__(self, hass, config, websession): - """Initialize HomematicIP cloud connection.""" - from homematicip.async.home import AsyncHome - - self._hass = hass - self._ws_close_requested = False - self._retry_task = None - self._tries = 0 - self._accesspoint = config.get(CONF_ACCESSPOINT) - _authtoken = config.get(CONF_AUTHTOKEN) - - self.home = AsyncHome(hass.loop, websession) - self.home.set_auth_token(_authtoken) - - self.home.on_update(self.async_update) - self._accesspoint_connected = True - - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self.close()) - - async def init(self): - """Initialize connection.""" - await self.home.init(self._accesspoint) - await self.home.get_current_state() - - @callback - def async_update(self, *args, **kwargs): - """Async update the home device. - - Triggered when the hmip HOME_CHANGED event has fired. - There are several occasions for this event to happen. - We are only interested to check whether the access point - is still connected. If not, device state changes cannot - be forwarded to hass. So if access point is disconnected all devices - are set to unavailable. - """ - if not self.home.connected: - _LOGGER.error( - "HMIP access point has lost connection with the cloud") - self._accesspoint_connected = False - self.set_all_to_unavailable() - elif not self._accesspoint_connected: - # Explicitly getting an update as device states might have - # changed during access point disconnect.""" - - job = self._hass.async_add_job(self.get_state()) - job.add_done_callback(self.get_state_finished) - - async def get_state(self): - """Update hmip state and tell hass.""" - await self.home.get_current_state() - self.update_all() - - def get_state_finished(self, future): - """Execute when get_state coroutine has finished.""" - from homematicip.base.base_connection import HmipConnectionError - - try: - future.result() - except HmipConnectionError: - # Somehow connection could not recover. Will disconnect and - # so reconnect loop is taking over. - _LOGGER.error( - "updating state after himp access point reconnect failed.") - self._hass.async_add_job(self.home.disable_events()) - - def set_all_to_unavailable(self): - """Set all devices to unavailable and tell Hass.""" - for device in self.home.devices: - device.unreach = True - self.update_all() - - def update_all(self): - """Signal all devices to update their state.""" - for device in self.home.devices: - device.fire_update_event() - - async def _handle_connection(self): - """Handle websocket connection.""" - from homematicip.base.base_connection import HmipConnectionError - - await self.home.get_current_state() - hmip_events = await self.home.enable_events() - try: - await hmip_events - except HmipConnectionError: - return - - async def connect(self): - """Start websocket connection.""" - self._tries = 0 - while True: - await self._handle_connection() - if self._ws_close_requested: - break - self._ws_close_requested = False - self._tries += 1 - try: - self._retry_task = self._hass.async_add_job(asyncio.sleep( - 2 ** min(9, self._tries), loop=self._hass.loop)) - await self._retry_task - except asyncio.CancelledError: - break - _LOGGER.info('Reconnect (%s) to the HomematicIP cloud server.', - self._tries) - - async def close(self): - """Close the websocket connection.""" - self._ws_close_requested = True - if self._retry_task is not None: - self._retry_task.cancel() - await self.home.disable_events() - _LOGGER.info("Closed connection to HomematicIP cloud server.") - - -class HomematicipGenericDevice(Entity): - """Representation of an HomematicIP generic device.""" - - def __init__(self, home, device, post=None): - """Initialize the generic device.""" - self._home = home - self._device = device - self.post = post - _LOGGER.info('Setting up %s (%s)', self.name, - self._device.modelType) - - async def async_added_to_hass(self): - """Register callbacks.""" - self._device.on_update(self._device_changed) - - def _device_changed(self, json, **kwargs): - """Handle device state changes.""" - _LOGGER.debug('Event %s (%s)', self.name, self._device.modelType) - self.async_schedule_update_ha_state() - - @property - def name(self): - """Return the name of the generic device.""" - name = self._device.label - if self._home.name is not None: - name = "{} {}".format(self._home.name, name) - if self.post is not None: - name = "{} {}".format(name, self.post) - return name - - @property - def should_poll(self): - """No polling needed.""" - return False - - @property - def available(self): - """Device available.""" - return not self._device.unreach - - @property - def device_state_attributes(self): - """Return the state attributes of the generic device.""" - return { - ATTR_LOW_BATTERY: self._device.lowBat, - ATTR_MODEL_TYPE: self._device.modelType - } diff --git a/homeassistant/components/homematicip_cloud/.translations/ca.json b/homeassistant/components/homematicip_cloud/.translations/ca.json new file mode 100644 index 00000000000000..9d40bc2d24170d --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/ca.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "El punt d'acc\u00e9s ja est\u00e0 configurat", + "conection_aborted": "No s'ha pogut connectar al servidor HMIP", + "unknown": "S'ha produ\u00eft un error desconegut." + }, + "error": { + "invalid_pin": "Codi PIN inv\u00e0lid, torna-ho a provar.", + "press_the_button": "Si us plau, premeu el bot\u00f3 blau.", + "register_failed": "Error al registrar, torneu-ho a provar.", + "timeout_button": "Temps d'espera per pr\u00e9mer el bot\u00f3 blau esgotat, torneu-ho a provar." + }, + "step": { + "init": { + "data": { + "hapid": "Identificador del punt d'acc\u00e9s (SGTIN)", + "name": "Nom (opcional, s'utilitza com a nom prefix per a tots els dispositius)", + "pin": "Codi PIN (opcional)" + }, + "title": "Trieu el punt d'acc\u00e9s HomematicIP" + }, + "link": { + "description": "Premeu el bot\u00f3 blau del punt d'acc\u00e9s i el bot\u00f3 de enviar per registrar HomematicIP amb Home Assistent. \n\n ![Ubicaci\u00f3 del bot\u00f3 al pont](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Enlla\u00e7ar punt d'acc\u00e9s" + } + }, + "title": "HomematicIP Cloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/cs.json b/homeassistant/components/homematicip_cloud/.translations/cs.json new file mode 100644 index 00000000000000..59f232edea486a --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/cs.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "P\u0159\u00edstupov\u00fd bod je ji\u017e nakonfigurov\u00e1n", + "conection_aborted": "Nelze se p\u0159ipojit k serveru HMIP", + "unknown": "Do\u0161lo k nezn\u00e1m\u00e9 chyb\u011b" + }, + "error": { + "invalid_pin": "Neplatn\u00fd k\u00f3d PIN, zkuste to znovu.", + "press_the_button": "Stiskn\u011bte modr\u00e9 tla\u010d\u00edtko.", + "register_failed": "Registrace se nezda\u0159ila, zkuste to znovu.", + "timeout_button": "\u010casov\u00fd limit stisknut\u00ed modr\u00e9ho tla\u010d\u00edtka vypr\u0161el. Zkuste to znovu." + }, + "step": { + "init": { + "data": { + "hapid": "ID p\u0159\u00edstupov\u00e9ho bodu (SGTIN)", + "name": "N\u00e1zev (nepovinn\u00e9, pou\u017e\u00edv\u00e1 se jako p\u0159edpona n\u00e1zvu pro v\u0161echna za\u0159\u00edzen\u00ed)", + "pin": "Pin k\u00f3d (nepovinn\u00e9)" + }, + "title": "Vyberte p\u0159\u00edstupov\u00fd bod HomematicIP" + }, + "link": { + "description": "Stiskn\u011bte modr\u00e9 tla\u010d\u00edtko na p\u0159\u00edstupov\u00e9m bodu a tla\u010d\u00edtko pro registraci HomematicIP s dom\u00e1c\u00edm asistentem. \n\n ! [Um\u00edst\u011bn\u00ed tla\u010d\u00edtka na za\u0159\u00edzen\u00ed] (/static/images/config_flows/config_homematicip_cloud.png)", + "title": "P\u0159ipojit se k p\u0159\u00edstupov\u00e9mu bodu" + } + }, + "title": "HomematicIP Cloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/de.json b/homeassistant/components/homematicip_cloud/.translations/de.json new file mode 100644 index 00000000000000..61a9bd6eb404bc --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/de.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Der Accesspoint ist bereits konfiguriert", + "conection_aborted": "Keine Verbindung zum HMIP-Server m\u00f6glich", + "unknown": "Ein unbekannter Fehler ist aufgetreten." + }, + "error": { + "invalid_pin": "Ung\u00fcltige PIN, bitte versuchen Sie es erneut.", + "press_the_button": "Bitte dr\u00fccken Sie die blaue Taste.", + "register_failed": "Registrierung fehlgeschlagen, bitte versuchen Sie es erneut.", + "timeout_button": "Zeit\u00fcberschreitung beim Dr\u00fccken der blauen Taste. Bitte versuchen Sie es erneut." + }, + "step": { + "init": { + "data": { + "hapid": "Accesspoint ID (SGTIN)", + "name": "Name (optional, wird als Pr\u00e4fix f\u00fcr alle Ger\u00e4te verwendet)", + "pin": "PIN Code (optional)" + }, + "title": "HometicIP Accesspoint ausw\u00e4hlen" + }, + "link": { + "description": "Dr\u00fccken Sie den blauen Taster auf dem Accesspoint, sowie den Senden Button um HomematicIP mit Home Assistant zu verbinden.\n\n![Position des Tasters auf dem AP](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Verkn\u00fcpfe den Accesspoint" + } + }, + "title": "HomematicIP Cloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/en.json b/homeassistant/components/homematicip_cloud/.translations/en.json new file mode 100644 index 00000000000000..0cf99cd297582f --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/en.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Accesspoint is already configured", + "conection_aborted": "Could not connect to HMIP server", + "unknown": "Unknown error occurred." + }, + "error": { + "invalid_pin": "Invalid PIN, please try again.", + "press_the_button": "Please press the blue button.", + "register_failed": "Failed to register, please try again.", + "timeout_button": "Blue button press timeout, please try again." + }, + "step": { + "init": { + "data": { + "hapid": "Accesspoint ID (SGTIN)", + "name": "Name (optional, used as name prefix for all devices)", + "pin": "Pin Code (optional)" + }, + "title": "Pick HomematicIP Accesspoint" + }, + "link": { + "description": "Press the blue button on the accesspoint and the submit button to register HomematicIP with Home Assistant.\n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Link Accesspoint" + } + }, + "title": "HomematicIP Cloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/es-419.json b/homeassistant/components/homematicip_cloud/.translations/es-419.json new file mode 100644 index 00000000000000..9af472893807b9 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/es-419.json @@ -0,0 +1,23 @@ +{ + "config": { + "abort": { + "already_configured": "Accesspoint ya est\u00e1 configurado", + "conection_aborted": "No se pudo conectar al servidor HMIP", + "unknown": "Se produjo un error desconocido." + }, + "error": { + "invalid_pin": "PIN no v\u00e1lido, por favor intente de nuevo.", + "press_the_button": "Por favor, presione el bot\u00f3n azul.", + "register_failed": "No se pudo registrar, por favor intente de nuevo." + }, + "step": { + "init": { + "data": { + "hapid": "ID de punto de acceso (SGTIN)", + "name": "Nombre (opcional, usado como prefijo de nombre para todos los dispositivos)", + "pin": "C\u00f3digo PIN (opcional)" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/hu.json b/homeassistant/components/homematicip_cloud/.translations/hu.json new file mode 100644 index 00000000000000..f2f22e6a49d1e1 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/hu.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "HomematicIP Felh\u0151" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/ja.json b/homeassistant/components/homematicip_cloud/.translations/ja.json new file mode 100644 index 00000000000000..105a74157897b8 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/ja.json @@ -0,0 +1,13 @@ +{ + "config": { + "abort": { + "already_configured": "\u30a2\u30af\u30bb\u30b9\u30dd\u30a4\u30f3\u30c8\u306f\u65e2\u306b\u8a2d\u5b9a\u3055\u308c\u3066\u3044\u307e\u3059", + "conection_aborted": "HMIP\u30b5\u30fc\u30d0\u30fc\u306b\u63a5\u7d9a\u3067\u304d\u307e\u305b\u3093\u3067\u3057\u305f", + "unknown": "\u4e0d\u660e\u306a\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f\u3002" + }, + "error": { + "invalid_pin": "PIN\u304c\u7121\u52b9\u3067\u3059\u3001\u3082\u3046\u4e00\u5ea6\u304a\u8a66\u3057\u304f\u3060\u3055\u3044\u3002", + "press_the_button": "\u9752\u3044\u30dc\u30bf\u30f3\u3092\u62bc\u3057\u3066\u304f\u3060\u3055\u3044\u3002" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/ko.json b/homeassistant/components/homematicip_cloud/.translations/ko.json new file mode 100644 index 00000000000000..e135873067eb86 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/ko.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8\uac00 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4", + "conection_aborted": "HMIP \uc11c\ubc84\uc5d0 \uc5f0\uacb0\ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4", + "unknown": "\uc54c \uc218\uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4" + }, + "error": { + "invalid_pin": "PIN\uc774 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "press_the_button": "\ud30c\ub780\uc0c9 \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694.", + "register_failed": "\ub4f1\ub85d\uc5d0 \uc2e4\ud328\ud558\uc600\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "timeout_button": "\uc815\ud574\uc9c4 \uc2dc\uac04\ub0b4\uc5d0 \ud30c\ub780\uc0c9 \ubc84\ud2bc\uc744 \ub20c\ub974\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694." + }, + "step": { + "init": { + "data": { + "hapid": "\uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8 ID (SGTIN)", + "name": "\uc774\ub984 (\uc120\ud0dd \uc0ac\ud56d, \ubaa8\ub4e0 \uc7a5\uce58 \uc774\ub984\uc758 \uc811\ub450\uc5b4\ub85c \uc0ac\uc6a9)", + "pin": "PIN \ucf54\ub4dc (\uc120\ud0dd\uc0ac\ud56d)" + }, + "title": "HomematicIP \uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8 \uc120\ud0dd" + }, + "link": { + "description": "Home Assistant\uc5d0 HomematicIP\ub97c \ub4f1\ub85d\ud558\ub824\uba74 \uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8\uc758 \ud30c\ub780\uc0c9 \ubc84\ud2bc\uacfc \uc11c\ubc0b \ubc84\ud2bc\uc744 \ub20c\ub7ec\uc8fc\uc138\uc694.\n\n![\ube0c\ub9bf\uc9c0\uc758 \ubc84\ud2bc \uc704\uce58 \ubcf4\uae30](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "\uc561\uc138\uc2a4 \ud3ec\uc778\ud2b8\uc5d0 \uc5f0\uacb0" + } + }, + "title": "HomematicIP \ud074\ub77c\uc6b0\ub4dc" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/lb.json b/homeassistant/components/homematicip_cloud/.translations/lb.json new file mode 100644 index 00000000000000..3dd3f1a5dca388 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/lb.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Acesspoint ass schon konfigur\u00e9iert", + "conection_aborted": "Konnt sech net mam HMIP Server verbannen", + "unknown": "Onbekannten Feeler opgetrueden" + }, + "error": { + "invalid_pin": "Ong\u00ebltege Pin, prob\u00e9iert w.e.g. nach emol.", + "press_the_button": "Dr\u00e9ckt w.e.g. de bloe Kn\u00e4ppchen.", + "register_failed": "Feeler beim registr\u00e9ieren, prob\u00e9iert w.e.g. nach emol.", + "timeout_button": "Z\u00e4itiwwerschreidung beim dr\u00e9cken vum bloe Kn\u00e4ppchen, prob\u00e9iert w.e.g. nach emol." + }, + "step": { + "init": { + "data": { + "hapid": "ID vum Accesspoint (SGTIN)", + "name": "Numm (optional, g\u00ebtt als prefixe fir all Apparat benotzt)", + "pin": "Pin Code (Optional)" + }, + "title": "HomematicIP Accesspoint auswielen" + }, + "link": { + "description": "Dr\u00e9ckt de bloen Kn\u00e4ppchen um Accesspoint an den Submit Kn\u00e4ppchen fir d'HomematicIP mam Home Assistant ze registr\u00e9ieren.", + "title": "Accesspoint verbannen" + } + }, + "title": "HomematicIP Cloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/nl.json b/homeassistant/components/homematicip_cloud/.translations/nl.json new file mode 100644 index 00000000000000..0559dae4bfd66c --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/nl.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Accesspoint is reeds geconfigureerd", + "conection_aborted": "Kon geen verbinding maken met de HMIP-server", + "unknown": "Er is een onbekende fout opgetreden." + }, + "error": { + "invalid_pin": "Ongeldige PIN-code, probeer het nogmaals.", + "press_the_button": "Druk op de blauwe knop.", + "register_failed": "Kan niet registreren, gelieve opnieuw te proberen.", + "timeout_button": "Blauwe knop druk op timeout, probeer het opnieuw." + }, + "step": { + "init": { + "data": { + "hapid": "Accesspoint ID (SGTIN)", + "name": "Naam (optioneel, gebruikt als naamprefix voor alle apparaten)", + "pin": "Pin-Code (optioneel)" + }, + "title": "Kies HomematicIP Accesspoint" + }, + "link": { + "description": "Druk op de blauwe knop op de accesspoint en de verzendknop om HomematicIP met de Home Assistant te registreren. \n\n![Locatie van knop op brug](/static/images/config_flows/\nconfig_homematicip_cloud.png)", + "title": "Link Accesspoint" + } + }, + "title": "HomematicIP Cloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/no.json b/homeassistant/components/homematicip_cloud/.translations/no.json new file mode 100644 index 00000000000000..650c921af31e1b --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/no.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Tilgangspunktet er allerede konfigurert", + "conection_aborted": "Kunne ikke koble til HMIP serveren", + "unknown": "Ukjent feil oppstod." + }, + "error": { + "invalid_pin": "Ugyldig PIN kode, pr\u00f8v igjen.", + "press_the_button": "Vennligst trykk p\u00e5 den bl\u00e5 knappen.", + "register_failed": "Kunne ikke registrere, vennligst pr\u00f8v igjen.", + "timeout_button": "Bl\u00e5 knapp-trykk tok for lang tid, vennligst pr\u00f8v igjen." + }, + "step": { + "init": { + "data": { + "hapid": "Tilgangspunkt ID (SGTIN)", + "name": "Navn (valgfritt, brukes som prefiks for alle enheter)", + "pin": "PIN kode (valgfritt)" + }, + "title": "Velg HomematicIP tilgangspunkt" + }, + "link": { + "description": "Trykk p\u00e5 den bl\u00e5 knappen p\u00e5 tilgangspunktet og send knappen for \u00e5 registrere HomematicIP med Home Assistant. \n\n![Plassering av knapp p\u00e5 bridge](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Link tilgangspunkt" + } + }, + "title": "HomematicIP Sky" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/pl.json b/homeassistant/components/homematicip_cloud/.translations/pl.json new file mode 100644 index 00000000000000..c2ec6be4bd465a --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/pl.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Punkt dost\u0119pu jest ju\u017c skonfigurowany", + "conection_aborted": "Nie mo\u017cna po\u0142\u0105czy\u0107 si\u0119 z serwerem HMIP", + "unknown": "Wyst\u0105pi\u0142 nieznany b\u0142\u0105d" + }, + "error": { + "invalid_pin": "Nieprawid\u0142owy kod PIN, spr\u00f3buj ponownie.", + "press_the_button": "Prosz\u0119 nacisn\u0105\u0107 niebieski przycisk.", + "register_failed": "Nie uda\u0142o si\u0119 zarejestrowa\u0107, spr\u00f3buj ponownie.", + "timeout_button": "Oczekiwania na naci\u015bni\u0119cie niebieskiego przycisku zako\u0144czone, spr\u00f3buj ponownie." + }, + "step": { + "init": { + "data": { + "hapid": "ID punktu dost\u0119pu (SGTIN)", + "name": "Nazwa (opcjonalnie, u\u017cywana jako prefiks nazwy dla wszystkich urz\u0105dze\u0144)", + "pin": "Kod PIN (opcjonalnie)" + }, + "title": "Wybierz punkt dost\u0119pu HomematicIP" + }, + "link": { + "description": "Naci\u015bnij niebieski przycisk na punkcie dost\u0119pu i przycisk przesy\u0142ania, aby zarejestrowa\u0107 HomematicIP w Home Assistant. \n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Po\u0142\u0105cz z punktem dost\u0119pu" + } + }, + "title": "Chmura HomematicIP" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/pt-BR.json b/homeassistant/components/homematicip_cloud/.translations/pt-BR.json new file mode 100644 index 00000000000000..6e5af1c26cc97a --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/pt-BR.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "O Accesspoint j\u00e1 est\u00e1 configurado", + "conection_aborted": "N\u00e3o foi poss\u00edvel conectar ao servidor HMIP", + "unknown": "Ocorreu um erro desconhecido." + }, + "error": { + "invalid_pin": "PIN inv\u00e1lido, por favor tente novamente.", + "press_the_button": "Por favor, pressione o bot\u00e3o azul.", + "register_failed": "Falha ao registrar, por favor tente novamente.", + "timeout_button": "Tempo para pressionar o Bot\u00e3o Azul expirou, por favor tente novamente." + }, + "step": { + "init": { + "data": { + "hapid": "ID do AccessPoint (SGTIN)", + "name": "Nome (opcional, usado como prefixo de nome para todos os dispositivos)", + "pin": "C\u00f3digo PIN (opcional)" + }, + "title": "Escolha um HomematicIP Accesspoint" + }, + "link": { + "description": "Pressione o bot\u00e3o azul no ponto de acesso e o bot\u00e3o enviar para registrar o HomematicIP com o Home Assistant.\n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Accesspoint link" + } + }, + "title": "Nuvem do HomematicIP" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/pt.json b/homeassistant/components/homematicip_cloud/.translations/pt.json new file mode 100644 index 00000000000000..2266e83ac440a0 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/pt.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "O ponto de acesso j\u00e1 se encontra configurado", + "conection_aborted": "N\u00e3o foi poss\u00edvel ligar ao servidor HMIP", + "unknown": "Ocorreu um erro desconhecido." + }, + "error": { + "invalid_pin": "PIN inv\u00e1lido, por favor, tente novamente.", + "press_the_button": "Por favor, pressione o bot\u00e3o azul.", + "register_failed": "Falha ao registrar, por favor, tente novamente.", + "timeout_button": "Tempo limite ultrapassado para carregar bot\u00e3o azul, por favor, tente de novo." + }, + "step": { + "init": { + "data": { + "hapid": "ID do ponto de acesso (SGTIN)", + "name": "Nome (opcional, usado como prefixo de nome para todos os dispositivos)", + "pin": "C\u00f3digo PIN (opcional)" + }, + "title": "Escolher ponto de acesso HomematicIP" + }, + "link": { + "description": "Pressione o bot\u00e3o azul no ponto de acesso e o bot\u00e3o enviar para registrar HomematicIP com o Home Assistant.\n\n![Localiza\u00e7\u00e3o do bot\u00e3o na ponte](/ static/images/config_flows/config_homematicip_cloud.png)", + "title": "Associar ponto de acesso" + } + }, + "title": "Nuvem do HomematicIP" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/ru.json b/homeassistant/components/homematicip_cloud/.translations/ru.json new file mode 100644 index 00000000000000..77c6469f64c67c --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/ru.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\u0422\u043e\u0447\u043a\u0430 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0443\u0436\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0435\u043d\u0430", + "conection_aborted": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u043f\u043e\u0434\u043a\u043b\u044e\u0447\u0438\u0442\u044c\u0441\u044f \u043a \u0441\u0435\u0440\u0432\u0435\u0440\u0443 HMIP", + "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430" + }, + "error": { + "invalid_pin": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 PIN-\u043a\u043e\u0434, \u043f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430.", + "press_the_button": "\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043d\u0430\u0436\u043c\u0438\u0442\u0435 \u0441\u0438\u043d\u044e\u044e \u043a\u043d\u043e\u043f\u043a\u0443.", + "register_failed": "\u041d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c\u0441\u044f, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430", + "timeout_button": "\u0412\u044b \u043d\u0435 \u043d\u0430\u0436\u0430\u043b\u0438 \u0441\u0438\u043d\u0438\u044e \u043a\u043d\u043e\u043f\u043a\u0443 \u0432 \u043f\u0440\u0435\u0434\u0435\u043b\u0430\u0445 \u0443\u0441\u0442\u0430\u043d\u043e\u0432\u043b\u0435\u043d\u043d\u043e\u0433\u043e \u0432\u0440\u0435\u043c\u0435\u043d\u0438, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430" + }, + "step": { + "init": { + "data": { + "hapid": "\u0418\u0434\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0442\u043e\u0440 \u0442\u043e\u0447\u043a\u0438 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 (SGTIN)", + "name": "\u041d\u0430\u0437\u0432\u0430\u043d\u0438\u0435 (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e, \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u0443\u0435\u0442\u0441\u044f \u043a\u0430\u043a \u043f\u0440\u0435\u0444\u0438\u043a\u0441 \u0434\u043b\u044f \u043d\u0430\u0437\u0432\u0430\u043d\u0438\u044f \u0432\u0441\u0435\u0445 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432)", + "pin": "PIN-\u043a\u043e\u0434 (\u043d\u0435\u043e\u0431\u044f\u0437\u0430\u0442\u0435\u043b\u044c\u043d\u043e)" + }, + "title": "\u0412\u044b\u0431\u0438\u0440\u0438\u0442\u0435 \u0442\u043e\u0447\u043a\u0443 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 HomematicIP" + }, + "link": { + "description": "\u041d\u0430\u0436\u043c\u0438\u0442\u0435 \u0441\u0438\u043d\u044e\u044e \u043a\u043d\u043e\u043f\u043a\u0443 \u043d\u0430 \u0442\u043e\u0447\u043a\u0435 \u0434\u043e\u0441\u0442\u0443\u043f\u0430 \u0438 \u043a\u043d\u043e\u043f\u043a\u0443 \u043e\u0442\u043f\u0440\u0430\u0432\u043a\u0438, \u0447\u0442\u043e\u0431\u044b \u0437\u0430\u0440\u0435\u0433\u0438\u0441\u0442\u0440\u0438\u0440\u043e\u0432\u0430\u0442\u044c HomematicIP \u0432 Home Assistant. \n\n ![\u0420\u0430\u0441\u043f\u043e\u043b\u043e\u0436\u0435\u043d\u0438\u0435 \u043a\u043d\u043e\u043f\u043a\u0438](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "\u041f\u0440\u0438\u0432\u044f\u0437\u0430\u0442\u044c \u0442\u043e\u0447\u043a\u0443 \u0434\u043e\u0441\u0442\u0443\u043f\u0430" + } + }, + "title": "HomematicIP Cloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/sl.json b/homeassistant/components/homematicip_cloud/.translations/sl.json new file mode 100644 index 00000000000000..d9749480c0d59b --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/sl.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Dostopna to\u010dka je \u017ee konfigurirana", + "conection_aborted": "Povezave s stre\u017enikom HMIP ni bila mogo\u010da", + "unknown": "Pri\u0161lo je do neznane napake" + }, + "error": { + "invalid_pin": "Neveljavna koda PIN, poskusite znova.", + "press_the_button": "Prosimo, pritisnite modri gumb.", + "register_failed": "Registracija ni uspela, poskusite znova", + "timeout_button": "Potekla je \u010dasovna omejitev za pritisk modrega gumba, poskusite znova." + }, + "step": { + "init": { + "data": { + "hapid": "ID dostopne to\u010dke (SGTIN)", + "name": "Ime (neobvezno, ki se uporablja kot predpona za vse naprave)", + "pin": "Koda PIN (neobvezno)" + }, + "title": "Izberite dostopno to\u010dko HomematicIP" + }, + "link": { + "description": "Pritisnite modro tipko na dostopni to\u010dko in gumb po\u0161lji, da registrirate homematicIP s Home Assistentom. \n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "Pove\u017eite dostopno to\u010dno" + } + }, + "title": "HomematicIP Cloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/sv.json b/homeassistant/components/homematicip_cloud/.translations/sv.json new file mode 100644 index 00000000000000..945dca8a277c79 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/sv.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Accesspunkten \u00e4r redan konfigurerad", + "conection_aborted": "Kunde inte ansluta till HMIP server", + "unknown": "Ett ok\u00e4nt fel har intr\u00e4ffat" + }, + "error": { + "invalid_pin": "Ogiltig PIN-kod, f\u00f6rs\u00f6k igen.", + "press_the_button": "V\u00e4nligen tryck p\u00e5 den bl\u00e5 knappen.", + "register_failed": "Misslyckades med att registrera, f\u00f6rs\u00f6k igen.", + "timeout_button": "Bl\u00e5 knapptryckning timeout, f\u00f6rs\u00f6k igen." + }, + "step": { + "init": { + "data": { + "hapid": "Accesspunkt-ID (SGTIN)", + "name": "Namn (frivilligt, anv\u00e4nds som namnprefix f\u00f6r alla enheter)", + "pin": "Pin-kod (frivilligt)" + }, + "title": "V\u00e4lj HomematicIP Accesspunkt" + }, + "link": { + "description": "Tryck p\u00e5 den bl\u00e5 knappen p\u00e5 accesspunkten och p\u00e5 skickaknappen f\u00f6r att registrera HomematicIP med Home-Assistant. \n\n ![Placering av knapp p\u00e5 bryggan](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "L\u00e4nka Accesspunkt" + } + }, + "title": "HomematicIP Moln" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/zh-Hans.json b/homeassistant/components/homematicip_cloud/.translations/zh-Hans.json new file mode 100644 index 00000000000000..38970e4a97cad5 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/zh-Hans.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "\u63a5\u5165\u70b9\u5df2\u7ecf\u914d\u7f6e\u5b8c\u6210", + "conection_aborted": "\u65e0\u6cd5\u8fde\u63a5\u5230 HMIP \u670d\u52a1\u5668", + "unknown": "\u53d1\u751f\u672a\u77e5\u9519\u8bef\u3002" + }, + "error": { + "invalid_pin": "PIN \u65e0\u6548\uff0c\u8bf7\u518d\u8bd5\u4e00\u6b21\u3002", + "press_the_button": "\u8bf7\u6309\u4e0b\u84dd\u8272\u6309\u94ae\u3002", + "register_failed": "\u6ce8\u518c\u5931\u8d25\uff0c\u8bf7\u91cd\u8bd5", + "timeout_button": "\u6309\u4e0b\u84dd\u8272\u6309\u94ae\u8d85\u65f6\uff0c\u8bf7\u518d\u8bd5\u4e00\u6b21\u3002" + }, + "step": { + "init": { + "data": { + "hapid": "\u63a5\u5165\u70b9 ID (SGTIN)", + "name": "\u540d\u79f0\uff08\u53ef\u9009\uff0c\u7528\u4f5c\u6240\u6709\u8bbe\u5907\u7684\u540d\u79f0\u524d\u7f00\uff09", + "pin": "PIN \u7801\uff08\u53ef\u9009\uff09" + }, + "title": "\u9009\u62e9 HomematicIP \u63a5\u5165\u70b9" + }, + "link": { + "description": "\u6309\u4e0b\u63a5\u5165\u70b9\u4e0a\u7684\u84dd\u8272\u6309\u94ae\u7136\u540e\u70b9\u51fb\u63d0\u4ea4\u6309\u94ae\uff0c\u4ee5\u5c06 HomematicIP \u6ce8\u518c\u5230 Home Assistant\u3002\n\n![\u63a5\u5165\u70b9\u7684\u6309\u94ae\u4f4d\u7f6e]\n(/static/images/config_flows/config_homematicip_cloud.png)", + "title": "\u8fde\u63a5\u63a5\u5165\u70b9" + } + }, + "title": "HomematicIP Cloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/zh-Hant.json b/homeassistant/components/homematicip_cloud/.translations/zh-Hant.json new file mode 100644 index 00000000000000..d8c6cff9b0cc65 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/.translations/zh-Hant.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Accesspoint \u5df2\u7d93\u8a2d\u5b9a\u5b8c\u6210", + "conection_aborted": "\u7121\u6cd5\u9023\u7dda\u81f3 HMIP \u4f3a\u670d\u5668", + "unknown": "\u767c\u751f\u672a\u77e5\u932f\u8aa4\u3002" + }, + "error": { + "invalid_pin": "PIN \u78bc\u7121\u6548\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002", + "press_the_button": "\u8acb\u6309\u4e0b\u85cd\u8272\u6309\u9215\u3002", + "register_failed": "\u8a3b\u518a\u5931\u6557\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002", + "timeout_button": "\u85cd\u8272\u6309\u9215\u903e\u6642\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002" + }, + "step": { + "init": { + "data": { + "hapid": "Accesspoint ID (SGTIN)", + "name": "\u540d\u7a31\uff08\u9078\u9805\uff0c\u7528\u4ee5\u4f5c\u70ba\u6240\u6709\u88dd\u7f6e\u7684\u5b57\u9996\u7528\uff09", + "pin": "PIN \u78bc\uff08\u9078\u9805\uff09" + }, + "title": "\u9078\u64c7 HomematicIP Accesspoint" + }, + "link": { + "description": "\u6309\u4e0b AP \u4e0a\u7684\u85cd\u8272\u6309\u9215\u8207\u50b3\u9001\u6309\u9215\uff0c\u4ee5\u65bc Home Assistant \u4e0a\u9032\u884c HomematicIP \u8a3b\u518a\u3002\n\n![\u6a4b\u63a5\u5668\u4e0a\u7684\u6309\u9215\u4f4d\u7f6e](/static/images/config_flows/config_homematicip_cloud.png)", + "title": "\u9023\u7d50 Accesspoint" + } + }, + "title": "HomematicIP Cloud" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py new file mode 100644 index 00000000000000..f2cc8f443ac801 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -0,0 +1,67 @@ +""" +Support for HomematicIP components. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/homematicip_cloud/ +""" + +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant import config_entries + +from .const import ( + DOMAIN, HMIPC_HAPID, HMIPC_AUTHTOKEN, HMIPC_NAME, + CONF_ACCESSPOINT, CONF_AUTHTOKEN, CONF_NAME) +# Loading the config flow file will register the flow +from .config_flow import configured_haps +from .hap import HomematicipHAP, HomematicipAuth # noqa: F401 +from .device import HomematicipGenericDevice # noqa: F401 + +REQUIREMENTS = ['homematicip==0.9.8'] + +_LOGGER = logging.getLogger(__name__) + +CONFIG_SCHEMA = vol.Schema({ + vol.Optional(DOMAIN, default=[]): vol.All(cv.ensure_list, [vol.Schema({ + vol.Optional(CONF_NAME, default=''): vol.Any(cv.string), + vol.Required(CONF_ACCESSPOINT): cv.string, + vol.Required(CONF_AUTHTOKEN): cv.string, + })]), +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the HomematicIP component.""" + hass.data[DOMAIN] = {} + + accesspoints = config.get(DOMAIN, []) + + for conf in accesspoints: + if conf[CONF_ACCESSPOINT] not in configured_haps(hass): + hass.async_add_job(hass.config_entries.flow.async_init( + DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, + data={ + HMIPC_HAPID: conf[CONF_ACCESSPOINT], + HMIPC_AUTHTOKEN: conf[CONF_AUTHTOKEN], + HMIPC_NAME: conf[CONF_NAME], + } + )) + + return True + + +async def async_setup_entry(hass, entry): + """Set up an accsspoint from a config entry.""" + hap = HomematicipHAP(hass, entry) + hapid = entry.data[HMIPC_HAPID].replace('-', '').upper() + hass.data[DOMAIN][hapid] = hap + return await hap.async_setup() + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + hap = hass.data[DOMAIN].pop(entry.data[HMIPC_HAPID]) + return await hap.async_reset() diff --git a/homeassistant/components/homematicip_cloud/config_flow.py b/homeassistant/components/homematicip_cloud/config_flow.py new file mode 100644 index 00000000000000..78970031d11c27 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/config_flow.py @@ -0,0 +1,100 @@ +"""Config flow to configure HomematicIP Cloud.""" +import voluptuous as vol + +from homeassistant import config_entries, data_entry_flow +from homeassistant.core import callback + +from .const import ( + DOMAIN as HMIPC_DOMAIN, _LOGGER, + HMIPC_HAPID, HMIPC_AUTHTOKEN, HMIPC_PIN, HMIPC_NAME) +from .hap import HomematicipAuth + + +@callback +def configured_haps(hass): + """Return a set of the configured accesspoints.""" + return set(entry.data[HMIPC_HAPID] for entry + in hass.config_entries.async_entries(HMIPC_DOMAIN)) + + +@config_entries.HANDLERS.register(HMIPC_DOMAIN) +class HomematicipCloudFlowHandler(data_entry_flow.FlowHandler): + """Config flow HomematicIP Cloud.""" + + VERSION = 1 + + def __init__(self): + """Initialize HomematicIP Cloud config flow.""" + self.auth = None + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + return await self.async_step_init(user_input) + + async def async_step_init(self, user_input=None): + """Handle a flow start.""" + errors = {} + + if user_input is not None: + user_input[HMIPC_HAPID] = \ + user_input[HMIPC_HAPID].replace('-', '').upper() + if user_input[HMIPC_HAPID] in configured_haps(self.hass): + return self.async_abort(reason='already_configured') + + self.auth = HomematicipAuth(self.hass, user_input) + connected = await self.auth.async_setup() + if connected: + _LOGGER.info("Connection established") + return await self.async_step_link() + + return self.async_show_form( + step_id='init', + data_schema=vol.Schema({ + vol.Required(HMIPC_HAPID): str, + vol.Optional(HMIPC_PIN): str, + vol.Optional(HMIPC_NAME): str, + }), + errors=errors + ) + + async def async_step_link(self, user_input=None): + """Attempt to link with the HomematicIP Cloud accesspoint.""" + errors = {} + + pressed = await self.auth.async_checkbutton() + if pressed: + authtoken = await self.auth.async_register() + if authtoken: + _LOGGER.info("Write config entry") + return self.async_create_entry( + title=self.auth.config.get(HMIPC_HAPID), + data={ + HMIPC_HAPID: self.auth.config.get(HMIPC_HAPID), + HMIPC_AUTHTOKEN: authtoken, + HMIPC_NAME: self.auth.config.get(HMIPC_NAME) + }) + return self.async_abort(reason='conection_aborted') + errors['base'] = 'press_the_button' + + return self.async_show_form(step_id='link', errors=errors) + + async def async_step_import(self, import_info): + """Import a new bridge as a config entry.""" + hapid = import_info[HMIPC_HAPID] + authtoken = import_info[HMIPC_AUTHTOKEN] + name = import_info[HMIPC_NAME] + + hapid = hapid.replace('-', '').upper() + if hapid in configured_haps(self.hass): + return self.async_abort(reason='already_configured') + + _LOGGER.info('Imported authentication for %s', hapid) + + return self.async_create_entry( + title=hapid, + data={ + HMIPC_HAPID: hapid, + HMIPC_AUTHTOKEN: authtoken, + HMIPC_NAME: name + } + ) diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py new file mode 100644 index 00000000000000..54b05c464b546c --- /dev/null +++ b/homeassistant/components/homematicip_cloud/const.py @@ -0,0 +1,24 @@ +"""Constants for the HomematicIP Cloud component.""" +import logging + +_LOGGER = logging.getLogger('homeassistant.components.homematicip_cloud') + +DOMAIN = 'homematicip_cloud' + +COMPONENTS = [ + 'alarm_control_panel', + 'binary_sensor', + 'climate', + 'light', + 'sensor', + 'switch', +] + +CONF_NAME = 'name' +CONF_ACCESSPOINT = 'accesspoint' +CONF_AUTHTOKEN = 'authtoken' + +HMIPC_NAME = 'name' +HMIPC_HAPID = 'hapid' +HMIPC_AUTHTOKEN = 'authtoken' +HMIPC_PIN = 'pin' diff --git a/homeassistant/components/homematicip_cloud/device.py b/homeassistant/components/homematicip_cloud/device.py new file mode 100644 index 00000000000000..bb21e1df3d5f57 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/device.py @@ -0,0 +1,82 @@ +"""GenericDevice for the HomematicIP Cloud component.""" +import logging + +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +ATTR_HOME_ID = 'home_id' +ATTR_HOME_NAME = 'home_name' +ATTR_DEVICE_ID = 'device_id' +ATTR_DEVICE_LABEL = 'device_label' +ATTR_STATUS_UPDATE = 'status_update' +ATTR_FIRMWARE_STATE = 'firmware_state' +ATTR_UNREACHABLE = 'unreachable' +ATTR_LOW_BATTERY = 'low_battery' +ATTR_MODEL_TYPE = 'model_type' +ATTR_GROUP_TYPE = 'group_type' +ATTR_DEVICE_RSSI = 'device_rssi' +ATTR_DUTY_CYCLE = 'duty_cycle' +ATTR_CONNECTED = 'connected' +ATTR_SABOTAGE = 'sabotage' +ATTR_OPERATION_LOCK = 'operation_lock' + + +class HomematicipGenericDevice(Entity): + """Representation of an HomematicIP generic device.""" + + def __init__(self, home, device, post=None): + """Initialize the generic device.""" + self._home = home + self._device = device + self.post = post + _LOGGER.info('Setting up %s (%s)', self.name, + self._device.modelType) + + async def async_added_to_hass(self): + """Register callbacks.""" + self._device.on_update(self._device_changed) + + def _device_changed(self, json, **kwargs): + """Handle device state changes.""" + _LOGGER.debug('Event %s (%s)', self.name, self._device.modelType) + self.async_schedule_update_ha_state() + + @property + def name(self): + """Return the name of the generic device.""" + name = self._device.label + if (self._home.name is not None and self._home.name != ''): + name = "{} {}".format(self._home.name, name) + if (self.post is not None and self.post != ''): + name = "{} {}".format(name, self.post) + return name + + @property + def should_poll(self): + """No polling needed.""" + return False + + @property + def available(self): + """Device available.""" + return not self._device.unreach + + @property + def icon(self): + """Return the icon.""" + if hasattr(self._device, 'lowBat') and self._device.lowBat: + return 'mdi:battery-outline' + if hasattr(self._device, 'sabotage') and self._device.sabotage: + return 'mdi:alert' + return None + + @property + def device_state_attributes(self): + """Return the state attributes of the generic device.""" + attr = {ATTR_MODEL_TYPE: self._device.modelType} + if hasattr(self._device, 'lowBat') and self._device.lowBat: + attr.update({ATTR_LOW_BATTERY: self._device.lowBat}) + if hasattr(self._device, 'sabotage') and self._device.sabotage: + attr.update({ATTR_SABOTAGE: self._device.sabotage}) + return attr diff --git a/homeassistant/components/homematicip_cloud/errors.py b/homeassistant/components/homematicip_cloud/errors.py new file mode 100644 index 00000000000000..cb2925d1a70450 --- /dev/null +++ b/homeassistant/components/homematicip_cloud/errors.py @@ -0,0 +1,22 @@ +"""Errors for the HomematicIP component.""" +from homeassistant.exceptions import HomeAssistantError + + +class HmipcException(HomeAssistantError): + """Base class for HomematicIP exceptions.""" + + +class HmipcConnectionError(HmipcException): + """Unable to connect to the HomematicIP cloud server.""" + + +class HmipcConnectionWait(HmipcException): + """Wait for registration to the HomematicIP cloud server.""" + + +class HmipcRegistrationFailed(HmipcException): + """Registration on HomematicIP cloud failed.""" + + +class HmipcPressButton(HmipcException): + """User needs to press the blue button.""" diff --git a/homeassistant/components/homematicip_cloud/hap.py b/homeassistant/components/homematicip_cloud/hap.py new file mode 100644 index 00000000000000..9715a5fc02400c --- /dev/null +++ b/homeassistant/components/homematicip_cloud/hap.py @@ -0,0 +1,256 @@ +"""Accesspoint for the HomematicIP Cloud component.""" +import asyncio +import logging + +from homeassistant import config_entries +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.core import callback + +from .const import ( + HMIPC_HAPID, HMIPC_AUTHTOKEN, HMIPC_PIN, HMIPC_NAME, + COMPONENTS) +from .errors import HmipcConnectionError + +_LOGGER = logging.getLogger(__name__) + + +class HomematicipAuth: + """Manages HomematicIP client registration.""" + + def __init__(self, hass, config): + """Initialize HomematicIP Cloud client registration.""" + self.hass = hass + self.config = config + self.auth = None + + async def async_setup(self): + """Connect to HomematicIP for registration.""" + try: + self.auth = await self.get_auth( + self.hass, + self.config.get(HMIPC_HAPID), + self.config.get(HMIPC_PIN) + ) + return True + except HmipcConnectionError: + return False + + async def async_checkbutton(self): + """Check blue butten has been pressed.""" + from homematicip.base.base_connection import HmipConnectionError + + try: + await self.auth.isRequestAcknowledged() + return True + except HmipConnectionError: + return False + + async def async_register(self): + """Register client at HomematicIP.""" + from homematicip.base.base_connection import HmipConnectionError + + try: + authtoken = await self.auth.requestAuthToken() + await self.auth.confirmAuthToken(authtoken) + return authtoken + except HmipConnectionError: + return False + + async def get_auth(self, hass, hapid, pin): + """Create a HomematicIP access point object.""" + from homematicip.aio.auth import AsyncAuth + from homematicip.base.base_connection import HmipConnectionError + + auth = AsyncAuth(hass.loop, async_get_clientsession(hass)) + print(auth) + try: + await auth.init(hapid) + if pin: + auth.pin = pin + await auth.connectionRequest('HomeAssistant') + except HmipConnectionError: + return False + return auth + + +class HomematicipHAP: + """Manages HomematicIP http and websocket connection.""" + + def __init__(self, hass, config_entry): + """Initialize HomematicIP cloud connection.""" + self.hass = hass + self.config_entry = config_entry + self.home = None + + self._ws_close_requested = False + self._retry_task = None + self._tries = 0 + self._accesspoint_connected = True + self._retry_setup = None + + async def async_setup(self, tries=0): + """Initialize connection.""" + try: + self.home = await self.get_hap( + self.hass, + self.config_entry.data.get(HMIPC_HAPID), + self.config_entry.data.get(HMIPC_AUTHTOKEN), + self.config_entry.data.get(HMIPC_NAME) + ) + except HmipcConnectionError: + retry_delay = 2 ** min(tries + 1, 6) + _LOGGER.error("Error connecting to HomematicIP with HAP %s. " + "Retrying in %d seconds.", + self.config_entry.data.get(HMIPC_HAPID), retry_delay) + + async def retry_setup(_now): + """Retry setup.""" + if await self.async_setup(tries + 1): + self.config_entry.state = config_entries.ENTRY_STATE_LOADED + + self._retry_setup = self.hass.helpers.event.async_call_later( + retry_delay, retry_setup) + + return False + + _LOGGER.info('Connected to HomematicIP with HAP %s.', + self.config_entry.data.get(HMIPC_HAPID)) + + for component in COMPONENTS: + self.hass.async_create_task( + self.hass.config_entries.async_forward_entry_setup( + self.config_entry, component) + ) + return True + + @callback + def async_update(self, *args, **kwargs): + """Async update the home device. + + Triggered when the hmip HOME_CHANGED event has fired. + There are several occasions for this event to happen. + We are only interested to check whether the access point + is still connected. If not, device state changes cannot + be forwarded to hass. So if access point is disconnected all devices + are set to unavailable. + """ + if not self.home.connected: + _LOGGER.error( + "HMIP access point has lost connection with the cloud") + self._accesspoint_connected = False + self.set_all_to_unavailable() + elif not self._accesspoint_connected: + # Explicitly getting an update as device states might have + # changed during access point disconnect.""" + + job = self.hass.async_add_job(self.get_state()) + job.add_done_callback(self.get_state_finished) + + async def get_state(self): + """Update hmip state and tell hass.""" + await self.home.get_current_state() + self.update_all() + + def get_state_finished(self, future): + """Execute when get_state coroutine has finished.""" + from homematicip.base.base_connection import HmipConnectionError + + try: + future.result() + except HmipConnectionError: + # Somehow connection could not recover. Will disconnect and + # so reconnect loop is taking over. + _LOGGER.error( + "updating state after himp access point reconnect failed.") + self.hass.async_add_job(self.home.disable_events()) + + def set_all_to_unavailable(self): + """Set all devices to unavailable and tell Hass.""" + for device in self.home.devices: + device.unreach = True + self.update_all() + + def update_all(self): + """Signal all devices to update their state.""" + for device in self.home.devices: + device.fire_update_event() + + async def _handle_connection(self): + """Handle websocket connection.""" + from homematicip.base.base_connection import HmipConnectionError + + try: + await self.home.get_current_state() + except HmipConnectionError: + return + hmip_events = await self.home.enable_events() + try: + await hmip_events + except HmipConnectionError: + return + + async def async_connect(self): + """Start websocket connection.""" + from homematicip.base.base_connection import HmipConnectionError + + tries = 0 + while True: + try: + await self.home.get_current_state() + hmip_events = await self.home.enable_events() + tries = 0 + await hmip_events + except HmipConnectionError: + pass + + if self._ws_close_requested: + break + self._ws_close_requested = False + + tries += 1 + retry_delay = 2 ** min(tries + 1, 6) + _LOGGER.error("Error connecting to HomematicIP with HAP %s. " + "Retrying in %d seconds.", + self.config_entry.data.get(HMIPC_HAPID), retry_delay) + try: + self._retry_task = self.hass.async_add_job(asyncio.sleep( + retry_delay, loop=self.hass.loop)) + await self._retry_task + except asyncio.CancelledError: + break + + async def async_reset(self): + """Close the websocket connection.""" + self._ws_close_requested = True + if self._retry_setup is not None: + self._retry_setup.cancel() + if self._retry_task is not None: + self._retry_task.cancel() + self.home.disable_events() + _LOGGER.info("Closed connection to HomematicIP cloud server.") + for component in COMPONENTS: + await self.hass.config_entries.async_forward_entry_unload( + self.config_entry, component) + return True + + async def get_hap(self, hass, hapid, authtoken, name): + """Create a HomematicIP access point object.""" + from homematicip.aio.home import AsyncHome + from homematicip.base.base_connection import HmipConnectionError + + home = AsyncHome(hass.loop, async_get_clientsession(hass)) + + home.name = name + home.label = 'Access Point' + home.modelType = 'HmIP-HAP' + + home.set_auth_token(authtoken) + try: + await home.init(hapid) + await home.get_current_state() + except HmipConnectionError: + raise HmipcConnectionError + home.on_update(self.async_update) + hass.loop.create_task(self.async_connect()) + + return home diff --git a/homeassistant/components/homematicip_cloud/strings.json b/homeassistant/components/homematicip_cloud/strings.json new file mode 100644 index 00000000000000..887a3a5780b0eb --- /dev/null +++ b/homeassistant/components/homematicip_cloud/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "title": "HomematicIP Cloud", + "step": { + "init": { + "title": "Pick HomematicIP Accesspoint", + "data": { + "hapid": "Accesspoint ID (SGTIN)", + "pin": "Pin Code (optional)", + "name": "Name (optional, used as name prefix for all devices)" + } + }, + "link": { + "title": "Link Accesspoint", + "description": "Press the blue button on the accesspoint and the submit button to register HomematicIP with Home Assistant.\n\n![Location of button on bridge](/static/images/config_flows/config_homematicip_cloud.png)" + } + }, + "error": { + "register_failed": "Failed to register, please try again.", + "invalid_pin": "Invalid PIN, please try again.", + "press_the_button": "Please press the blue button.", + "timeout_button": "Blue button press timeout, please try again." + }, + "abort": { + "unknown": "Unknown error occurred.", + "conection_aborted": "Could not connect to HMIP server", + "already_configured": "Accesspoint is already configured" + } + } +} diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 17906157a6e797..9ba977f92f5c42 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -8,6 +8,7 @@ import logging import os import ssl +from typing import Optional from aiohttp import web from aiohttp.web_exceptions import HTTPMovedPermanently @@ -16,9 +17,9 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, SERVER_PORT) import homeassistant.helpers.config_validation as cv -import homeassistant.remote as rem import homeassistant.util as hass_util from homeassistant.util.logging import HideSensitiveDataFilter +from homeassistant.util import ssl as ssl_util from .auth import setup_auth from .ban import setup_bans @@ -40,33 +41,18 @@ CONF_SERVER_PORT = 'server_port' CONF_BASE_URL = 'base_url' CONF_SSL_CERTIFICATE = 'ssl_certificate' +CONF_SSL_PEER_CERTIFICATE = 'ssl_peer_certificate' CONF_SSL_KEY = 'ssl_key' CONF_CORS_ORIGINS = 'cors_allowed_origins' CONF_USE_X_FORWARDED_FOR = 'use_x_forwarded_for' +CONF_TRUSTED_PROXIES = 'trusted_proxies' CONF_TRUSTED_NETWORKS = 'trusted_networks' CONF_LOGIN_ATTEMPTS_THRESHOLD = 'login_attempts_threshold' CONF_IP_BAN_ENABLED = 'ip_ban_enabled' +CONF_SSL_PROFILE = 'ssl_profile' -# TLS configuration follows the best-practice guidelines specified here: -# https://wiki.mozilla.org/Security/Server_Side_TLS -# Intermediate guidelines are followed. -SSL_VERSION = ssl.PROTOCOL_SSLv23 -SSL_OPTS = ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 -if hasattr(ssl, 'OP_NO_COMPRESSION'): - SSL_OPTS |= ssl.OP_NO_COMPRESSION -CIPHERS = "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:" \ - "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:" \ - "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:" \ - "DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:" \ - "ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256:" \ - "ECDHE-ECDSA-AES128-SHA:ECDHE-RSA-AES256-SHA384:" \ - "ECDHE-RSA-AES128-SHA:ECDHE-ECDSA-AES256-SHA384:" \ - "ECDHE-ECDSA-AES256-SHA:ECDHE-RSA-AES256-SHA:" \ - "DHE-RSA-AES128-SHA256:DHE-RSA-AES128-SHA:DHE-RSA-AES256-SHA256:" \ - "DHE-RSA-AES256-SHA:ECDHE-ECDSA-DES-CBC3-SHA:" \ - "ECDHE-RSA-DES-CBC3-SHA:EDH-RSA-DES-CBC3-SHA:AES128-GCM-SHA256:" \ - "AES256-GCM-SHA384:AES128-SHA256:AES256-SHA256:AES128-SHA:" \ - "AES256-SHA:DES-CBC3-SHA:!DSS" +SSL_MODERN = 'modern' +SSL_INTERMEDIATE = 'intermediate' _LOGGER = logging.getLogger(__name__) @@ -80,16 +66,21 @@ vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port, vol.Optional(CONF_BASE_URL): cv.string, vol.Optional(CONF_SSL_CERTIFICATE): cv.isfile, + vol.Optional(CONF_SSL_PEER_CERTIFICATE): cv.isfile, vol.Optional(CONF_SSL_KEY): cv.isfile, vol.Optional(CONF_CORS_ORIGINS, default=[]): vol.All(cv.ensure_list, [cv.string]), - vol.Optional(CONF_USE_X_FORWARDED_FOR, default=False): cv.boolean, + vol.Inclusive(CONF_USE_X_FORWARDED_FOR, 'proxy'): cv.boolean, + vol.Inclusive(CONF_TRUSTED_PROXIES, 'proxy'): + vol.All(cv.ensure_list, [ip_network]), vol.Optional(CONF_TRUSTED_NETWORKS, default=[]): vol.All(cv.ensure_list, [ip_network]), vol.Optional(CONF_LOGIN_ATTEMPTS_THRESHOLD, default=NO_LOGIN_ATTEMPT_THRESHOLD): vol.Any(cv.positive_int, NO_LOGIN_ATTEMPT_THRESHOLD), - vol.Optional(CONF_IP_BAN_ENABLED, default=True): cv.boolean + vol.Optional(CONF_IP_BAN_ENABLED, default=True): cv.boolean, + vol.Optional(CONF_SSL_PROFILE, default=SSL_MODERN): + vol.In([SSL_INTERMEDIATE, SSL_MODERN]), }) CONFIG_SCHEMA = vol.Schema({ @@ -97,6 +88,28 @@ }, extra=vol.ALLOW_EXTRA) +class ApiConfig: + """Configuration settings for API server.""" + + def __init__(self, host: str, port: Optional[int] = SERVER_PORT, + use_ssl: bool = False, + api_password: Optional[str] = None) -> None: + """Initialize a new API config object.""" + self.host = host + self.port = port + self.api_password = api_password + + if host.startswith(("http://", "https://")): + self.base_url = host + elif use_ssl: + self.base_url = "https://{}".format(host) + else: + self.base_url = "http://{}".format(host) + + if port is not None: + self.base_url += ':{}'.format(port) + + async def async_setup(hass, config): """Set up the HTTP API and debug interface.""" conf = config.get(DOMAIN) @@ -108,12 +121,15 @@ async def async_setup(hass, config): server_host = conf[CONF_SERVER_HOST] server_port = conf[CONF_SERVER_PORT] ssl_certificate = conf.get(CONF_SSL_CERTIFICATE) + ssl_peer_certificate = conf.get(CONF_SSL_PEER_CERTIFICATE) ssl_key = conf.get(CONF_SSL_KEY) cors_origins = conf[CONF_CORS_ORIGINS] - use_x_forwarded_for = conf[CONF_USE_X_FORWARDED_FOR] + use_x_forwarded_for = conf.get(CONF_USE_X_FORWARDED_FOR, False) + trusted_proxies = conf.get(CONF_TRUSTED_PROXIES, []) trusted_networks = conf[CONF_TRUSTED_NETWORKS] is_ban_enabled = conf[CONF_IP_BAN_ENABLED] login_threshold = conf[CONF_LOGIN_ATTEMPTS_THRESHOLD] + ssl_profile = conf[CONF_SSL_PROFILE] if api_password is not None: logging.getLogger('aiohttp.access').addFilter( @@ -125,12 +141,15 @@ async def async_setup(hass, config): server_port=server_port, api_password=api_password, ssl_certificate=ssl_certificate, + ssl_peer_certificate=ssl_peer_certificate, ssl_key=ssl_key, cors_origins=cors_origins, use_x_forwarded_for=use_x_forwarded_for, + trusted_proxies=trusted_proxies, trusted_networks=trusted_networks, login_threshold=login_threshold, - is_ban_enabled=is_ban_enabled + is_ban_enabled=is_ban_enabled, + ssl_profile=ssl_profile, ) async def stop_server(event): @@ -157,43 +176,60 @@ async def start_server(event): host = hass_util.get_local_ip() port = server_port - hass.config.api = rem.API(host, api_password, port, - ssl_certificate is not None) + hass.config.api = ApiConfig(host, port, ssl_certificate is not None, + api_password) return True -class HomeAssistantHTTP(object): +class HomeAssistantHTTP: """HTTP server for Home Assistant.""" - def __init__(self, hass, api_password, ssl_certificate, + def __init__(self, hass, api_password, + ssl_certificate, ssl_peer_certificate, ssl_key, server_host, server_port, cors_origins, - use_x_forwarded_for, trusted_networks, - login_threshold, is_ban_enabled): + use_x_forwarded_for, trusted_proxies, trusted_networks, + login_threshold, is_ban_enabled, ssl_profile): """Initialize the HTTP Home Assistant server.""" app = self.app = web.Application( middlewares=[staticresource_middleware]) # This order matters - setup_real_ip(app, use_x_forwarded_for) + setup_real_ip(app, use_x_forwarded_for, trusted_proxies) if is_ban_enabled: setup_bans(hass, app, login_threshold) - setup_auth(app, trusted_networks, api_password) + if hass.auth.active: + if hass.auth.support_legacy: + _LOGGER.warning("Experimental auth api enabled and " + "legacy_api_password support enabled. Please " + "use access_token instead api_password, " + "although you can still use legacy " + "api_password") + else: + _LOGGER.warning("Experimental auth api enabled. Please use " + "access_token instead api_password.") + elif api_password is None: + _LOGGER.warning("You have been advised to set http.api_password.") + + setup_auth(app, trusted_networks, hass.auth.active, + support_legacy=hass.auth.support_legacy, + api_password=api_password) - if cors_origins: - setup_cors(app, cors_origins) + setup_cors(app, cors_origins) app['hass'] = hass self.hass = hass self.api_password = api_password self.ssl_certificate = ssl_certificate + self.ssl_peer_certificate = ssl_peer_certificate self.ssl_key = ssl_key self.server_host = server_host self.server_port = server_port self.is_ban_enabled = is_ban_enabled + self.ssl_profile = ssl_profile self._handler = None self.server = None @@ -220,7 +256,7 @@ def register_view(self, view): '{0} missing required attribute "name"'.format(class_name) ) - view.register(self.app.router) + view.register(self.app, self.app.router) def register_redirect(self, url, redirect_to): """Register a redirect with the server. @@ -280,15 +316,20 @@ async def start(self): if self.ssl_certificate: try: - context = ssl.SSLContext(SSL_VERSION) - context.options |= SSL_OPTS - context.set_ciphers(CIPHERS) + if self.ssl_profile == SSL_INTERMEDIATE: + context = ssl_util.server_context_intermediate() + else: + context = ssl_util.server_context_modern() context.load_cert_chain(self.ssl_certificate, self.ssl_key) except OSError as error: _LOGGER.error("Could not read SSL certificate from %s: %s", self.ssl_certificate, error) - context = None return + + if self.ssl_peer_certificate: + context.verify_mode = ssl.CERT_REQUIRED + context.load_verify_locations(cafile=self.ssl_peer_certificate) + else: context = None diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 5558063c5c439a..d01d1b50c5acc9 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -17,37 +17,49 @@ @callback -def setup_auth(app, trusted_networks, api_password): +def setup_auth(app, trusted_networks, use_auth, + support_legacy=False, api_password=None): """Create auth middleware for the app.""" + old_auth_warning = set() + @middleware async def auth_middleware(request, handler): """Authenticate as middleware.""" - # If no password set, just always set authenticated=True - if api_password is None: - request[KEY_AUTHENTICATED] = True - return await handler(request) - - # Check authentication authenticated = False - if (HTTP_HEADER_HA_AUTH in request.headers and - hmac.compare_digest( - api_password.encode('utf-8'), - request.headers[HTTP_HEADER_HA_AUTH].encode('utf-8'))): + if use_auth and (HTTP_HEADER_HA_AUTH in request.headers or + DATA_API_PASSWORD in request.query): + if request.path not in old_auth_warning: + _LOGGER.warning('Please change to use bearer token access %s', + request.path) + old_auth_warning.add(request.path) + + legacy_auth = (not use_auth or support_legacy) and api_password + if (hdrs.AUTHORIZATION in request.headers and + await async_validate_auth_header( + request, api_password if legacy_auth else None)): + # it included both use_auth and api_password Basic auth + authenticated = True + + elif (legacy_auth and HTTP_HEADER_HA_AUTH in request.headers and + hmac.compare_digest( + api_password.encode('utf-8'), + request.headers[HTTP_HEADER_HA_AUTH].encode('utf-8'))): # A valid auth header has been set authenticated = True - elif (DATA_API_PASSWORD in request.query and + elif (legacy_auth and DATA_API_PASSWORD in request.query and hmac.compare_digest( api_password.encode('utf-8'), request.query[DATA_API_PASSWORD].encode('utf-8'))): authenticated = True - elif (hdrs.AUTHORIZATION in request.headers and - await async_validate_auth_header(api_password, request)): + elif _is_trusted_ip(request, trusted_networks): authenticated = True - elif _is_trusted_ip(request, trusted_networks): + elif not use_auth and api_password is None: + # If neither password nor auth_providers set, + # just always set authenticated=True authenticated = True request[KEY_AUTHENTICATED] = authenticated @@ -76,14 +88,32 @@ def validate_password(request, api_password): request.app['hass'].http.api_password.encode('utf-8')) -async def async_validate_auth_header(api_password, request): - """Test an authorization header if valid password.""" +async def async_validate_auth_header(request, api_password=None): + """ + Test authorization header against access token. + + Basic auth_type is legacy code, should be removed with api_password. + """ if hdrs.AUTHORIZATION not in request.headers: return False - auth_type, auth_val = request.headers.get(hdrs.AUTHORIZATION).split(' ', 1) + try: + auth_type, auth_val = \ + request.headers.get(hdrs.AUTHORIZATION).split(' ', 1) + except ValueError: + # If no space in authorization header + return False + + if auth_type == 'Bearer': + hass = request.app['hass'] + refresh_token = await hass.auth.async_validate_access_token(auth_val) + if refresh_token is None: + return False + + request['hass_user'] = refresh_token.user + return True - if auth_type == 'Basic': + if auth_type == 'Basic' and api_password is not None: decoded = base64.b64decode(auth_val).decode('utf-8') try: username, password = decoded.split(':', 1) @@ -97,13 +127,4 @@ async def async_validate_auth_header(api_password, request): return hmac.compare_digest(api_password.encode('utf-8'), password.encode('utf-8')) - if auth_type != 'Bearer': - return False - - hass = request.app['hass'] - access_token = hass.auth.async_get_access_token(auth_val) - if access_token is None: - return False - - request['hass_user'] = access_token.refresh_token.user - return True + return False diff --git a/homeassistant/components/http/ban.py b/homeassistant/components/http/ban.py index fe8b7db84d1b95..ab582066a22a72 100644 --- a/homeassistant/components/http/ban.py +++ b/homeassistant/components/http/ban.py @@ -1,5 +1,4 @@ """Ban logic for HTTP component.""" - from collections import defaultdict from datetime import datetime from ipaddress import ip_address @@ -71,8 +70,23 @@ async def ban_middleware(request, handler): raise +def log_invalid_auth(func): + """Decorator to handle invalid auth or failed login attempts.""" + async def handle_req(view, request, *args, **kwargs): + """Try to log failed login attempts if response status >= 400.""" + resp = await func(view, request, *args, **kwargs) + if resp.status >= 400: + await process_wrong_login(request) + return resp + return handle_req + + async def process_wrong_login(request): - """Process a wrong login attempt.""" + """Process a wrong login attempt. + + Increase failed login attempts counter for remote IP address. + Add ip ban entry if failed login attempts exceeds threshold. + """ remote_addr = request[KEY_REAL_IP] msg = ('Login attempt or request with invalid authentication ' @@ -107,7 +121,28 @@ async def process_wrong_login(request): 'Banning IP address', NOTIFICATION_ID_BAN) -class IpBan(object): +async def process_success_login(request): + """Process a success login attempt. + + Reset failed login attempts counter for remote IP address. + No release IP address from banned list function, it can only be done by + manual modify ip bans config file. + """ + remote_addr = request[KEY_REAL_IP] + + # Check if ban middleware is loaded + if (KEY_BANNED_IPS not in request.app or + request.app[KEY_LOGIN_THRESHOLD] < 1): + return + + if remote_addr in request.app[KEY_FAILED_LOGIN_ATTEMPTS] and \ + request.app[KEY_FAILED_LOGIN_ATTEMPTS][remote_addr] > 0: + _LOGGER.debug('Login success, reset failed login attempts counter' + ' from %s', remote_addr) + request.app[KEY_FAILED_LOGIN_ATTEMPTS].pop(remote_addr) + + +class IpBan: """Represents banned IP address.""" def __init__(self, ip_ban: str, banned_at: datetime = None) -> None: diff --git a/homeassistant/components/http/cors.py b/homeassistant/components/http/cors.py index 0a37f22867efc4..555f302f8e1966 100644 --- a/homeassistant/components/http/cors.py +++ b/homeassistant/components/http/cors.py @@ -27,16 +27,36 @@ def setup_cors(app, origins): ) for host in origins }) + cors_added = set() + + def _allow_cors(route, config=None): + """Allow cors on a route.""" + if hasattr(route, 'resource'): + path = route.resource + else: + path = route + + path = path.canonical + + if path in cors_added: + return + + cors.add(route, config) + cors_added.add(path) + + app['allow_cors'] = lambda route: _allow_cors(route, { + '*': aiohttp_cors.ResourceOptions( + allow_headers=ALLOWED_CORS_HEADERS, + allow_methods='*', + ) + }) + + if not origins: + return + async def cors_startup(app): """Initialize cors when app starts up.""" - cors_added = set() - for route in list(app.router.routes()): - if hasattr(route, 'resource'): - route = route.resource - if route in cors_added: - continue - cors.add(route) - cors_added.add(route) + _allow_cors(route) app.on_startup.append(cors_startup) diff --git a/homeassistant/components/http/real_ip.py b/homeassistant/components/http/real_ip.py index c394016a683c43..f8adc815fdef25 100644 --- a/homeassistant/components/http/real_ip.py +++ b/homeassistant/components/http/real_ip.py @@ -11,18 +11,25 @@ @callback -def setup_real_ip(app, use_x_forwarded_for): +def setup_real_ip(app, use_x_forwarded_for, trusted_proxies): """Create IP Ban middleware for the app.""" @middleware async def real_ip_middleware(request, handler): """Real IP middleware.""" - if (use_x_forwarded_for and - X_FORWARDED_FOR in request.headers): - request[KEY_REAL_IP] = ip_address( - request.headers.get(X_FORWARDED_FOR).split(',')[0]) - else: - request[KEY_REAL_IP] = \ - ip_address(request.transport.get_extra_info('peername')[0]) + connected_ip = ip_address( + request.transport.get_extra_info('peername')[0]) + request[KEY_REAL_IP] = connected_ip + + # Only use the XFF header if enabled, present, and from a trusted proxy + try: + if (use_x_forwarded_for and + X_FORWARDED_FOR in request.headers and + any(connected_ip in trusted_proxy + for trusted_proxy in trusted_proxies)): + request[KEY_REAL_IP] = ip_address( + request.headers.get(X_FORWARDED_FOR).split(', ')[-1]) + except ValueError: + pass return await handler(request) diff --git a/homeassistant/components/http/static.py b/homeassistant/components/http/static.py index 3fbaf703d06798..8b28a7cf28807c 100644 --- a/homeassistant/components/http/static.py +++ b/homeassistant/components/http/static.py @@ -18,7 +18,6 @@ async def _handle(self, request): filename = URL(request.match_info['filename']).path try: # PyLint is wrong about resolve not being a member. - # pylint: disable=no-member filepath = self._directory.joinpath(filename).resolve() if not self._follow_symlinks: filepath.relative_to(self._directory) @@ -32,10 +31,9 @@ async def _handle(self, request): if filepath.is_dir(): return await super()._handle(request) - elif filepath.is_file(): + if filepath.is_file(): return CachingFileResponse(filepath, chunk_size=self._chunk_size) - else: - raise HTTPNotFound + raise HTTPNotFound # pylint: disable=too-many-ancestors diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index 81c6ea4bcfb365..22ef34de54adad 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -12,7 +12,8 @@ from aiohttp.web_exceptions import HTTPUnauthorized, HTTPInternalServerError import homeassistant.remote as rem -from homeassistant.core import is_callback +from homeassistant.components.http.ban import process_success_login +from homeassistant.core import Context, is_callback from homeassistant.const import CONTENT_TYPE_JSON from .const import KEY_AUTHENTICATED, KEY_REAL_IP @@ -21,14 +22,24 @@ _LOGGER = logging.getLogger(__name__) -class HomeAssistantView(object): +class HomeAssistantView: """Base view for all views.""" url = None extra_urls = [] - requires_auth = True # Views inheriting from this class can override this + # Views inheriting from this class can override this + requires_auth = True + cors_allowed = False # pylint: disable=no-self-use + def context(self, request): + """Generate a context from a request.""" + user = request.get('hass_user') + if user is None: + return Context() + + return Context(user_id=user.id) + def json(self, result, status_code=200, headers=None): """Return a JSON response.""" try: @@ -51,16 +62,11 @@ def json_message(self, message, status_code=200, message_code=None, data['code'] = message_code return self.json(data, status_code, headers=headers) - # pylint: disable=no-self-use - async def file(self, request, fil): - """Return a file.""" - assert isinstance(fil, str), 'only string paths allowed' - return web.FileResponse(fil) - - def register(self, router): + def register(self, app, router): """Register the view with a router.""" assert self.url is not None, 'No url set for view' urls = [self.url] + self.extra_urls + routes = [] for method in ('get', 'post', 'delete', 'put'): handler = getattr(self, method, None) @@ -71,13 +77,13 @@ def register(self, router): handler = request_handler_factory(self, handler) for url in urls: - router.add_route(method, url, handler) + routes.append(router.add_route(method, url, handler)) - # aiohttp_cors does not work with class based views - # self.app.router.add_route('*', self.url, self, name=self.name) + if not self.cors_allowed: + return - # for url in self.extra_urls: - # self.app.router.add_route('*', url, self) + for route in routes: + app['allow_cors'](route) def request_handler_factory(view, handler): @@ -92,8 +98,11 @@ async def handle(request): authenticated = request.get(KEY_AUTHENTICATED, False) - if view.requires_auth and not authenticated: - raise HTTPUnauthorized() + if view.requires_auth: + if authenticated: + await process_success_login(request) + else: + raise HTTPUnauthorized() _LOGGER.info('Serving %s to %s (auth: %s)', request.path, request.get(KEY_REAL_IP), authenticated) diff --git a/homeassistant/components/hue/.translations/ca.json b/homeassistant/components/hue/.translations/ca.json new file mode 100644 index 00000000000000..6c41eed5467ac9 --- /dev/null +++ b/homeassistant/components/hue/.translations/ca.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "all_configured": "Tots els enlla\u00e7os Philips Hue ja estan configurats", + "already_configured": "L'enlla\u00e7 ja est\u00e0 configurat", + "cannot_connect": "No es pot connectar amb l'enlla\u00e7", + "discover_timeout": "No s'han pogut descobrir enlla\u00e7os Hue", + "no_bridges": "No s'han trobat enlla\u00e7os Philips Hue", + "unknown": "S'ha produ\u00eft un error desconegut" + }, + "error": { + "linking": "S'ha produ\u00eft un error desconegut al vincular.", + "register_failed": "No s'ha pogut registrar, torneu-ho a provar" + }, + "step": { + "init": { + "data": { + "host": "Amfitri\u00f3" + }, + "title": "Tria l'enlla\u00e7 Hue" + }, + "link": { + "description": "Premeu el bot\u00f3 de l'ella\u00e7 per registrar Philips Hue amb Home Assistant. \n\n ![Ubicaci\u00f3 del bot\u00f3 al pont](/static/images/config_philips_hue.jpg)", + "title": "Vincular concentrador" + } + }, + "title": "Philips Hue" + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/cs.json b/homeassistant/components/hue/.translations/cs.json new file mode 100644 index 00000000000000..35c423b1a03420 --- /dev/null +++ b/homeassistant/components/hue/.translations/cs.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "all_configured": "V\u0161echny Philips Hue p\u0159emost\u011bn\u00ed jsou ji\u017e nakonfigurov\u00e1ny", + "already_configured": "P\u0159emost\u011bn\u00ed je ji\u017e nakonfigurov\u00e1no", + "cannot_connect": "Nelze se p\u0159ipojit k p\u0159emost\u011bn\u00ed", + "discover_timeout": "Nelze nal\u00e9zt p\u0159emost\u011bn\u00ed Hue", + "no_bridges": "Nebyly nalezeny \u017e\u00e1dn\u00e9 p\u0159emost\u011bn\u00ed Philips Hue", + "unknown": "Do\u0161lo k nezn\u00e1m\u00e9 chyb\u011b" + }, + "error": { + "linking": "Do\u0161lo k nezn\u00e1m\u00e9 chyb\u011b propojen\u00ed.", + "register_failed": "Registrace se nezda\u0159ila, zkuste to pros\u00edm znovu" + }, + "step": { + "init": { + "data": { + "host": "Hostitel" + }, + "title": "Vybrat Hue p\u0159emost\u011bn\u00ed" + }, + "link": { + "description": "Stiskn\u011bte tla\u010d\u00edtko na p\u0159emost\u011bn\u00ed k registraci Philips Hue v Home Assistant.\n\n! [Um\u00edst\u011bn\u00ed tla\u010d\u00edtka na p\u0159emost\u011bn\u00ed] (/ static/images/config_philips_hue.jpg)", + "title": "P\u0159ipojit Hub" + } + }, + "title": "Philips Hue p\u0159emost\u011bn\u00ed" + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/de.json b/homeassistant/components/hue/.translations/de.json index d466488e9fcd85..a0bd50d8514dfc 100644 --- a/homeassistant/components/hue/.translations/de.json +++ b/homeassistant/components/hue/.translations/de.json @@ -24,6 +24,6 @@ "title": "Hub verbinden" } }, - "title": "Philips Hue Bridge" + "title": "Philips Hue" } } \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/en.json b/homeassistant/components/hue/.translations/en.json index b0459ec39163ab..cea8d8be10af34 100644 --- a/homeassistant/components/hue/.translations/en.json +++ b/homeassistant/components/hue/.translations/en.json @@ -24,6 +24,6 @@ "title": "Link Hub" } }, - "title": "Philips Hue Bridge" + "title": "Philips Hue" } } \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/es-419.json b/homeassistant/components/hue/.translations/es-419.json new file mode 100644 index 00000000000000..8efc9101d9a175 --- /dev/null +++ b/homeassistant/components/hue/.translations/es-419.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "all_configured": "Todos los puentes Philips Hue ya est\u00e1n configurados", + "already_configured": "El puente ya est\u00e1 configurado", + "cannot_connect": "No se puede conectar al puente", + "discover_timeout": "Incapaz de descubrir puentes Hue", + "no_bridges": "No se descubrieron puentes Philips Hue", + "unknown": "Se produjo un error desconocido" + }, + "error": { + "linking": "Se produjo un error de enlace desconocido.", + "register_failed": "No se pudo registrar, intente de nuevo" + }, + "step": { + "init": { + "data": { + "host": "Host" + } + } + }, + "title": "Philips Hue" + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/fr.json b/homeassistant/components/hue/.translations/fr.json new file mode 100644 index 00000000000000..73613f237dac3b --- /dev/null +++ b/homeassistant/components/hue/.translations/fr.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "all_configured": "Tous les ponts Philips Hue sont d\u00e9j\u00e0 configur\u00e9s", + "already_configured": "Ce pont est d\u00e9j\u00e0 configur\u00e9", + "cannot_connect": "Connexion au pont impossible", + "discover_timeout": "D\u00e9tection de ponts Philips Hue impossible", + "no_bridges": "Aucun pont Philips Hue n'a \u00e9t\u00e9 d\u00e9couvert", + "unknown": "Une erreur inconnue s'est produite" + }, + "error": { + "linking": "Une erreur inconnue s'est produite lors de la liaison entre le pont et Home Assistant", + "register_failed": "\u00c9chec d'enregistrement. Veuillez r\u00e9essayer." + }, + "step": { + "init": { + "data": { + "host": "H\u00f4te" + }, + "title": "Choisissez le pont Philips Hue" + }, + "link": { + "description": "Appuyez sur le bouton du pont pour lier Philips Hue avec Home Assistant. \n\n ![Emplacement du bouton sur le pont] (/static/images/config_philips_hue.jpg)", + "title": "Hub de liaison" + } + }, + "title": "Pont Philips Hue" + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/hu.json b/homeassistant/components/hue/.translations/hu.json index a4032dcbcfc215..be6548f59a0e91 100644 --- a/homeassistant/components/hue/.translations/hu.json +++ b/homeassistant/components/hue/.translations/hu.json @@ -2,7 +2,7 @@ "config": { "abort": { "all_configured": "M\u00e1r minden Philips Hue bridge konfigur\u00e1lt", - "already_configured": "A bridge m\u00e1r konfigur\u00e1lt", + "already_configured": "A bridge m\u00e1r konfigur\u00e1lva van", "cannot_connect": "Nem siker\u00fclt csatlakozni a bridge-hez.", "discover_timeout": "Nem tal\u00e1ltam a Hue bridget", "no_bridges": "Nem tal\u00e1ltam Philips Hue bridget", @@ -20,6 +20,7 @@ "title": "V\u00e1lassz Hue bridge-t" }, "link": { + "description": "Nyomja meg a gombot a bridge-en a Philips Hue Home Assistant-ben val\u00f3 regisztr\u00e1l\u00e1s\u00e1hoz.\n\n![Location of button on bridge](/static/images/config_philips_hue.jpg)", "title": "Kapcsol\u00f3d\u00e1s a hubhoz" } }, diff --git a/homeassistant/components/hue/.translations/it.json b/homeassistant/components/hue/.translations/it.json index 2c7a8c1924d6db..a9f2a732127a23 100644 --- a/homeassistant/components/hue/.translations/it.json +++ b/homeassistant/components/hue/.translations/it.json @@ -2,8 +2,27 @@ "config": { "abort": { "all_configured": "Tutti i bridge Philips Hue sono gi\u00e0 configurati", + "already_configured": "Il Bridge \u00e8 gi\u00e0 configurato", + "cannot_connect": "Impossibile connettersi al bridge", "discover_timeout": "Impossibile trovare i bridge Hue", - "no_bridges": "Nessun bridge Hue di Philips trovato" + "no_bridges": "Nessun bridge Hue di Philips trovato", + "unknown": "Si \u00e8 verificato un errore" + }, + "error": { + "linking": "Si \u00e8 verificato un errore sconosciuto in fase di collegamento.", + "register_failed": "Errore in fase di registrazione, riprova" + }, + "step": { + "init": { + "data": { + "host": "Host" + }, + "title": "Selezione il bridge Hue" + }, + "link": { + "description": "Premi il pulsante sul bridge per registrare Philips Hue con Home Assistant\n\n![Posizione del pulsante sul bridge](/static/images/config_philips_hue.jpg)", + "title": "Collega Hub" + } }, "title": "Philips Hue Bridge" } diff --git a/homeassistant/components/hue/.translations/ja.json b/homeassistant/components/hue/.translations/ja.json new file mode 100644 index 00000000000000..ccd260cb1cf2e0 --- /dev/null +++ b/homeassistant/components/hue/.translations/ja.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "unknown": "\u4e0d\u660e\u306a\u30a8\u30e9\u30fc\u304c\u767a\u751f\u3057\u307e\u3057\u305f" + }, + "step": { + "init": { + "data": { + "host": "\u30db\u30b9\u30c8" + } + } + }, + "title": "Philips Hue" + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/pt-BR.json b/homeassistant/components/hue/.translations/pt-BR.json new file mode 100644 index 00000000000000..5c6e409245c7e9 --- /dev/null +++ b/homeassistant/components/hue/.translations/pt-BR.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "all_configured": "Todas as pontes Philips Hue j\u00e1 est\u00e3o configuradas", + "already_configured": "A ponte j\u00e1 est\u00e1 configurada", + "cannot_connect": "N\u00e3o \u00e9 poss\u00edvel conectar-se \u00e0 ponte", + "discover_timeout": "Incapaz de descobrir pontes Hue", + "no_bridges": "N\u00e3o h\u00e1 pontes Philips Hue descobertas", + "unknown": "Ocorreu um erro desconhecido" + }, + "error": { + "linking": "Ocorreu um erro de liga\u00e7\u00e3o desconhecido.", + "register_failed": "Falhou ao registrar, por favor tente novamente" + }, + "step": { + "init": { + "data": { + "host": "Hospedeiro" + }, + "title": "Escolha a ponte Hue" + }, + "link": { + "description": "Pressione o bot\u00e3o na ponte para registrar o Philips Hue com o Home Assistant. \n\n ![Localiza\u00e7\u00e3o do bot\u00e3o na ponte] (/static/images/config_philips_hue.jpg)", + "title": "Hub de links" + } + }, + "title": "Philips Hue" + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/pt.json b/homeassistant/components/hue/.translations/pt.json index 8c4c45f9c897c8..f7988d82d8ce3b 100644 --- a/homeassistant/components/hue/.translations/pt.json +++ b/homeassistant/components/hue/.translations/pt.json @@ -1,5 +1,29 @@ { "config": { + "abort": { + "all_configured": "Todas os Philips Hue j\u00e1 est\u00e3o configuradas", + "already_configured": "Hue j\u00e1 est\u00e1 configurado", + "cannot_connect": "N\u00e3o foi poss\u00edvel se conectar", + "discover_timeout": "Nenhum Hue bridge descoberto", + "no_bridges": "Nenhum Philips Hue descoberto", + "unknown": "Ocorreu um erro desconhecido" + }, + "error": { + "linking": "Ocorreu um erro de liga\u00e7\u00e3o desconhecido.", + "register_failed": "Falha ao registrar, por favor, tente novamente" + }, + "step": { + "init": { + "data": { + "host": "Servidor" + }, + "title": "Hue bridge" + }, + "link": { + "description": "Pressione o bot\u00e3o no Philips Hue para registrar com o Home Assistant. \n\n ! [Localiza\u00e7\u00e3o do bot\u00e3o] (/ static / images / config_philips_hue.jpg)", + "title": "Link Hub" + } + }, "title": "" } } \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/ro.json b/homeassistant/components/hue/.translations/ro.json index 91541edcc7d7ff..69cee1198d3e0f 100644 --- a/homeassistant/components/hue/.translations/ro.json +++ b/homeassistant/components/hue/.translations/ro.json @@ -1,5 +1,9 @@ { "config": { + "abort": { + "all_configured": "Toate pun\u021bile Philips Hue sunt deja configurate", + "discover_timeout": "Imposibil de descoperit podurile Hue" + }, "error": { "linking": "A ap\u0103rut o eroare de leg\u0103tur\u0103 necunoscut\u0103.", "register_failed": "Nu a reu\u0219it \u00eenregistrarea, \u00eencerca\u021bi din nou" diff --git a/homeassistant/components/hue/.translations/ru.json b/homeassistant/components/hue/.translations/ru.json index ea1e4fff1bf915..b471dd1a0cd59e 100644 --- a/homeassistant/components/hue/.translations/ru.json +++ b/homeassistant/components/hue/.translations/ru.json @@ -24,6 +24,6 @@ "title": "\u0421\u0432\u044f\u0437\u044c \u0441 \u0445\u0430\u0431\u043e\u043c" } }, - "title": "\u0428\u043b\u044e\u0437 Philips Hue" + "title": "Philips Hue" } } \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/sv.json b/homeassistant/components/hue/.translations/sv.json new file mode 100644 index 00000000000000..efbcfa544f5d81 --- /dev/null +++ b/homeassistant/components/hue/.translations/sv.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "all_configured": "Alla Philips Hue-bryggor \u00e4r redan konfigurerade", + "already_configured": "Bryggan \u00e4r redan konfigurerad", + "cannot_connect": "Det gick inte att ansluta till bryggan", + "discover_timeout": "Det gick inte att uppt\u00e4cka n\u00e5gra Hue-bryggor", + "no_bridges": "Inga Philips Hue-bryggor uppt\u00e4cktes", + "unknown": "Ett ok\u00e4nt fel intr\u00e4ffade" + }, + "error": { + "linking": "Ett ok\u00e4nt l\u00e4nkningsfel intr\u00e4ffade.", + "register_failed": "Misslyckades med att registrera, v\u00e4nligen f\u00f6rs\u00f6k igen" + }, + "step": { + "init": { + "data": { + "host": "V\u00e4rd" + }, + "title": "V\u00e4lj Hue-brygga" + }, + "link": { + "description": "Tryck p\u00e5 knappen p\u00e5 bryggan f\u00f6r att registrera Philips Hue med Home Assistant. \n\n ! [Placering av knapp p\u00e5 brygga] (/ static / images / config_philips_hue.jpg)", + "title": "L\u00e4nka hub" + } + }, + "title": "Philips Hue Brygga" + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/.translations/vi.json b/homeassistant/components/hue/.translations/vi.json new file mode 100644 index 00000000000000..5cbd0c4aebfbd0 --- /dev/null +++ b/homeassistant/components/hue/.translations/vi.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "all_configured": "T\u1ea5t c\u1ea3 c\u00e1c c\u1ea7u Philips Hue \u0111\u00e3 \u0111\u01b0\u1ee3c c\u1ea5u h\u00ecnh", + "unknown": "X\u1ea3y ra l\u1ed7i kh\u00f4ng x\u00e1c \u0111\u1ecbnh \u0111\u01b0\u1ee3c" + }, + "error": { + "linking": "\u0110\u00e3 x\u1ea3y ra l\u1ed7i li\u00ean k\u1ebft kh\u00f4ng x\u00e1c \u0111\u1ecbnh.", + "register_failed": "Kh\u00f4ng th\u1ec3 \u0111\u0103ng k\u00fd, vui l\u00f2ng th\u1eed l\u1ea1i" + }, + "step": { + "link": { + "title": "Li\u00ean k\u1ebft Hub" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hue/__init__.py b/homeassistant/components/hue/__init__.py index 0aed854d4e4993..c04380e13035cf 100644 --- a/homeassistant/components/hue/__init__.py +++ b/homeassistant/components/hue/__init__.py @@ -9,6 +9,7 @@ import voluptuous as vol +from homeassistant import config_entries from homeassistant.const import CONF_FILENAME, CONF_HOST from homeassistant.helpers import aiohttp_client, config_validation as cv @@ -17,7 +18,7 @@ # Loading the config flow file will register the flow from .config_flow import configured_hosts -REQUIREMENTS = ['aiohue==1.3.0'] +REQUIREMENTS = ['aiohue==1.5.0'] _LOGGER = logging.getLogger(__name__) @@ -107,7 +108,8 @@ async def async_setup(hass, config): # deadlock: creating a config entry will set up the component but the # setup would block till the entry is created! hass.async_add_job(hass.config_entries.flow.async_init( - DOMAIN, source='import', data={ + DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, + data={ 'host': bridge_conf[CONF_HOST], 'path': bridge_conf[CONF_FILENAME], } diff --git a/homeassistant/components/hue/bridge.py b/homeassistant/components/hue/bridge.py index 5ff5e2dbf6f860..874c18aaa7ece1 100644 --- a/homeassistant/components/hue/bridge.py +++ b/homeassistant/components/hue/bridge.py @@ -19,7 +19,7 @@ }) -class HueBridge(object): +class HueBridge: """Manages a single Hue bridge.""" def __init__(self, hass, config_entry, allow_unreachable, allow_groups): @@ -51,7 +51,8 @@ async def async_setup(self, tries=0): # linking procedure. When linking succeeds, it will remove the # old config entry. hass.async_add_job(hass.config_entries.flow.async_init( - DOMAIN, source='import', data={ + DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, + data={ 'host': host, } )) @@ -78,7 +79,7 @@ async def retry_setup(_now): host) return False - hass.async_add_job(hass.config_entries.async_forward_entry_setup( + hass.async_create_task(hass.config_entries.async_forward_entry_setup( self.config_entry, 'light')) hass.services.async_register( @@ -124,12 +125,16 @@ async def hue_activate_scene(self, call, updated=False): (group for group in self.api.groups.values() if group.name == group_name), None) - scene_id = next( - (scene.id for scene in self.api.scenes.values() - if scene.name == scene_name), None) + # Additional scene logic to handle duplicate scene names across groups + scene = next( + (scene for scene in self.api.scenes.values() + if scene.name == scene_name + and group is not None + and sorted(scene.lights) == sorted(group.lights)), + None) # If we can't find it, fetch latest info. - if not updated and (group is None or scene_id is None): + if not updated and (group is None or scene is None): await self.api.groups.update() await self.api.scenes.update() await self.hue_activate_scene(call, updated=True) @@ -139,11 +144,11 @@ async def hue_activate_scene(self, call, updated=False): LOGGER.warning('Unable to find group %s', group_name) return - if scene_id is None: + if scene is None: LOGGER.warning('Unable to find scene %s', scene_name) return - await group.set_action(scene=scene_id) + await group.set_action(scene=scene.id) async def get_bridge(hass, host, username=None): diff --git a/homeassistant/components/hue/config_flow.py b/homeassistant/components/hue/config_flow.py index af67a594495e57..49ebbdaabf5dbb 100644 --- a/homeassistant/components/hue/config_flow.py +++ b/homeassistant/components/hue/config_flow.py @@ -50,6 +50,10 @@ def __init__(self): """Initialize the Hue flow.""" self.host = None + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + return await self.async_step_init(user_input) + async def async_step_init(self, user_input=None): """Handle a flow start.""" from aiohue.discovery import discover_nupnp @@ -84,7 +88,7 @@ async def async_step_init(self, user_input=None): reason='all_configured' ) - elif len(hosts) == 1: + if len(hosts) == 1: self.host = hosts[0] return await self.async_step_link() diff --git a/homeassistant/components/hue/strings.json b/homeassistant/components/hue/strings.json index fc9e91c93d7523..f8873894a01bf0 100644 --- a/homeassistant/components/hue/strings.json +++ b/homeassistant/components/hue/strings.json @@ -1,6 +1,6 @@ { "config": { - "title": "Philips Hue Bridge", + "title": "Philips Hue", "step": { "init": { "title": "Pick Hue bridge", diff --git a/homeassistant/components/hydrawise.py b/homeassistant/components/hydrawise.py new file mode 100644 index 00000000000000..0c4db63034ea56 --- /dev/null +++ b/homeassistant/components/hydrawise.py @@ -0,0 +1,153 @@ +""" +Support for Hydrawise cloud. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/hydrawise/ +""" +import asyncio +from datetime import timedelta +import logging + +from requests.exceptions import ConnectTimeout, HTTPError +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_ACCESS_TOKEN, CONF_SCAN_INTERVAL) +import homeassistant.helpers.config_validation as cv +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, dispatcher_send) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import track_time_interval + +REQUIREMENTS = ['hydrawiser==0.1.1'] + +_LOGGER = logging.getLogger(__name__) + +ALLOWED_WATERING_TIME = [5, 10, 15, 30, 45, 60] + +CONF_ATTRIBUTION = "Data provided by hydrawise.com" +CONF_WATERING_TIME = 'watering_minutes' + +NOTIFICATION_ID = 'hydrawise_notification' +NOTIFICATION_TITLE = 'Hydrawise Setup' + +DATA_HYDRAWISE = 'hydrawise' +DOMAIN = 'hydrawise' +DEFAULT_WATERING_TIME = 15 + +DEVICE_MAP_INDEX = ['KEY_INDEX', 'ICON_INDEX', 'DEVICE_CLASS_INDEX', + 'UNIT_OF_MEASURE_INDEX'] +DEVICE_MAP = { + 'auto_watering': ['Automatic Watering', 'mdi:autorenew', '', ''], + 'is_watering': ['Watering', '', 'moisture', ''], + 'manual_watering': ['Manual Watering', 'mdi:water-pump', '', ''], + 'next_cycle': ['Next Cycle', 'mdi:calendar-clock', '', ''], + 'status': ['Status', '', 'connectivity', ''], + 'watering_time': ['Watering Time', 'mdi:water-pump', '', 'min'], + 'rain_sensor': ['Rain Sensor', '', 'moisture', ''] +} + +BINARY_SENSORS = ['is_watering', 'status', 'rain_sensor'] + +SENSORS = ['next_cycle', 'watering_time'] + +SWITCHES = ['auto_watering', 'manual_watering'] + +SCAN_INTERVAL = timedelta(seconds=30) + +SIGNAL_UPDATE_HYDRAWISE = "hydrawise_update" + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): + cv.time_period, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Hunter Hydrawise component.""" + conf = config[DOMAIN] + access_token = conf[CONF_ACCESS_TOKEN] + scan_interval = conf.get(CONF_SCAN_INTERVAL) + + try: + from hydrawiser.core import Hydrawiser + + hydrawise = Hydrawiser(user_token=access_token) + hass.data[DATA_HYDRAWISE] = HydrawiseHub(hydrawise) + except (ConnectTimeout, HTTPError) as ex: + _LOGGER.error( + "Unable to connect to Hydrawise cloud service: %s", str(ex)) + hass.components.persistent_notification.create( + 'Error: {}
' + 'You will need to restart hass after fixing.' + ''.format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + + def hub_refresh(event_time): + """Call Hydrawise hub to refresh information.""" + _LOGGER.debug("Updating Hydrawise Hub component") + hass.data[DATA_HYDRAWISE].data.update_controller_info() + dispatcher_send(hass, SIGNAL_UPDATE_HYDRAWISE) + + # Call the Hydrawise API to refresh updates + track_time_interval(hass, hub_refresh, scan_interval) + + return True + + +class HydrawiseHub: + """Representation of a base Hydrawise device.""" + + def __init__(self, data): + """Initialize the entity.""" + self.data = data + + +class HydrawiseEntity(Entity): + """Entity class for Hydrawise devices.""" + + def __init__(self, data, sensor_type): + """Initialize the Hydrawise entity.""" + self.data = data + self._sensor_type = sensor_type + self._name = "{0} {1}".format( + self.data['name'], + DEVICE_MAP[self._sensor_type][ + DEVICE_MAP_INDEX.index('KEY_INDEX')]) + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @asyncio.coroutine + def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_HYDRAWISE, self._update_callback) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + + @property + def unit_of_measurement(self): + """Return the units of measurement.""" + return DEVICE_MAP[self._sensor_type][ + DEVICE_MAP_INDEX.index('UNIT_OF_MEASURE_INDEX')] + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + 'identifier': self.data.get('relay'), + } diff --git a/homeassistant/components/ifttt.py b/homeassistant/components/ifttt.py index 0a4ad66ce56756..9497282ab21cd1 100644 --- a/homeassistant/components/ifttt.py +++ b/homeassistant/components/ifttt.py @@ -63,7 +63,7 @@ def trigger_service(call): value3 = call.data.get(ATTR_VALUE3) try: - import pyfttt as pyfttt + import pyfttt pyfttt.send_event(key, event, value1, value2, value3) except requests.exceptions.RequestException: _LOGGER.exception("Error communicating with IFTTT") diff --git a/homeassistant/components/ihc/__init__.py b/homeassistant/components/ihc/__init__.py index 0c0100bc9f595e..672964f765e12d 100644 --- a/homeassistant/components/ihc/__init__.py +++ b/homeassistant/components/ihc/__init__.py @@ -167,7 +167,10 @@ def get_discovery_info(component_setup, groups): name = '{}_{}'.format(groupname, ihc_id) device = { 'ihc_id': ihc_id, - 'product': product, + 'product': { + 'name': product.attrib['name'], + 'note': product.attrib['note'], + 'position': product.attrib['position']}, 'product_cfg': product_cfg} discovery_data[name] = device return discovery_data diff --git a/homeassistant/components/ihc/ihcdevice.py b/homeassistant/components/ihc/ihcdevice.py index de6db875def005..2ccca366d901cd 100644 --- a/homeassistant/components/ihc/ihcdevice.py +++ b/homeassistant/components/ihc/ihcdevice.py @@ -1,6 +1,5 @@ """Implementation of a base class for all IHC devices.""" import asyncio -from xml.etree.ElementTree import Element from homeassistant.helpers.entity import Entity @@ -14,16 +13,16 @@ class IHCDevice(Entity): """ def __init__(self, ihc_controller, name, ihc_id: int, info: bool, - product: Element = None) -> None: + product=None) -> None: """Initialize IHC attributes.""" self.ihc_controller = ihc_controller self._name = name self.ihc_id = ihc_id self.info = info if product: - self.ihc_name = product.attrib['name'] - self.ihc_note = product.attrib['note'] - self.ihc_position = product.attrib['position'] + self.ihc_name = product['name'] + self.ihc_note = product['note'] + self.ihc_position = product['position'] else: self.ihc_name = '' self.ihc_note = '' diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index c6100ff701d39c..480ec31da7d127 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -10,14 +10,14 @@ import voluptuous as vol -from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv from homeassistant.const import ( - ATTR_ENTITY_ID, CONF_NAME, CONF_ENTITY_ID) + ATTR_ENTITY_ID, ATTR_NAME, CONF_ENTITY_ID, CONF_NAME) +from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.loader import bind_hass +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.loader import bind_hass from homeassistant.util.async_ import run_callback_threadsafe _LOGGER = logging.getLogger(__name__) @@ -42,7 +42,6 @@ ATTR_FACES = 'faces' ATTR_GENDER = 'gender' ATTR_GLASSES = 'glasses' -ATTR_NAME = 'name' ATTR_MOTION = 'motion' ATTR_TOTAL_FACES = 'total_faces' @@ -60,7 +59,7 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ vol.Optional(CONF_SOURCE): vol.All(cv.ensure_list, [SOURCE_SCHEMA]), vol.Optional(CONF_CONFIDENCE, default=DEFAULT_CONFIDENCE): - vol.All(vol.Coerce(float), vol.Range(min=0, max=100)) + vol.All(vol.Coerce(float), vol.Range(min=0, max=100)), }) SERVICE_SCAN_SCHEMA = vol.Schema({ @@ -70,27 +69,32 @@ @bind_hass def scan(hass, entity_id=None): - """Force process an image.""" + """Force process of all cameras or given entity.""" + hass.add_job(async_scan, hass, entity_id) + + +@callback +@bind_hass +def async_scan(hass, entity_id=None): + """Force process of all cameras or given entity.""" data = {ATTR_ENTITY_ID: entity_id} if entity_id else None - hass.services.call(DOMAIN, SERVICE_SCAN, data) + hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_SCAN, data)) -@asyncio.coroutine -def async_setup(hass, config): - """Set up image processing.""" +async def async_setup(hass, config): + """Set up the image processing.""" component = EntityComponent(_LOGGER, DOMAIN, hass, SCAN_INTERVAL) - yield from component.async_setup(config) + await component.async_setup(config) - @asyncio.coroutine - def async_scan_service(service): + async def async_scan_service(service): """Service handler for scan.""" image_entities = component.async_extract_from_service(service) update_task = [entity.async_update_ha_state(True) for entity in image_entities] if update_task: - yield from asyncio.wait(update_task, loop=hass.loop) + await asyncio.wait(update_task, loop=hass.loop) hass.services.async_register( DOMAIN, SERVICE_SCAN, async_scan_service, @@ -125,8 +129,7 @@ def async_process_image(self, image): """ return self.hass.async_add_job(self.process_image, image) - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Update image and process it. This method is a coroutine. @@ -135,7 +138,7 @@ def async_update(self): image = None try: - image = yield from camera.async_get_image( + image = await camera.async_get_image( self.camera_entity, timeout=self.timeout) except HomeAssistantError as err: @@ -143,7 +146,7 @@ def async_update(self): return # process image data - yield from self.async_process_image(image.content) + await self.async_process_image(image.content) class ImageProcessingFaceEntity(ImageProcessingEntity): diff --git a/homeassistant/components/image_processing/facebox.py b/homeassistant/components/image_processing/facebox.py new file mode 100644 index 00000000000000..e5ce0b825d0794 --- /dev/null +++ b/homeassistant/components/image_processing/facebox.py @@ -0,0 +1,260 @@ +""" +Component that will perform facial detection and identification via facebox. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/image_processing.facebox +""" +import base64 +import logging + +import requests +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_NAME) +from homeassistant.core import split_entity_id +import homeassistant.helpers.config_validation as cv +from homeassistant.components.image_processing import ( + PLATFORM_SCHEMA, ImageProcessingFaceEntity, ATTR_CONFIDENCE, CONF_SOURCE, + CONF_ENTITY_ID, CONF_NAME, DOMAIN) +from homeassistant.const import ( + CONF_IP_ADDRESS, CONF_PORT, CONF_PASSWORD, CONF_USERNAME, + HTTP_BAD_REQUEST, HTTP_OK, HTTP_UNAUTHORIZED) + +_LOGGER = logging.getLogger(__name__) + +ATTR_BOUNDING_BOX = 'bounding_box' +ATTR_CLASSIFIER = 'classifier' +ATTR_IMAGE_ID = 'image_id' +ATTR_ID = 'id' +ATTR_MATCHED = 'matched' +FACEBOX_NAME = 'name' +CLASSIFIER = 'facebox' +DATA_FACEBOX = 'facebox_classifiers' +FILE_PATH = 'file_path' +SERVICE_TEACH_FACE = 'facebox_teach_face' + + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Required(CONF_PORT): cv.port, + vol.Optional(CONF_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, +}) + +SERVICE_TEACH_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_NAME): cv.string, + vol.Required(FILE_PATH): cv.string, +}) + + +def check_box_health(url, username, password): + """Check the health of the classifier and return its id if healthy.""" + kwargs = {} + if username: + kwargs['auth'] = requests.auth.HTTPBasicAuth(username, password) + try: + response = requests.get( + url, + **kwargs + ) + if response.status_code == HTTP_UNAUTHORIZED: + _LOGGER.error("AuthenticationError on %s", CLASSIFIER) + return None + if response.status_code == HTTP_OK: + return response.json()['hostname'] + except requests.exceptions.ConnectionError: + _LOGGER.error("ConnectionError: Is %s running?", CLASSIFIER) + return None + + +def encode_image(image): + """base64 encode an image stream.""" + base64_img = base64.b64encode(image).decode('ascii') + return base64_img + + +def get_matched_faces(faces): + """Return the name and rounded confidence of matched faces.""" + return {face['name']: round(face['confidence'], 2) + for face in faces if face['matched']} + + +def parse_faces(api_faces): + """Parse the API face data into the format required.""" + known_faces = [] + for entry in api_faces: + face = {} + if entry['matched']: # This data is only in matched faces. + face[FACEBOX_NAME] = entry['name'] + face[ATTR_IMAGE_ID] = entry['id'] + else: # Lets be explicit. + face[FACEBOX_NAME] = None + face[ATTR_IMAGE_ID] = None + face[ATTR_CONFIDENCE] = round(100.0*entry['confidence'], 2) + face[ATTR_MATCHED] = entry['matched'] + face[ATTR_BOUNDING_BOX] = entry['rect'] + known_faces.append(face) + return known_faces + + +def post_image(url, image, username, password): + """Post an image to the classifier.""" + kwargs = {} + if username: + kwargs['auth'] = requests.auth.HTTPBasicAuth(username, password) + try: + response = requests.post( + url, + json={"base64": encode_image(image)}, + **kwargs + ) + if response.status_code == HTTP_UNAUTHORIZED: + _LOGGER.error("AuthenticationError on %s", CLASSIFIER) + return None + return response + except requests.exceptions.ConnectionError: + _LOGGER.error("ConnectionError: Is %s running?", CLASSIFIER) + return None + + +def teach_file(url, name, file_path, username, password): + """Teach the classifier a name associated with a file.""" + kwargs = {} + if username: + kwargs['auth'] = requests.auth.HTTPBasicAuth(username, password) + try: + with open(file_path, 'rb') as open_file: + response = requests.post( + url, + data={FACEBOX_NAME: name, ATTR_ID: file_path}, + files={'file': open_file}, + **kwargs + ) + if response.status_code == HTTP_UNAUTHORIZED: + _LOGGER.error("AuthenticationError on %s", CLASSIFIER) + elif response.status_code == HTTP_BAD_REQUEST: + _LOGGER.error("%s teaching of file %s failed with message:%s", + CLASSIFIER, file_path, response.text) + except requests.exceptions.ConnectionError: + _LOGGER.error("ConnectionError: Is %s running?", CLASSIFIER) + + +def valid_file_path(file_path): + """Check that a file_path points to a valid file.""" + try: + cv.isfile(file_path) + return True + except vol.Invalid: + _LOGGER.error( + "%s error: Invalid file path: %s", CLASSIFIER, file_path) + return False + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the classifier.""" + if DATA_FACEBOX not in hass.data: + hass.data[DATA_FACEBOX] = [] + + ip_address = config[CONF_IP_ADDRESS] + port = config[CONF_PORT] + username = config.get(CONF_USERNAME) + password = config.get(CONF_PASSWORD) + url_health = "http://{}:{}/healthz".format(ip_address, port) + hostname = check_box_health(url_health, username, password) + if hostname is None: + return + + entities = [] + for camera in config[CONF_SOURCE]: + facebox = FaceClassifyEntity( + ip_address, port, username, password, hostname, + camera[CONF_ENTITY_ID], camera.get(CONF_NAME)) + entities.append(facebox) + hass.data[DATA_FACEBOX].append(facebox) + add_devices(entities) + + def service_handle(service): + """Handle for services.""" + entity_ids = service.data.get('entity_id') + + classifiers = hass.data[DATA_FACEBOX] + if entity_ids: + classifiers = [c for c in classifiers if c.entity_id in entity_ids] + + for classifier in classifiers: + name = service.data.get(ATTR_NAME) + file_path = service.data.get(FILE_PATH) + classifier.teach(name, file_path) + + hass.services.register( + DOMAIN, SERVICE_TEACH_FACE, service_handle, + schema=SERVICE_TEACH_SCHEMA) + + +class FaceClassifyEntity(ImageProcessingFaceEntity): + """Perform a face classification.""" + + def __init__(self, ip_address, port, username, password, hostname, + camera_entity, name=None): + """Init with the API key and model id.""" + super().__init__() + self._url_check = "http://{}:{}/{}/check".format( + ip_address, port, CLASSIFIER) + self._url_teach = "http://{}:{}/{}/teach".format( + ip_address, port, CLASSIFIER) + self._username = username + self._password = password + self._hostname = hostname + self._camera = camera_entity + if name: + self._name = name + else: + camera_name = split_entity_id(camera_entity)[1] + self._name = "{} {}".format(CLASSIFIER, camera_name) + self._matched = {} + + def process_image(self, image): + """Process an image.""" + response = post_image( + self._url_check, image, self._username, self._password) + if response: + response_json = response.json() + if response_json['success']: + total_faces = response_json['facesCount'] + faces = parse_faces(response_json['faces']) + self._matched = get_matched_faces(faces) + self.process_faces(faces, total_faces) + + else: + self.total_faces = None + self.faces = [] + self._matched = {} + + def teach(self, name, file_path): + """Teach classifier a face name.""" + if (not self.hass.config.is_allowed_path(file_path) + or not valid_file_path(file_path)): + return + teach_file( + self._url_teach, name, file_path, self._username, self._password) + + @property + def camera_entity(self): + """Return camera entity id from process pictures.""" + return self._camera + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def device_state_attributes(self): + """Return the classifier attributes.""" + return { + 'matched_faces': self._matched, + 'total_matched_faces': len(self._matched), + 'hostname': self._hostname + } diff --git a/homeassistant/components/image_processing/microsoft_face_detect.py b/homeassistant/components/image_processing/microsoft_face_detect.py index cd1e341a21874c..0b57dba8bcab4d 100644 --- a/homeassistant/components/image_processing/microsoft_face_detect.py +++ b/homeassistant/components/image_processing/microsoft_face_detect.py @@ -9,12 +9,12 @@ import voluptuous as vol +from homeassistant.components.image_processing import ( + ATTR_AGE, ATTR_GENDER, ATTR_GLASSES, CONF_ENTITY_ID, CONF_NAME, + CONF_SOURCE, PLATFORM_SCHEMA, ImageProcessingFaceEntity) +from homeassistant.components.microsoft_face import DATA_MICROSOFT_FACE from homeassistant.core import split_entity_id from homeassistant.exceptions import HomeAssistantError -from homeassistant.components.microsoft_face import DATA_MICROSOFT_FACE -from homeassistant.components.image_processing import ( - PLATFORM_SCHEMA, ImageProcessingFaceEntity, ATTR_AGE, ATTR_GENDER, - ATTR_GLASSES, CONF_SOURCE, CONF_ENTITY_ID, CONF_NAME) import homeassistant.helpers.config_validation as cv DEPENDENCIES = ['microsoft_face'] @@ -103,7 +103,7 @@ def async_process_image(self, image): _LOGGER.error("Can't process image on microsoft face: %s", err) return - if face_data is None or len(face_data) < 1: + if not face_data: return faces = [] diff --git a/homeassistant/components/image_processing/microsoft_face_identify.py b/homeassistant/components/image_processing/microsoft_face_identify.py index 32f02e1820e533..9479a804a44161 100644 --- a/homeassistant/components/image_processing/microsoft_face_identify.py +++ b/homeassistant/components/image_processing/microsoft_face_identify.py @@ -9,12 +9,13 @@ import voluptuous as vol +from homeassistant.components.image_processing import ( + ATTR_CONFIDENCE, CONF_CONFIDENCE, CONF_ENTITY_ID, CONF_NAME, CONF_SOURCE, + PLATFORM_SCHEMA, ImageProcessingFaceEntity) +from homeassistant.components.microsoft_face import DATA_MICROSOFT_FACE +from homeassistant.const import ATTR_NAME from homeassistant.core import split_entity_id from homeassistant.exceptions import HomeAssistantError -from homeassistant.components.microsoft_face import DATA_MICROSOFT_FACE -from homeassistant.components.image_processing import ( - PLATFORM_SCHEMA, ImageProcessingFaceEntity, ATTR_NAME, - CONF_CONFIDENCE, CONF_SOURCE, CONF_ENTITY_ID, CONF_NAME, ATTR_CONFIDENCE) import homeassistant.helpers.config_validation as cv DEPENDENCIES = ['microsoft_face'] @@ -89,7 +90,7 @@ def async_process_image(self, image): face_data = yield from self._api.call_api( 'post', 'detect', image, binary=True) - if face_data is None or len(face_data) < 1: + if not face_data: return face_ids = [data['faceId'] for data in face_data] diff --git a/homeassistant/components/image_processing/opencv.py b/homeassistant/components/image_processing/opencv.py index c3e34b4d42be93..00ae01f11231a4 100644 --- a/homeassistant/components/image_processing/opencv.py +++ b/homeassistant/components/image_processing/opencv.py @@ -16,7 +16,7 @@ from homeassistant.core import split_entity_id import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['numpy==1.14.3'] +REQUIREMENTS = ['numpy==1.15.0'] _LOGGER = logging.getLogger(__name__) @@ -152,7 +152,6 @@ def process_image(self, image): import cv2 # pylint: disable=import-error import numpy - # pylint: disable=no-member cv_image = cv2.imdecode( numpy.asarray(bytearray(image)), cv2.IMREAD_UNCHANGED) @@ -168,7 +167,6 @@ def process_image(self, image): else: path = classifier - # pylint: disable=no-member cascade = cv2.CascadeClassifier(path) detections = cascade.detectMultiScale( diff --git a/homeassistant/components/image_processing/services.yaml b/homeassistant/components/image_processing/services.yaml index 1f1fa347dc9b5f..0689c34c1a3eec 100644 --- a/homeassistant/components/image_processing/services.yaml +++ b/homeassistant/components/image_processing/services.yaml @@ -6,3 +6,16 @@ scan: entity_id: description: Name(s) of entities to scan immediately. example: 'image_processing.alpr_garage' + +facebox_teach_face: + description: Teach facebox a face using a file. + fields: + entity_id: + description: The facebox entity to teach. + example: 'image_processing.facebox' + name: + description: The name of the face to teach. + example: 'my_name' + file_path: + description: The path to the image file. + example: '/images/my_image.jpg' diff --git a/homeassistant/components/insteon_plm/__init__.py b/homeassistant/components/insteon_plm/__init__.py index 246e84ec71f3a2..055015b74f5de2 100644 --- a/homeassistant/components/insteon_plm/__init__.py +++ b/homeassistant/components/insteon_plm/__init__.py @@ -17,7 +17,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['insteonplm==0.9.1'] +REQUIREMENTS = ['insteonplm==0.11.7'] _LOGGER = logging.getLogger(__name__) @@ -29,17 +29,36 @@ CONF_SUBCAT = 'subcat' CONF_FIRMWARE = 'firmware' CONF_PRODUCT_KEY = 'product_key' +CONF_X10 = 'x10_devices' +CONF_HOUSECODE = 'housecode' +CONF_UNITCODE = 'unitcode' +CONF_DIM_STEPS = 'dim_steps' +CONF_X10_ALL_UNITS_OFF = 'x10_all_units_off' +CONF_X10_ALL_LIGHTS_ON = 'x10_all_lights_on' +CONF_X10_ALL_LIGHTS_OFF = 'x10_all_lights_off' SRV_ADD_ALL_LINK = 'add_all_link' SRV_DEL_ALL_LINK = 'delete_all_link' SRV_LOAD_ALDB = 'load_all_link_database' SRV_PRINT_ALDB = 'print_all_link_database' SRV_PRINT_IM_ALDB = 'print_im_all_link_database' +SRV_X10_ALL_UNITS_OFF = 'x10_all_units_off' +SRV_X10_ALL_LIGHTS_OFF = 'x10_all_lights_off' +SRV_X10_ALL_LIGHTS_ON = 'x10_all_lights_on' SRV_ALL_LINK_GROUP = 'group' SRV_ALL_LINK_MODE = 'mode' SRV_LOAD_DB_RELOAD = 'reload' SRV_CONTROLLER = 'controller' SRV_RESPONDER = 'responder' +SRV_HOUSECODE = 'housecode' + +HOUSECODES = ['a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', + 'i', 'j', 'k', 'l', 'm', 'n', 'o', 'p'] + +BUTTON_PRESSED_STATE_NAME = 'onLevelButton' +EVENT_BUTTON_ON = 'insteon_plm.button_on' +EVENT_BUTTON_OFF = 'insteon_plm.button_off' +EVENT_CONF_BUTTON = 'button' CONF_DEVICE_OVERRIDE_SCHEMA = vol.All( cv.deprecated(CONF_PLATFORM), vol.Schema({ @@ -51,11 +70,24 @@ vol.Optional(CONF_PLATFORM): cv.string, })) +CONF_X10_SCHEMA = vol.All( + vol.Schema({ + vol.Required(CONF_HOUSECODE): cv.string, + vol.Required(CONF_UNITCODE): vol.Range(min=1, max=16), + vol.Required(CONF_PLATFORM): cv.string, + vol.Optional(CONF_DIM_STEPS): vol.Range(min=2, max=255) + })) + CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_PORT): cv.string, vol.Optional(CONF_OVERRIDE): vol.All( - cv.ensure_list_csv, [CONF_DEVICE_OVERRIDE_SCHEMA]) + cv.ensure_list_csv, [CONF_DEVICE_OVERRIDE_SCHEMA]), + vol.Optional(CONF_X10_ALL_UNITS_OFF): vol.In(HOUSECODES), + vol.Optional(CONF_X10_ALL_LIGHTS_ON): vol.In(HOUSECODES), + vol.Optional(CONF_X10_ALL_LIGHTS_OFF): vol.In(HOUSECODES), + vol.Optional(CONF_X10): vol.All( + cv.ensure_list_csv, [CONF_X10_SCHEMA]) }) }, extra=vol.ALLOW_EXTRA) @@ -77,6 +109,10 @@ vol.Required(CONF_ENTITY_ID): cv.entity_id, }) +X10_HOUSECODE_SCHEMA = vol.Schema({ + vol.Required(SRV_HOUSECODE): vol.In(HOUSECODES), + }) + @asyncio.coroutine def async_setup(hass, config): @@ -89,24 +125,33 @@ def async_setup(hass, config): conf = config[DOMAIN] port = conf.get(CONF_PORT) overrides = conf.get(CONF_OVERRIDE, []) + x10_devices = conf.get(CONF_X10, []) + x10_all_units_off_housecode = conf.get(CONF_X10_ALL_UNITS_OFF) + x10_all_lights_on_housecode = conf.get(CONF_X10_ALL_LIGHTS_ON) + x10_all_lights_off_housecode = conf.get(CONF_X10_ALL_LIGHTS_OFF) @callback def async_plm_new_device(device): """Detect device from transport to be delegated to platform.""" for state_key in device.states: platform_info = ipdb[device.states[state_key]] - if platform_info: + if platform_info and platform_info.platform: platform = platform_info.platform - if platform: + + if platform == 'on_off_events': + device.states[state_key].register_updates( + _fire_button_on_off_event) + + else: _LOGGER.info("New INSTEON PLM device: %s (%s) %s", device.address, device.states[state_key].name, platform) - hass.async_add_job( + hass.async_create_task( discovery.async_load_platform( hass, platform, DOMAIN, - discovered={'address': device.address.hex, + discovered={'address': device.address.id, 'state_key': state_key}, hass_config=config)) @@ -151,6 +196,21 @@ def print_im_aldb(service): # Furture direction is to create an INSTEON control panel. print_aldb_to_log(plm.aldb) + def x10_all_units_off(service): + """Send the X10 All Units Off command.""" + housecode = service.data.get(SRV_HOUSECODE) + plm.x10_all_units_off(housecode) + + def x10_all_lights_off(service): + """Send the X10 All Lights Off command.""" + housecode = service.data.get(SRV_HOUSECODE) + plm.x10_all_lights_off(housecode) + + def x10_all_lights_on(service): + """Send the X10 All Lights On command.""" + housecode = service.data.get(SRV_HOUSECODE) + plm.x10_all_lights_on(housecode) + def _register_services(): hass.services.register(DOMAIN, SRV_ADD_ALL_LINK, add_all_link, schema=ADD_ALL_LINK_SCHEMA) @@ -162,8 +222,34 @@ def _register_services(): schema=PRINT_ALDB_SCHEMA) hass.services.register(DOMAIN, SRV_PRINT_IM_ALDB, print_im_aldb, schema=None) + hass.services.register(DOMAIN, SRV_X10_ALL_UNITS_OFF, + x10_all_units_off, + schema=X10_HOUSECODE_SCHEMA) + hass.services.register(DOMAIN, SRV_X10_ALL_LIGHTS_OFF, + x10_all_lights_off, + schema=X10_HOUSECODE_SCHEMA) + hass.services.register(DOMAIN, SRV_X10_ALL_LIGHTS_ON, + x10_all_lights_on, + schema=X10_HOUSECODE_SCHEMA) _LOGGER.debug("Insteon_plm Services registered") + def _fire_button_on_off_event(address, group, val): + # Firing an event when a button is pressed. + device = plm.devices[address.hex] + state_name = device.states[group].name + button = ("" if state_name == BUTTON_PRESSED_STATE_NAME + else state_name[-1].lower()) + schema = {CONF_ADDRESS: address.hex} + if button != "": + schema[EVENT_CONF_BUTTON] = button + if val: + event = EVENT_BUTTON_ON + else: + event = EVENT_BUTTON_OFF + _LOGGER.debug('Firing event %s with address %s and button %s', + event, address.hex, button) + hass.bus.fire(event, schema) + _LOGGER.info("Looking for PLM on %s", port) conn = yield from insteonplm.Connection.create( device=port, @@ -192,6 +278,36 @@ def _register_services(): hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, conn.close) plm.devices.add_device_callback(async_plm_new_device) + + if x10_all_units_off_housecode: + device = plm.add_x10_device(x10_all_units_off_housecode, + 20, + 'allunitsoff') + if x10_all_lights_on_housecode: + device = plm.add_x10_device(x10_all_lights_on_housecode, + 21, + 'alllightson') + if x10_all_lights_off_housecode: + device = plm.add_x10_device(x10_all_lights_off_housecode, + 22, + 'alllightsoff') + for device in x10_devices: + housecode = device.get(CONF_HOUSECODE) + unitcode = device.get(CONF_UNITCODE) + x10_type = 'onoff' + steps = device.get(CONF_DIM_STEPS, 22) + if device.get(CONF_PLATFORM) == 'light': + x10_type = 'dimmable' + elif device.get(CONF_PLATFORM) == 'binary_sensor': + x10_type = 'sensor' + _LOGGER.debug("Adding X10 device to insteonplm: %s %d %s", + housecode, unitcode, x10_type) + device = plm.add_x10_device(housecode, + unitcode, + x10_type) + if device and hasattr(device.states[0x01], 'steps'): + device.states[0x01].steps = steps + hass.async_add_job(_register_services) return True @@ -200,7 +316,7 @@ def _register_services(): State = collections.namedtuple('Product', 'stateType platform') -class IPDB(object): +class IPDB: """Embodies the INSTEON Product Database static data and access methods.""" def __init__(self): @@ -211,7 +327,8 @@ def __init__(self): OpenClosedRelay) from insteonplm.states.dimmable import (DimmableSwitch, - DimmableSwitch_Fan) + DimmableSwitch_Fan, + DimmableRemote) from insteonplm.states.sensor import (VariableSensor, OnOffSensor, @@ -219,6 +336,13 @@ def __init__(self): IoLincSensor, LeakSensorDryWet) + from insteonplm.states.x10 import (X10DimmableSwitch, + X10OnOffSwitch, + X10OnOffSensor, + X10AllUnitsOffSensor, + X10AllLightsOnSensor, + X10AllLightsOffSensor) + self.states = [State(OnOffSwitch_OutletTop, 'switch'), State(OnOffSwitch_OutletBottom, 'switch'), State(OpenClosedRelay, 'switch'), @@ -231,7 +355,15 @@ def __init__(self): State(VariableSensor, 'sensor'), State(DimmableSwitch_Fan, 'fan'), - State(DimmableSwitch, 'light')] + State(DimmableSwitch, 'light'), + State(DimmableRemote, 'on_off_events'), + + State(X10DimmableSwitch, 'light'), + State(X10OnOffSwitch, 'switch'), + State(X10OnOffSensor, 'binary_sensor'), + State(X10AllUnitsOffSensor, 'binary_sensor'), + State(X10AllLightsOnSensor, 'binary_sensor'), + State(X10AllLightsOffSensor, 'binary_sensor')] def __len__(self): """Return the number of INSTEON state types mapped to HA platforms.""" diff --git a/homeassistant/components/insteon_plm/services.yaml b/homeassistant/components/insteon_plm/services.yaml index a0e250fef1ff9c..4d87d7881bf666 100644 --- a/homeassistant/components/insteon_plm/services.yaml +++ b/homeassistant/components/insteon_plm/services.yaml @@ -14,7 +14,7 @@ delete_all_link: description: All-Link group number. example: 1 load_all_link_database: - description: Load the All-Link Database for a device. WARNING - Loading a device All-LInk database is very time consuming and inconsistant. This may take a LONG time and may need to be repeated to obtain all records. + description: Load the All-Link Database for a device. WARNING - Loading a device All-LInk database is very time consuming and inconsistent. This may take a LONG time and may need to be repeated to obtain all records. fields: entity_id: description: Name of the device to print @@ -30,3 +30,21 @@ print_all_link_database: example: 'light.1a2b3c' print_im_all_link_database: description: Print the All-Link Database for the INSTEON Modem (IM). +x10_all_units_off: + description: Send X10 All Units Off command + fields: + housecode: + description: X10 house code + example: c +x10_all_lights_on: + description: Send X10 All Lights On command + fields: + housecode: + description: X10 house code + example: c +x10_all_lights_off: + description: Send X10 All Lights Off command + fields: + housecode: + description: X10 house code + example: c diff --git a/homeassistant/components/ios.py b/homeassistant/components/ios.py index fe3c934659b927..7f7377469fd3cb 100644 --- a/homeassistant/components/ios.py +++ b/homeassistant/components/ios.py @@ -181,7 +181,6 @@ def devices_with_push(): def enabled_push_ids(): """Return a list of push enabled target push IDs.""" push_ids = list() - # pylint: disable=unused-variable for device in CONFIG_FILE[ATTR_DEVICES].values(): if device.get(ATTR_PUSH_ID) is not None: push_ids.append(device.get(ATTR_PUSH_ID)) @@ -203,7 +202,6 @@ def device_name_for_push_id(push_id): def setup(hass, config): """Set up the iOS component.""" - # pylint: disable=global-statement, import-error global CONFIG_FILE global CONFIG_FILE_PATH diff --git a/homeassistant/components/iota.py b/homeassistant/components/iota.py index 442be6e22e7097..ada70f8a9ebdea 100644 --- a/homeassistant/components/iota.py +++ b/homeassistant/components/iota.py @@ -13,7 +13,7 @@ from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyota==2.0.4'] +REQUIREMENTS = ['pyota==2.0.5'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/isy994.py b/homeassistant/components/isy994.py index 48a9499d1a9e9b..d8afb7be5dae13 100644 --- a/homeassistant/components/isy994.py +++ b/homeassistant/components/isy994.py @@ -11,12 +11,12 @@ import voluptuous as vol -from homeassistant.core import HomeAssistant # noqa +from homeassistant.core import HomeAssistant from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, CONF_USERNAME, EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers import discovery, config_validation as cv from homeassistant.helpers.entity import Entity -from homeassistant.helpers.typing import ConfigType, Dict # noqa +from homeassistant.helpers.typing import ConfigType, Dict REQUIREMENTS = ['PyISY==1.1.0'] @@ -202,7 +202,7 @@ def _check_for_uom_id(hass: HomeAssistant, node, node_uom = set(map(str.lower, node.uom)) if uom_list: - if node_uom.intersection(NODE_FILTERS[single_domain]['uom']): + if node_uom.intersection(uom_list): hass.data[ISY994_NODES][single_domain].append(node) return True else: @@ -268,7 +268,6 @@ def _is_sensor_a_binary_sensor(hass: HomeAssistant, node) -> bool: def _categorize_nodes(hass: HomeAssistant, nodes, ignore_identifier: str, sensor_identifier: str)-> None: """Sort the nodes to their proper domains.""" - # pylint: disable=no-member for (path, node) in nodes: ignored = ignore_identifier in path or ignore_identifier in node.name if ignored: @@ -425,7 +424,6 @@ def async_added_to_hass(self) -> None: self._control_handler = self._node.controlEvents.subscribe( self.on_control) - # pylint: disable=unused-argument def on_update(self, event: object) -> None: """Handle the update event from the ISY994 Node.""" self.schedule_update_ha_state() diff --git a/homeassistant/components/keyboard_remote.py b/homeassistant/components/keyboard_remote.py index d737c555873b9f..9a7cc7caecbf9b 100644 --- a/homeassistant/components/keyboard_remote.py +++ b/homeassistant/components/keyboard_remote.py @@ -50,10 +50,7 @@ def setup(hass, config): """Set up the keyboard_remote.""" config = config.get(DOMAIN) - keyboard_remote = KeyboardRemote( - hass, - config - ) + keyboard_remote = KeyboardRemote(hass, config) def _start_keyboard_remote(_event): keyboard_remote.run() @@ -61,14 +58,8 @@ def _start_keyboard_remote(_event): def _stop_keyboard_remote(_event): keyboard_remote.stop() - hass.bus.listen_once( - EVENT_HOMEASSISTANT_START, - _start_keyboard_remote - ) - hass.bus.listen_once( - EVENT_HOMEASSISTANT_STOP, - _stop_keyboard_remote - ) + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, _start_keyboard_remote) + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, _stop_keyboard_remote) return True @@ -93,10 +84,8 @@ def __init__(self, hass, device_name, device_descriptor, key_value): _LOGGER.debug("Keyboard connected, %s", self.device_id) else: _LOGGER.debug( - 'Keyboard not connected, %s.\n\ - Check /dev/input/event* permissions.', - self.device_id - ) + "Keyboard not connected, %s. " + "Check /dev/input/event* permissions", self.device_id) id_folder = '/dev/input/by-id/' @@ -105,12 +94,9 @@ def __init__(self, hass, device_name, device_descriptor, key_value): device_names = [InputDevice(file_name).name for file_name in list_devices()] _LOGGER.debug( - 'Possible device names are:\n %s.\n \ - Possible device descriptors are %s:\n %s', - device_names, - id_folder, - os.listdir(id_folder) - ) + "Possible device names are: %s. " + "Possible device descriptors are %s: %s", + device_names, id_folder, os.listdir(id_folder)) threading.Thread.__init__(self) self.stopped = threading.Event() @@ -149,9 +135,7 @@ def run(self): self.dev = self._get_keyboard_device() if self.dev is not None: self.dev.grab() - self.hass.bus.fire( - KEYBOARD_REMOTE_CONNECTED - ) + self.hass.bus.fire(KEYBOARD_REMOTE_CONNECTED) _LOGGER.debug("Keyboard re-connected, %s", self.device_id) else: continue @@ -160,25 +144,26 @@ def run(self): event = self.dev.read_one() except IOError: # Keyboard Disconnected self.dev = None - self.hass.bus.fire( - KEYBOARD_REMOTE_DISCONNECTED - ) + self.hass.bus.fire(KEYBOARD_REMOTE_DISCONNECTED) _LOGGER.debug("Keyboard disconnected, %s", self.device_id) continue if not event: continue - # pylint: disable=no-member if event.type is ecodes.EV_KEY and event.value is self.key_value: _LOGGER.debug(categorize(event)) self.hass.bus.fire( KEYBOARD_REMOTE_COMMAND_RECEIVED, - {KEY_CODE: event.code} + { + KEY_CODE: event.code, + DEVICE_DESCRIPTOR: self.device_descriptor, + DEVICE_NAME: self.device_name + } ) -class KeyboardRemote(object): +class KeyboardRemote: """Sets up one thread per device.""" def __init__(self, hass, config): @@ -191,9 +176,8 @@ def __init__(self, hass, config): if device_descriptor is not None\ or device_name is not None: - thread = KeyboardRemoteThread(hass, device_name, - device_descriptor, - key_value) + thread = KeyboardRemoteThread( + hass, device_name, device_descriptor, key_value) self.threads.append(thread) def run(self): diff --git a/homeassistant/components/knx.py b/homeassistant/components/knx.py index 61f8ca90137f58..5b3af3029b4f4a 100644 --- a/homeassistant/components/knx.py +++ b/homeassistant/components/knx.py @@ -107,7 +107,7 @@ async def async_setup(hass, config): ('scene', 'Scene'), ('notify', 'Notification')): found_devices = _get_devices(hass, discovery_type) - hass.async_add_job( + hass.async_create_task( discovery.async_load_platform(hass, component, DOMAIN, { ATTR_DISCOVER_DEVICES: found_devices }, config)) @@ -129,7 +129,7 @@ def _get_devices(hass, discovery_type): hass.data[DATA_KNX].xknx.devices))) -class KNXModule(object): +class KNXModule: """Representation of KNX Object.""" def __init__(self, hass, config): @@ -172,7 +172,7 @@ def connection_config(self): """Return the connection_config.""" if CONF_KNX_TUNNELING in self.config[DOMAIN]: return self.connection_config_tunneling() - elif CONF_KNX_ROUTING in self.config[DOMAIN]: + if CONF_KNX_ROUTING in self.config[DOMAIN]: return self.connection_config_routing() return self.connection_config_auto() @@ -284,7 +284,7 @@ def __init__(self, hass, device, hook, action, counter=1): device.actions.append(self.action) -class KNXExposeTime(object): +class KNXExposeTime: """Object to Expose Time/Date object to KNX bus.""" def __init__(self, xknx, expose_type, address): @@ -308,7 +308,7 @@ def async_register(self): self.xknx.devices.add(self.device) -class KNXExposeSensor(object): +class KNXExposeSensor: """Object to Expose HASS entity to KNX bus.""" def __init__(self, hass, xknx, expose_type, entity_id, address): diff --git a/homeassistant/components/konnected.py b/homeassistant/components/konnected.py new file mode 100644 index 00000000000000..a3e9ff86ed012a --- /dev/null +++ b/homeassistant/components/konnected.py @@ -0,0 +1,337 @@ +""" +Support for Konnected devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/konnected/ +""" +import logging +import hmac +import json +import voluptuous as vol + +from aiohttp.hdrs import AUTHORIZATION +from aiohttp.web import Request, Response + +from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA +from homeassistant.components.discovery import SERVICE_KONNECTED +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import ( + HTTP_BAD_REQUEST, HTTP_INTERNAL_SERVER_ERROR, HTTP_UNAUTHORIZED, + CONF_DEVICES, CONF_BINARY_SENSORS, CONF_SWITCHES, CONF_HOST, CONF_PORT, + CONF_ID, CONF_NAME, CONF_TYPE, CONF_PIN, CONF_ZONE, CONF_ACCESS_TOKEN, + ATTR_ENTITY_ID, ATTR_STATE) +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers import discovery +from homeassistant.helpers import config_validation as cv + +_LOGGER = logging.getLogger(__name__) + +REQUIREMENTS = ['konnected==0.1.2'] + +DOMAIN = 'konnected' + +CONF_ACTIVATION = 'activation' +CONF_API_HOST = 'api_host' +STATE_LOW = 'low' +STATE_HIGH = 'high' + +PIN_TO_ZONE = {1: 1, 2: 2, 5: 3, 6: 4, 7: 5, 8: 'out', 9: 6} +ZONE_TO_PIN = {zone: pin for pin, zone in PIN_TO_ZONE.items()} + +_BINARY_SENSOR_SCHEMA = vol.All( + vol.Schema({ + vol.Exclusive(CONF_PIN, 's_pin'): vol.Any(*PIN_TO_ZONE), + vol.Exclusive(CONF_ZONE, 's_pin'): vol.Any(*ZONE_TO_PIN), + vol.Required(CONF_TYPE): DEVICE_CLASSES_SCHEMA, + vol.Optional(CONF_NAME): cv.string, + }), cv.has_at_least_one_key(CONF_PIN, CONF_ZONE) +) + +_SWITCH_SCHEMA = vol.All( + vol.Schema({ + vol.Exclusive(CONF_PIN, 'a_pin'): vol.Any(*PIN_TO_ZONE), + vol.Exclusive(CONF_ZONE, 'a_pin'): vol.Any(*ZONE_TO_PIN), + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_ACTIVATION, default=STATE_HIGH): + vol.All(vol.Lower, vol.Any(STATE_HIGH, STATE_LOW)) + }), cv.has_at_least_one_key(CONF_PIN, CONF_ZONE) +) + +# pylint: disable=no-value-for-parameter +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: vol.Schema({ + vol.Required(CONF_ACCESS_TOKEN): cv.string, + vol.Optional(CONF_API_HOST): vol.Url(), + vol.Required(CONF_DEVICES): [{ + vol.Required(CONF_ID): cv.string, + vol.Optional(CONF_BINARY_SENSORS): vol.All( + cv.ensure_list, [_BINARY_SENSOR_SCHEMA]), + vol.Optional(CONF_SWITCHES): vol.All( + cv.ensure_list, [_SWITCH_SCHEMA]), + }], + }), + }, + extra=vol.ALLOW_EXTRA, +) + +DEPENDENCIES = ['http', 'discovery'] + +ENDPOINT_ROOT = '/api/konnected' +UPDATE_ENDPOINT = (ENDPOINT_ROOT + r'/device/{device_id:[a-zA-Z0-9]+}') +SIGNAL_SENSOR_UPDATE = 'konnected.{}.update' + + +async def async_setup(hass, config): + """Set up the Konnected platform.""" + cfg = config.get(DOMAIN) + if cfg is None: + cfg = {} + + access_token = cfg.get(CONF_ACCESS_TOKEN) + if DOMAIN not in hass.data: + hass.data[DOMAIN] = { + CONF_ACCESS_TOKEN: access_token, + CONF_API_HOST: cfg.get(CONF_API_HOST) + } + + def device_discovered(service, info): + """Call when a Konnected device has been discovered.""" + _LOGGER.debug("Discovered a new Konnected device: %s", info) + host = info.get(CONF_HOST) + port = info.get(CONF_PORT) + + device = KonnectedDevice(hass, host, port, cfg) + device.setup() + + discovery.async_listen( + hass, + SERVICE_KONNECTED, + device_discovered) + + hass.http.register_view(KonnectedView(access_token)) + + return True + + +class KonnectedDevice: + """A representation of a single Konnected device.""" + + def __init__(self, hass, host, port, config): + """Initialize the Konnected device.""" + self.hass = hass + self.host = host + self.port = port + self.user_config = config + + import konnected + self.client = konnected.Client(host, str(port)) + self.status = self.client.get_status() + _LOGGER.info('Initialized Konnected device %s', self.device_id) + + def setup(self): + """Set up a newly discovered Konnected device.""" + user_config = self.config() + if user_config: + _LOGGER.debug('Configuring Konnected device %s', self.device_id) + self.save_data() + self.sync_device_config() + discovery.load_platform( + self.hass, 'binary_sensor', + DOMAIN, {'device_id': self.device_id}) + discovery.load_platform( + self.hass, 'switch', DOMAIN, + {'device_id': self.device_id}) + + @property + def device_id(self): + """Device id is the MAC address as string with punctuation removed.""" + return self.status['mac'].replace(':', '') + + def config(self): + """Return an object representing the user defined configuration.""" + device_id = self.device_id + valid_keys = [device_id, device_id.upper(), + device_id[6:], device_id.upper()[6:]] + configured_devices = self.user_config[CONF_DEVICES] + return next((device for device in + configured_devices if device[CONF_ID] in valid_keys), + None) + + def save_data(self): + """Save the device configuration to `hass.data`.""" + sensors = {} + for entity in self.config().get(CONF_BINARY_SENSORS) or []: + if CONF_ZONE in entity: + pin = ZONE_TO_PIN[entity[CONF_ZONE]] + else: + pin = entity[CONF_PIN] + + sensor_status = next((sensor for sensor in + self.status.get('sensors') if + sensor.get(CONF_PIN) == pin), {}) + if sensor_status.get(ATTR_STATE): + initial_state = bool(int(sensor_status.get(ATTR_STATE))) + else: + initial_state = None + + sensors[pin] = { + CONF_TYPE: entity[CONF_TYPE], + CONF_NAME: entity.get(CONF_NAME, 'Konnected {} Zone {}'.format( + self.device_id[6:], PIN_TO_ZONE[pin])), + ATTR_STATE: initial_state + } + _LOGGER.debug('Set up sensor %s (initial state: %s)', + sensors[pin].get('name'), + sensors[pin].get(ATTR_STATE)) + + actuators = {} + for entity in self.config().get(CONF_SWITCHES) or []: + if 'zone' in entity: + pin = ZONE_TO_PIN[entity['zone']] + else: + pin = entity['pin'] + + actuator_status = next((actuator for actuator in + self.status.get('actuators') if + actuator.get('pin') == pin), {}) + if actuator_status.get(ATTR_STATE): + initial_state = bool(int(actuator_status.get(ATTR_STATE))) + else: + initial_state = None + + actuators[pin] = { + CONF_NAME: entity.get( + CONF_NAME, 'Konnected {} Actuator {}'.format( + self.device_id[6:], PIN_TO_ZONE[pin])), + ATTR_STATE: initial_state, + CONF_ACTIVATION: entity[CONF_ACTIVATION], + } + _LOGGER.debug('Set up actuator %s (initial state: %s)', + actuators[pin].get(CONF_NAME), + actuators[pin].get(ATTR_STATE)) + + device_data = { + 'client': self.client, + CONF_BINARY_SENSORS: sensors, + CONF_SWITCHES: actuators, + CONF_HOST: self.host, + CONF_PORT: self.port, + } + + if CONF_DEVICES not in self.hass.data[DOMAIN]: + self.hass.data[DOMAIN][CONF_DEVICES] = {} + + _LOGGER.debug('Storing data in hass.data[konnected]: %s', device_data) + self.hass.data[DOMAIN][CONF_DEVICES][self.device_id] = device_data + + @property + def stored_configuration(self): + """Return the configuration stored in `hass.data` for this device.""" + return self.hass.data[DOMAIN][CONF_DEVICES][self.device_id] + + def sensor_configuration(self): + """Return the configuration map for syncing sensors.""" + return [{'pin': p} for p in + self.stored_configuration[CONF_BINARY_SENSORS]] + + def actuator_configuration(self): + """Return the configuration map for syncing actuators.""" + return [{'pin': p, + 'trigger': (0 if data.get(CONF_ACTIVATION) in [0, STATE_LOW] + else 1)} + for p, data in + self.stored_configuration[CONF_SWITCHES].items()] + + def sync_device_config(self): + """Sync the new pin configuration to the Konnected device.""" + desired_sensor_configuration = self.sensor_configuration() + current_sensor_configuration = [ + {'pin': s[CONF_PIN]} for s in self.status.get('sensors')] + _LOGGER.debug('%s: desired sensor config: %s', self.device_id, + desired_sensor_configuration) + _LOGGER.debug('%s: current sensor config: %s', self.device_id, + current_sensor_configuration) + + desired_actuator_config = self.actuator_configuration() + current_actuator_config = self.status.get('actuators') + _LOGGER.debug('%s: desired actuator config: %s', self.device_id, + desired_actuator_config) + _LOGGER.debug('%s: current actuator config: %s', self.device_id, + current_actuator_config) + + desired_api_host = \ + self.hass.data[DOMAIN].get(CONF_API_HOST) or \ + self.hass.config.api.base_url + desired_api_endpoint = desired_api_host + ENDPOINT_ROOT + current_api_endpoint = self.status.get('endpoint') + + _LOGGER.debug('%s: desired api endpoint: %s', self.device_id, + desired_api_endpoint) + _LOGGER.debug('%s: current api endpoint: %s', self.device_id, + current_api_endpoint) + + if (desired_sensor_configuration != current_sensor_configuration) or \ + (current_actuator_config != desired_actuator_config) or \ + (current_api_endpoint != desired_api_endpoint): + _LOGGER.debug('pushing settings to device %s', self.device_id) + self.client.put_settings( + desired_sensor_configuration, + desired_actuator_config, + self.hass.data[DOMAIN].get(CONF_ACCESS_TOKEN), + desired_api_endpoint + ) + + +class KonnectedView(HomeAssistantView): + """View creates an endpoint to receive push updates from the device.""" + + url = UPDATE_ENDPOINT + extra_urls = [UPDATE_ENDPOINT + '/{pin_num}/{state}'] + name = 'api:konnected' + requires_auth = False # Uses access token from configuration + + def __init__(self, auth_token): + """Initialize the view.""" + self.auth_token = auth_token + + async def put(self, request: Request, device_id, + pin_num=None, state=None) -> Response: + """Receive a sensor update via PUT request and async set state.""" + hass = request.app['hass'] + data = hass.data[DOMAIN] + + try: # Konnected 2.2.0 and above supports JSON payloads + payload = await request.json() + pin_num = payload['pin'] + state = payload['state'] + except json.decoder.JSONDecodeError: + _LOGGER.warning(("Your Konnected device software may be out of " + "date. Visit https://help.konnected.io for " + "updating instructions.")) + + auth = request.headers.get(AUTHORIZATION, None) + if not hmac.compare_digest('Bearer {}'.format(self.auth_token), auth): + return self.json_message( + "unauthorized", status_code=HTTP_UNAUTHORIZED) + pin_num = int(pin_num) + state = bool(int(state)) + device = data[CONF_DEVICES].get(device_id) + if device is None: + return self.json_message('unregistered device', + status_code=HTTP_BAD_REQUEST) + pin_data = device[CONF_BINARY_SENSORS].get(pin_num) or \ + device[CONF_SWITCHES].get(pin_num) + + if pin_data is None: + return self.json_message('unregistered sensor/actuator', + status_code=HTTP_BAD_REQUEST) + + entity_id = pin_data.get(ATTR_ENTITY_ID) + if entity_id is None: + return self.json_message('uninitialized sensor/actuator', + status_code=HTTP_INTERNAL_SERVER_ERROR) + + async_dispatcher_send( + hass, SIGNAL_SENSOR_UPDATE.format(entity_id), state) + return self.json_message('ok') diff --git a/homeassistant/components/lametric.py b/homeassistant/components/lametric.py index 49b4f73ea17e3f..96ea3781566cd1 100644 --- a/homeassistant/components/lametric.py +++ b/homeassistant/components/lametric.py @@ -31,7 +31,6 @@ }, extra=vol.ALLOW_EXTRA) -# pylint: disable=broad-except def setup(hass, config): """Set up the LaMetricManager.""" _LOGGER.debug("Setting up LaMetric platform") diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 30a1a800a44988..8b4b213771160c 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -83,17 +83,6 @@ LIGHT_PROFILES_FILE = "light_profiles.csv" -PROP_TO_ATTR = { - 'brightness': ATTR_BRIGHTNESS, - 'color_temp': ATTR_COLOR_TEMP, - 'min_mireds': ATTR_MIN_MIREDS, - 'max_mireds': ATTR_MAX_MIREDS, - 'hs_color': ATTR_HS_COLOR, - 'white_value': ATTR_WHITE_VALUE, - 'effect_list': ATTR_EFFECT_LIST, - 'effect': ATTR_EFFECT, -} - # Service call validation schemas VALID_TRANSITION = vol.All(vol.Coerce(float), vol.Clamp(min=0, max=6553)) VALID_BRIGHTNESS = vol.All(vol.Coerce(int), vol.Clamp(min=0, max=255)) @@ -357,7 +346,12 @@ async def async_handle_light_service(service): update_tasks = [] for light in target_lights: if service.service == SERVICE_TURN_ON: - await light.async_turn_on(**params) + pars = params + if not pars: + pars = params.copy() + pars[ATTR_PROFILE] = Profiles.get_default(light.entity_id) + preprocess_turn_on_alternatives(pars) + await light.async_turn_on(**pars) elif service.service == SERVICE_TURN_OFF: await light.async_turn_off(**params) else: @@ -365,7 +359,9 @@ async def async_handle_light_service(service): if not light.should_poll: continue - update_tasks.append(light.async_update_ha_state(True)) + + update_tasks.append( + light.async_update_ha_state(True, service.context)) if update_tasks: await asyncio.wait(update_tasks, loop=hass.loop) @@ -442,12 +438,22 @@ def get(cls, name): """Return a named profile.""" return cls._all.get(name) + @classmethod + def get_default(cls, entity_id): + """Return the default turn-on profile for the given light.""" + # pylint: disable=unsupported-membership-test + name = entity_id + ".default" + if name in cls._all: + return name + name = ENTITY_ID_ALL_LIGHTS + ".default" + if name in cls._all: + return name + return None + class Light(ToggleEntity): """Representation of a light.""" - # pylint: disable=no-self-use - @property def brightness(self): """Return the brightness of this light between 0..255.""" @@ -496,29 +502,37 @@ def effect(self): def state_attributes(self): """Return optional state attributes.""" data = {} + supported_features = self.supported_features - if self.supported_features & SUPPORT_COLOR_TEMP: + if supported_features & SUPPORT_COLOR_TEMP: data[ATTR_MIN_MIREDS] = self.min_mireds data[ATTR_MAX_MIREDS] = self.max_mireds if self.is_on: - for prop, attr in PROP_TO_ATTR.items(): - value = getattr(self, prop) - if value is not None: - data[attr] = value - - # Expose current color also as RGB and XY - if ATTR_HS_COLOR in data: - data[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB( - *data[ATTR_HS_COLOR]) - data[ATTR_XY_COLOR] = color_util.color_hs_to_xy( - *data[ATTR_HS_COLOR]) + if supported_features & SUPPORT_BRIGHTNESS: + data[ATTR_BRIGHTNESS] = self.brightness + + if supported_features & SUPPORT_COLOR_TEMP: + data[ATTR_COLOR_TEMP] = self.color_temp + + if self.supported_features & SUPPORT_COLOR and self.hs_color: + # pylint: disable=unsubscriptable-object,not-an-iterable + hs_color = self.hs_color data[ATTR_HS_COLOR] = ( - round(data[ATTR_HS_COLOR][0], 3), - round(data[ATTR_HS_COLOR][1], 3), + round(hs_color[0], 3), + round(hs_color[1], 3), ) + data[ATTR_RGB_COLOR] = color_util.color_hs_to_RGB(*hs_color) + data[ATTR_XY_COLOR] = color_util.color_hs_to_xy(*hs_color) + + if supported_features & SUPPORT_WHITE_VALUE: + data[ATTR_WHITE_VALUE] = self.white_value + + if supported_features & SUPPORT_EFFECT: + data[ATTR_EFFECT_LIST] = self.effect_list + data[ATTR_EFFECT] = self.effect - return data + return {key: val for key, val in data.items() if val is not None} @property def supported_features(self): diff --git a/homeassistant/components/light/abode.py b/homeassistant/components/light/abode.py index 8b7e09d86bcebd..431f5d12ff0d56 100644 --- a/homeassistant/components/light/abode.py +++ b/homeassistant/components/light/abode.py @@ -88,7 +88,7 @@ def supported_features(self): """Flag supported features.""" if self._device.is_dimmable and self._device.has_color: return SUPPORT_BRIGHTNESS | SUPPORT_COLOR - elif self._device.is_dimmable: + if self._device.is_dimmable: return SUPPORT_BRIGHTNESS return 0 diff --git a/homeassistant/components/light/avion.py b/homeassistant/components/light/avion.py index b4b9f4e777567c..be608ea477668b 100644 --- a/homeassistant/components/light/avion.py +++ b/homeassistant/components/light/avion.py @@ -37,7 +37,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up an Avion switch.""" - # pylint: disable=import-error, no-member + # pylint: disable=no-member import avion lights = [] @@ -70,7 +70,7 @@ class AvionLight(Light): def __init__(self, device): """Initialize the light.""" - # pylint: disable=import-error, no-member + # pylint: disable=no-member import avion self._name = device['name'] @@ -117,7 +117,7 @@ def assumed_state(self): def set_state(self, brightness): """Set the state of this lamp to the provided brightness.""" - # pylint: disable=import-error, no-member + # pylint: disable=no-member import avion # Bluetooth LE is unreliable, and the connection may drop at any diff --git a/homeassistant/components/light/blinksticklight.py b/homeassistant/components/light/blinksticklight.py index 18a6b4ae266d99..bca587074b01c4 100644 --- a/homeassistant/components/light/blinksticklight.py +++ b/homeassistant/components/light/blinksticklight.py @@ -31,7 +31,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up Blinkstick device specified by serial number.""" from blinkstick import blinkstick diff --git a/homeassistant/components/light/blinkt.py b/homeassistant/components/light/blinkt.py index 97edd7c54d254e..7035320945a0ef 100644 --- a/homeassistant/components/light/blinkt.py +++ b/homeassistant/components/light/blinkt.py @@ -30,7 +30,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Blinkt Light platform.""" - # pylint: disable=import-error, no-member + # pylint: disable=no-member import blinkt # ensure that the lights are off when exiting diff --git a/homeassistant/components/light/deconz.py b/homeassistant/components/light/deconz.py index 916e60c00b1b9c..20160edf8066f3 100644 --- a/homeassistant/components/light/deconz.py +++ b/homeassistant/components/light/deconz.py @@ -4,8 +4,9 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/light.deconz/ """ -from homeassistant.components.deconz import ( - DOMAIN as DATA_DECONZ, DATA_DECONZ_ID, DATA_DECONZ_UNSUB) +from homeassistant.components.deconz.const import ( + CONF_ALLOW_DECONZ_GROUPS, DOMAIN as DATA_DECONZ, + DATA_DECONZ_ID, DATA_DECONZ_UNSUB, SWITCH_TYPES) from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, ATTR_TRANSITION, EFFECT_COLORLOOP, FLASH_LONG, FLASH_SHORT, @@ -31,8 +32,10 @@ def async_add_light(lights): """Add light from deCONZ.""" entities = [] for light in lights: - entities.append(DeconzLight(light)) + if light.type not in SWITCH_TYPES: + entities.append(DeconzLight(light)) async_add_devices(entities, True) + hass.data[DATA_DECONZ_UNSUB].append( async_dispatcher_connect(hass, 'deconz_new_light', async_add_light)) @@ -40,10 +43,12 @@ def async_add_light(lights): def async_add_group(groups): """Add group from deCONZ.""" entities = [] + allow_group = config_entry.data.get(CONF_ALLOW_DECONZ_GROUPS, True) for group in groups: - if group.lights: + if group.lights and allow_group: entities.append(DeconzLight(group)) async_add_devices(entities, True) + hass.data[DATA_DECONZ_UNSUB].append( async_dispatcher_connect(hass, 'deconz_new_group', async_add_group)) @@ -94,12 +99,17 @@ def effect_list(self): @property def color_temp(self): """Return the CT color value.""" + if self._light.colormode != 'ct': + return None + return self._light.ct @property - def xy_color(self): - """Return the XY color value.""" - return self._light.xy + def hs_color(self): + """Return the hs color value.""" + if self._light.colormode in ('xy', 'hs') and self._light.xy: + return color_util.color_xy_to_hs(*self._light.xy) + return None @property def is_on(self): @@ -168,7 +178,7 @@ async def async_turn_off(self, **kwargs): data = {'on': False} if ATTR_TRANSITION in kwargs: - data = {'bri': 0} + data['bri'] = 0 data['transitiontime'] = int(kwargs[ATTR_TRANSITION]) * 10 if ATTR_FLASH in kwargs: @@ -180,3 +190,12 @@ async def async_turn_off(self, **kwargs): del data['on'] await self._light.async_set_state(data) + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + attributes = {} + attributes['is_deconz_group'] = self._light.type == 'LightGroup' + if self._light.type == 'LightGroup': + attributes['all_on'] = self._light.all_on + return attributes diff --git a/homeassistant/components/light/decora.py b/homeassistant/components/light/decora.py index c7478b435ee3e0..85d9180c59bcae 100644 --- a/homeassistant/components/light/decora.py +++ b/homeassistant/components/light/decora.py @@ -75,7 +75,7 @@ class DecoraLight(Light): def __init__(self, device): """Initialize the light.""" - # pylint: disable=import-error, no-member + # pylint: disable=no-member import decora self._name = device['name'] diff --git a/homeassistant/components/light/decora_wifi.py b/homeassistant/components/light/decora_wifi.py index 111d39f20190ac..17003d51610c13 100644 --- a/homeassistant/components/light/decora_wifi.py +++ b/homeassistant/components/light/decora_wifi.py @@ -36,7 +36,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Decora WiFi platform.""" - # pylint: disable=import-error, no-member, no-name-in-module + # pylint: disable=import-error, no-name-in-module from decora_wifi import DecoraWiFiSession from decora_wifi.models.person import Person from decora_wifi.models.residential_account import ResidentialAccount diff --git a/homeassistant/components/light/eufy.py b/homeassistant/components/light/eufy.py index 6f0a8816eea173..2e7370cb336f1b 100644 --- a/homeassistant/components/light/eufy.py +++ b/homeassistant/components/light/eufy.py @@ -36,7 +36,6 @@ class EufyLight(Light): def __init__(self, device): """Initialize the light.""" - # pylint: disable=import-error import lakeside self._temp = None diff --git a/homeassistant/components/light/flux_led.py b/homeassistant/components/light/flux_led.py index 6c7f2e98e37dce..2b53fb650540df 100644 --- a/homeassistant/components/light/flux_led.py +++ b/homeassistant/components/light/flux_led.py @@ -33,6 +33,10 @@ MODE_RGB = 'rgb' MODE_RGBW = 'rgbw' +# This mode enables white value to be controlled by brightness. +# RGB value is ignored when this mode is specified. +MODE_WHITE = 'w' + # List of supported effects which aren't already declared in LIGHT EFFECT_RED_FADE = 'red_fade' EFFECT_GREEN_FADE = 'green_fade' @@ -84,7 +88,7 @@ DEVICE_SCHEMA = vol.Schema({ vol.Optional(CONF_NAME): cv.string, vol.Optional(ATTR_MODE, default=MODE_RGBW): - vol.All(cv.string, vol.In([MODE_RGBW, MODE_RGB])), + vol.All(cv.string, vol.In([MODE_RGBW, MODE_RGB, MODE_WHITE])), vol.Optional(CONF_PROTOCOL): vol.All(cv.string, vol.In(['ledenet'])), }) @@ -181,6 +185,9 @@ def is_on(self): @property def brightness(self): """Return the brightness of this light between 0..255.""" + if self._mode == MODE_WHITE: + return self.white_value + return self._bulb.brightness @property @@ -191,9 +198,12 @@ def hs_color(self): @property def supported_features(self): """Flag supported features.""" - if self._mode is MODE_RGBW: + if self._mode == MODE_RGBW: return SUPPORT_FLUX_LED | SUPPORT_WHITE_VALUE + if self._mode == MODE_WHITE: + return SUPPORT_BRIGHTNESS + return SUPPORT_FLUX_LED @property @@ -222,28 +232,45 @@ def turn_on(self, **kwargs): effect = kwargs.get(ATTR_EFFECT) white = kwargs.get(ATTR_WHITE_VALUE) - # color change only - if rgb is not None: - self._bulb.setRgb(*tuple(rgb), brightness=self.brightness) + # Show warning if effect set with rgb, brightness, or white level + if effect and (brightness or white or rgb): + _LOGGER.warning("RGB, brightness and white level are ignored when" + " an effect is specified for a flux bulb") - # brightness change only - elif brightness is not None: - (red, green, blue) = self._bulb.getRgb() - self._bulb.setRgb(red, green, blue, brightness=brightness) - - # random color effect - elif effect == EFFECT_RANDOM: + # Random color effect + if effect == EFFECT_RANDOM: self._bulb.setRgb(random.randint(0, 255), random.randint(0, 255), random.randint(0, 255)) + return - # effect selection - elif effect in EFFECT_MAP: + # Effect selection + if effect in EFFECT_MAP: self._bulb.setPresetPattern(EFFECT_MAP[effect], 50) + return + + # Preserve current brightness on color/white level change + if brightness is None: + brightness = self.brightness - # white change only - elif white is not None: - self._bulb.setWarmWhite255(white) + # Preserve color on brightness/white level change + if rgb is None: + rgb = self._bulb.getRgb() + + if white is None and self._mode == MODE_RGBW: + white = self.white_value + + # handle W only mode (use brightness instead of white value) + if self._mode == MODE_WHITE: + self._bulb.setRgbw(0, 0, 0, w=brightness) + + # handle RGBW mode + elif self._mode == MODE_RGBW: + self._bulb.setRgbw(*tuple(rgb), w=white, brightness=brightness) + + # handle RGB mode + else: + self._bulb.setRgb(*tuple(rgb), brightness=brightness) def turn_off(self, **kwargs): """Turn the specified or all lights off.""" diff --git a/homeassistant/components/light/futurenow.py b/homeassistant/components/light/futurenow.py new file mode 100644 index 00000000000000..1777376881e331 --- /dev/null +++ b/homeassistant/components/light/futurenow.py @@ -0,0 +1,130 @@ +""" +Support for FutureNow Ethernet unit outputs as Lights. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.futurenow/ +""" + +import logging + +import voluptuous as vol + +from homeassistant.const import ( + CONF_NAME, CONF_HOST, CONF_PORT, CONF_DEVICES) +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light, + PLATFORM_SCHEMA) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pyfnip==0.2'] + +_LOGGER = logging.getLogger(__name__) + +CONF_DRIVER = 'driver' +CONF_DRIVER_FNIP6X10AD = 'FNIP6x10ad' +CONF_DRIVER_FNIP8X10A = 'FNIP8x10a' +CONF_DRIVER_TYPES = [CONF_DRIVER_FNIP6X10AD, CONF_DRIVER_FNIP8X10A] + +DEVICE_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Optional('dimmable', default=False): cv.boolean, +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_DRIVER): vol.In(CONF_DRIVER_TYPES), + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PORT): cv.port, + vol.Required(CONF_DEVICES): {cv.string: DEVICE_SCHEMA}, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the light platform for each FutureNow unit.""" + lights = [] + for channel, device_config in config[CONF_DEVICES].items(): + device = {} + device['name'] = device_config[CONF_NAME] + device['dimmable'] = device_config['dimmable'] + device['channel'] = channel + device['driver'] = config[CONF_DRIVER] + device['host'] = config[CONF_HOST] + device['port'] = config[CONF_PORT] + lights.append(FutureNowLight(device)) + + add_devices(lights, True) + + +def to_futurenow_level(level): + """Convert the given HASS light level (0-255) to FutureNow (0-100).""" + return int((level * 100) / 255) + + +def to_hass_level(level): + """Convert the given FutureNow (0-100) light level to HASS (0-255).""" + return int((level * 255) / 100) + + +class FutureNowLight(Light): + """Representation of an FutureNow light.""" + + def __init__(self, device): + """Initialize the light.""" + import pyfnip + + self._name = device['name'] + self._dimmable = device['dimmable'] + self._channel = device['channel'] + self._brightness = None + self._last_brightness = 255 + self._state = None + + if device['driver'] == CONF_DRIVER_FNIP6X10AD: + self._light = pyfnip.FNIP6x2adOutput(device['host'], + device['port'], + self._channel) + if device['driver'] == CONF_DRIVER_FNIP8X10A: + self._light = pyfnip.FNIP8x10aOutput(device['host'], + device['port'], + self._channel) + + @property + def name(self): + """Return the name of the device if any.""" + return self._name + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return self._brightness + + @property + def supported_features(self): + """Flag supported features.""" + if self._dimmable: + return SUPPORT_BRIGHTNESS + return 0 + + def turn_on(self, **kwargs): + """Turn the light on.""" + if self._dimmable: + level = kwargs.get(ATTR_BRIGHTNESS, self._last_brightness) + else: + level = 255 + self._light.turn_on(to_futurenow_level(level)) + + def turn_off(self, **kwargs): + """Turn the light off.""" + self._light.turn_off() + if self._brightness: + self._last_brightness = self._brightness + + def update(self): + """Fetch new state data for this light.""" + state = int(self._light.is_on()) + self._state = bool(state) + self._brightness = to_hass_level(state) diff --git a/homeassistant/components/light/greenwave.py b/homeassistant/components/light/greenwave.py index 8e9d93657cef2f..52a70532005dcf 100644 --- a/homeassistant/components/light/greenwave.py +++ b/homeassistant/components/light/greenwave.py @@ -121,7 +121,7 @@ def update(self): self._name = bulbs[self._did]['name'] -class GatewayData(object): +class GatewayData: """Handle Gateway data and limit updates.""" def __init__(self, host, token): diff --git a/homeassistant/components/light/group.py b/homeassistant/components/light/group.py index f9ffbb4e0bf72f..b2fdd36abe7edc 100644 --- a/homeassistant/components/light/group.py +++ b/homeassistant/components/light/group.py @@ -254,8 +254,6 @@ def _mean_tuple(*args): return tuple(sum(l) / len(l) for l in zip(*args)) -# https://github.com/PyCQA/pylint/issues/1831 -# pylint: disable=bad-whitespace def _reduce_attribute(states: List[State], key: str, default: Optional[Any] = None, diff --git a/homeassistant/components/light/homekit_controller.py b/homeassistant/components/light/homekit_controller.py index e6dc09e455cb27..8d77cb0523668e 100644 --- a/homeassistant/components/light/homekit_controller.py +++ b/homeassistant/components/light/homekit_controller.py @@ -4,7 +4,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.homekit_controller/ """ -import json import logging from homeassistant.components.homekit_controller import ( @@ -122,13 +121,11 @@ def turn_on(self, **kwargs): characteristics.append({'aid': self._aid, 'iid': self._chars['on'], 'value': True}) - body = json.dumps({'characteristics': characteristics}) - self._securecon.put('/characteristics', body) + self.put_characteristics(characteristics) def turn_off(self, **kwargs): """Turn the specified light off.""" characteristics = [{'aid': self._aid, 'iid': self._chars['on'], 'value': False}] - body = json.dumps({'characteristics': characteristics}) - self._securecon.put('/characteristics', body) + self.put_characteristics(characteristics) diff --git a/homeassistant/components/light/homematicip_cloud.py b/homeassistant/components/light/homematicip_cloud.py new file mode 100644 index 00000000000000..617a7209a865fc --- /dev/null +++ b/homeassistant/components/light/homematicip_cloud.py @@ -0,0 +1,119 @@ +""" +Support for HomematicIP light. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/light.homematicip_cloud/ +""" + +import logging + +from homeassistant.components.light import ( + Light, ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS) +from homeassistant.components.homematicip_cloud import ( + HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN, + HMIPC_HAPID) + +DEPENDENCIES = ['homematicip_cloud'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_POWER_CONSUMPTION = 'power_consumption' +ATTR_ENERGIE_COUNTER = 'energie_counter_kwh' +ATTR_PROFILE_MODE = 'profile_mode' + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Old way of setting up HomematicIP lights.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the HomematicIP lights from a config entry.""" + from homematicip.aio.device import ( + AsyncBrandSwitchMeasuring, AsyncDimmer) + + home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home + devices = [] + for device in home.devices: + if isinstance(device, AsyncBrandSwitchMeasuring): + devices.append(HomematicipLightMeasuring(home, device)) + elif isinstance(device, AsyncDimmer): + devices.append(HomematicipDimmer(home, device)) + + if devices: + async_add_devices(devices) + + +class HomematicipLight(HomematicipGenericDevice, Light): + """MomematicIP light device.""" + + def __init__(self, home, device): + """Initialize the light device.""" + super().__init__(home, device) + + @property + def is_on(self): + """Return true if device is on.""" + return self._device.on + + async def async_turn_on(self, **kwargs): + """Turn the device on.""" + await self._device.turn_on() + + async def async_turn_off(self, **kwargs): + """Turn the device off.""" + await self._device.turn_off() + + +class HomematicipLightMeasuring(HomematicipLight): + """MomematicIP measuring light device.""" + + @property + def device_state_attributes(self): + """Return the state attributes of the generic device.""" + attr = super().device_state_attributes + if self._device.currentPowerConsumption > 0.05: + attr.update({ + ATTR_POWER_CONSUMPTION: + round(self._device.currentPowerConsumption, 2) + }) + attr.update({ + ATTR_ENERGIE_COUNTER: round(self._device.energyCounter, 2) + }) + return attr + + +class HomematicipDimmer(HomematicipGenericDevice, Light): + """MomematicIP dimmer light device.""" + + def __init__(self, home, device): + """Initialize the dimmer light device.""" + super().__init__(home, device) + + @property + def is_on(self): + """Return true if device is on.""" + return self._device.dimLevel != 0 + + @property + def brightness(self): + """Return the brightness of this light between 0..255.""" + return int(self._device.dimLevel*255) + + @property + def supported_features(self): + """Flag supported features.""" + return SUPPORT_BRIGHTNESS + + async def async_turn_on(self, **kwargs): + """Turn the light on.""" + if ATTR_BRIGHTNESS in kwargs: + await self._device.set_dim_level( + kwargs[ATTR_BRIGHTNESS]/255.0) + else: + await self._device.set_dim_level(1) + + async def async_turn_off(self, **kwargs): + """Turn the light off.""" + await self._device.set_dim_level(0) diff --git a/homeassistant/components/light/hue.py b/homeassistant/components/light/hue.py index 837a6f82510e53..0da59b6f100c39 100644 --- a/homeassistant/components/light/hue.py +++ b/homeassistant/components/light/hue.py @@ -11,7 +11,7 @@ import async_timeout -import homeassistant.components.hue as hue +from homeassistant.components import hue from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_TRANSITION, ATTR_HS_COLOR, EFFECT_COLORLOOP, EFFECT_RANDOM, diff --git a/homeassistant/components/light/hyperion.py b/homeassistant/components/light/hyperion.py index 8ba2329af7e0bd..cbac8cf4e201ea 100644 --- a/homeassistant/components/light/hyperion.py +++ b/homeassistant/components/light/hyperion.py @@ -146,10 +146,7 @@ def turn_on(self, **kwargs): else: rgb_color = self._rgb_mem - if ATTR_BRIGHTNESS in kwargs: - brightness = kwargs[ATTR_BRIGHTNESS] - else: - brightness = self._brightness + brightness = kwargs.get(ATTR_BRIGHTNESS, self._brightness) if ATTR_EFFECT in kwargs: self._skip_update = True diff --git a/homeassistant/components/light/ihc.py b/homeassistant/components/light/ihc.py index c9ceda8651ac46..5a7e85d50dc5b8 100644 --- a/homeassistant/components/light/ihc.py +++ b/homeassistant/components/light/ihc.py @@ -3,8 +3,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.ihc/ """ -from xml.etree.ElementTree import Element - import voluptuous as vol from homeassistant.components.ihc import ( @@ -64,7 +62,7 @@ class IhcLight(IHCDevice, Light): """ def __init__(self, ihc_controller, name, ihc_id: int, info: bool, - dimmable=False, product: Element = None) -> None: + dimmable=False, product=None) -> None: """Initialize the light.""" super().__init__(ihc_controller, name, ihc_id, info, product) self._brightness = 0 diff --git a/homeassistant/components/light/insteon_local.py b/homeassistant/components/light/insteon_local.py index bd7814df8f3ec7..e2bc54de517632 100644 --- a/homeassistant/components/light/insteon_local.py +++ b/homeassistant/components/light/insteon_local.py @@ -9,7 +9,7 @@ from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) -import homeassistant.util as util +from homeassistant import util _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/light/isy994.py b/homeassistant/components/light/isy994.py index d2ed865892e6f5..ce358d0a974e5e 100644 --- a/homeassistant/components/light/isy994.py +++ b/homeassistant/components/light/isy994.py @@ -15,7 +15,6 @@ _LOGGER = logging.getLogger(__name__) -# pylint: disable=unused-argument def setup_platform(hass, config: ConfigType, add_devices: Callable[[list], None], discovery_info=None): """Set up the ISY994 light platform.""" diff --git a/homeassistant/components/light/knx.py b/homeassistant/components/light/knx.py index 18446951735bf2..8fa2b56d1d2d1e 100644 --- a/homeassistant/components/light/knx.py +++ b/homeassistant/components/light/knx.py @@ -88,7 +88,6 @@ def async_register_callbacks(self): """Register callbacks to update hass after device was changed.""" async def after_update_callback(device): """Call after device was updated.""" - # pylint: disable=unused-argument await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) diff --git a/homeassistant/components/light/lifx.py b/homeassistant/components/light/lifx.py index dff5ccd42acff2..3738fd8f00413b 100644 --- a/homeassistant/components/light/lifx.py +++ b/homeassistant/components/light/lifx.py @@ -30,7 +30,7 @@ _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['aiolifx==0.6.1', 'aiolifx_effects==0.1.2'] +REQUIREMENTS = ['aiolifx==0.6.3', 'aiolifx_effects==0.1.2'] UDP_BROADCAST_PORT = 56700 @@ -201,10 +201,10 @@ def merge_hsbk(base, change): """Copy change on top of base, except when None.""" if change is None: return None - return list(map(lambda x, y: y if y is not None else x, base, change)) + return [b if c is None else c for b, c in zip(base, change)] -class LIFXManager(object): +class LIFXManager: """Representation of all known LIFX entities.""" def __init__(self, hass, async_add_devices): @@ -256,7 +256,7 @@ async def service_handler(service): async def start_effect(self, entities, service, **kwargs): """Start a light effect on entities.""" - devices = list(map(lambda l: l.device, entities)) + devices = [light.device for light in entities] if service == SERVICE_EFFECT_PULSE: effect = aiolifx_effects().EffectPulse( @@ -314,12 +314,13 @@ async def register_new_device(self, device): # Read initial state ack = AwaitAioLIFX().wait - version_resp = await ack(device.get_version) - if version_resp: - color_resp = await ack(device.get_color) + color_resp = await ack(device.get_color) + if color_resp: + version_resp = await ack(device.get_version) - if version_resp is None or color_resp is None: + if color_resp is None or version_resp is None: _LOGGER.error("Failed to initialize %s", device.ip_addr) + device.registered = False else: device.timeout = MESSAGE_TIMEOUT device.retry_count = MESSAGE_RETRIES @@ -440,18 +441,15 @@ def supported_features(self): @property def brightness(self): """Return the brightness of this light between 0..255.""" - brightness = convert_16_to_8(self.device.color[2]) - _LOGGER.debug("brightness: %d", brightness) - return brightness + return convert_16_to_8(self.device.color[2]) @property def color_temp(self): """Return the color temperature.""" - kelvin = self.device.color[3] - temperature = color_util.color_temperature_kelvin_to_mired(kelvin) - - _LOGGER.debug("color_temp: %d", temperature) - return temperature + _, sat, _, kelvin = self.device.color + if sat: + return None + return color_util.color_temperature_kelvin_to_mired(kelvin) @property def is_on(self): @@ -564,7 +562,6 @@ async def default_effect(self, **kwargs): async def async_update(self): """Update bulb status.""" - _LOGGER.debug("%s async_update", self.who) if self.available and not self.lock.locked(): await AwaitAioLIFX().wait(self.device.get_color) @@ -606,7 +603,7 @@ def hs_color(self): hue, sat, _, _ = self.device.color hue = hue / 65535 * 360 sat = sat / 65535 * 100 - return (hue, sat) + return (hue, sat) if sat else None class LIFXStrip(LIFXColor): @@ -627,7 +624,7 @@ async def set_color(self, ack, hsbk, kwargs, duration=0): zones = list(range(0, num_zones)) else: - zones = list(filter(lambda x: x < num_zones, set(zones))) + zones = [x for x in set(zones) if x < num_zones] # Zone brightness is not reported when powered off if not self.is_on and hsbk[2] is None: diff --git a/homeassistant/components/light/lifx_legacy.py b/homeassistant/components/light/lifx_legacy.py index 490eeb6ecaba17..3ad75a1cea4c57 100644 --- a/homeassistant/components/light/lifx_legacy.py +++ b/homeassistant/components/light/lifx_legacy.py @@ -45,7 +45,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the LIFX platform.""" server_addr = config.get(CONF_SERVER) @@ -59,7 +58,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): lifx_library.probe() -class LIFX(object): +class LIFX: """Representation of a LIFX light.""" def __init__(self, add_devices_callback, server_addr=None, @@ -118,7 +117,6 @@ def on_power(self, ipaddr, power): bulb.set_power(power) bulb.schedule_update_ha_state() - # pylint: disable=unused-argument def poll(self, now): """Set up polling for the light.""" self.probe() diff --git a/homeassistant/components/light/limitlessled.py b/homeassistant/components/light/limitlessled.py index bb84b3a6fed023..2263a865758060 100644 --- a/homeassistant/components/light/limitlessled.py +++ b/homeassistant/components/light/limitlessled.py @@ -21,7 +21,7 @@ color_temperature_mired_to_kelvin, color_hs_to_RGB) from homeassistant.helpers.restore_state import async_get_last_state -REQUIREMENTS = ['limitlessled==1.1.0'] +REQUIREMENTS = ['limitlessled==1.1.2'] _LOGGER = logging.getLogger(__name__) @@ -46,7 +46,7 @@ WHITE = [0, 0] SUPPORT_LIMITLESSLED_WHITE = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | - SUPPORT_TRANSITION) + SUPPORT_EFFECT | SUPPORT_TRANSITION) SUPPORT_LIMITLESSLED_DIMMER = (SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION) SUPPORT_LIMITLESSLED_RGB = (SUPPORT_BRIGHTNESS | SUPPORT_EFFECT | SUPPORT_FLASH | SUPPORT_COLOR | @@ -136,16 +136,15 @@ def state(new_state): """ def decorator(function): """Set up the decorator function.""" - # pylint: disable=no-member,protected-access + # pylint: disable=protected-access def wrapper(self, **kwargs): """Wrap a group state change.""" from limitlessled.pipeline import Pipeline pipeline = Pipeline() transition_time = DEFAULT_TRANSITION - # Stop any repeating pipeline. - if self.repeating: - self.repeating = False + if self._effect == EFFECT_COLORLOOP: self.group.stop() + self._effect = None # Set transition time. if ATTR_TRANSITION in kwargs: transition_time = int(kwargs[ATTR_TRANSITION]) @@ -183,11 +182,11 @@ def __init__(self, group, config): self.group = group self.config = config - self.repeating = False self._is_on = False self._brightness = None self._temperature = None self._color = None + self._effect = None @asyncio.coroutine def async_added_to_hass(self): @@ -222,6 +221,9 @@ def is_on(self): @property def brightness(self): """Return the brightness property.""" + if self._effect == EFFECT_NIGHT: + return 1 + return self._brightness @property @@ -237,11 +239,19 @@ def max_mireds(self): @property def color_temp(self): """Return the temperature property.""" + if self.hs_color is not None: + return None return self._temperature @property def hs_color(self): """Return the color property.""" + if self._effect == EFFECT_NIGHT: + return None + + if self._color is None or self._color[1] == 0: + return None + return self._color @property @@ -249,6 +259,11 @@ def supported_features(self): """Flag supported features.""" return self._supported + @property + def effect(self): + """Return the current effect for this light.""" + return self._effect + @property def effect_list(self): """Return the list of supported effects for this light.""" @@ -270,6 +285,7 @@ def turn_on(self, transition_time, pipeline, **kwargs): if kwargs.get(ATTR_EFFECT) == EFFECT_NIGHT: if EFFECT_NIGHT in self._effect_list: pipeline.night_light() + self._effect = EFFECT_NIGHT return pipeline.on() @@ -314,7 +330,7 @@ def turn_on(self, transition_time, pipeline, **kwargs): if ATTR_EFFECT in kwargs and self._effect_list: if kwargs[ATTR_EFFECT] == EFFECT_COLORLOOP: from limitlessled.presets import COLORLOOP - self.repeating = True + self._effect = EFFECT_COLORLOOP pipeline.append(COLORLOOP) if kwargs[ATTR_EFFECT] == EFFECT_WHITE: pipeline.white() diff --git a/homeassistant/components/light/litejet.py b/homeassistant/components/light/litejet.py index 2ebe766c8c5e80..b8491b6f0f53c1 100644 --- a/homeassistant/components/light/litejet.py +++ b/homeassistant/components/light/litejet.py @@ -6,7 +6,7 @@ """ import logging -import homeassistant.components.litejet as litejet +from homeassistant.components import litejet from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) diff --git a/homeassistant/components/light/lutron.py b/homeassistant/components/light/lutron.py index 34d6cba7cb8b09..24744110c6fd98 100644 --- a/homeassistant/components/light/lutron.py +++ b/homeassistant/components/light/lutron.py @@ -16,7 +16,6 @@ DEPENDENCIES = ['lutron'] -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Lutron lights.""" devs = [] diff --git a/homeassistant/components/light/lutron_caseta.py b/homeassistant/components/light/lutron_caseta.py index e4e1baf6c582d2..29186b8fcd2283 100644 --- a/homeassistant/components/light/lutron_caseta.py +++ b/homeassistant/components/light/lutron_caseta.py @@ -19,7 +19,6 @@ DEPENDENCIES = ['lutron_caseta'] -# pylint: disable=unused-argument @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Lutron Caseta lights.""" @@ -49,10 +48,7 @@ def brightness(self): @asyncio.coroutine def async_turn_on(self, **kwargs): """Turn the light on.""" - if ATTR_BRIGHTNESS in kwargs: - brightness = kwargs[ATTR_BRIGHTNESS] - else: - brightness = 255 + brightness = kwargs.get(ATTR_BRIGHTNESS, 255) self._smartbridge.set_value(self._device_id, to_lutron_level(brightness)) diff --git a/homeassistant/components/light/lw12wifi.py b/homeassistant/components/light/lw12wifi.py new file mode 100644 index 00000000000000..f81d8368f98136 --- /dev/null +++ b/homeassistant/components/light/lw12wifi.py @@ -0,0 +1,158 @@ +""" +Support for Lagute LW-12 WiFi LED Controller. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.lw12wifi/ +""" + +import logging + +import voluptuous as vol + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_TRANSITION, + Light, PLATFORM_SCHEMA, SUPPORT_BRIGHTNESS, SUPPORT_EFFECT, + SUPPORT_COLOR, SUPPORT_TRANSITION +) +from homeassistant.const import ( + CONF_HOST, CONF_NAME, CONF_PORT +) +import homeassistant.helpers.config_validation as cv +import homeassistant.util.color as color_util + + +REQUIREMENTS = ['lw12==0.9.2'] + +_LOGGER = logging.getLogger(__name__) + + +DEFAULT_NAME = 'LW-12 FC' +DEFAULT_PORT = 5000 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup LW-12 WiFi LED Controller platform.""" + import lw12 + + # Assign configuration variables. + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + # Add devices + lw12_light = lw12.LW12Controller(host, port) + add_devices([LW12WiFi(name, lw12_light)]) + + +class LW12WiFi(Light): + """LW-12 WiFi LED Controller.""" + + def __init__(self, name, lw12_light): + """Initialisation of LW-12 WiFi LED Controller. + + Args: + name: Friendly name for this platform to use. + lw12_light: Instance of the LW12 controller. + """ + self._light = lw12_light + self._name = name + self._state = None + self._effect = None + self._rgb_color = [255, 255, 255] + self._brightness = 255 + # Setup feature list + self._supported_features = SUPPORT_BRIGHTNESS | SUPPORT_EFFECT \ + | SUPPORT_COLOR | SUPPORT_TRANSITION + + @property + def name(self): + """Return the display name of the controlled light.""" + return self._name + + @property + def brightness(self): + """Return the brightness of the light.""" + return self._brightness + + @property + def hs_color(self): + """Read back the hue-saturation of the light.""" + return color_util.color_RGB_to_hs(*self._rgb_color) + + @property + def effect(self): + """Return current light effect.""" + if self._effect is None: + return None + return self._effect.replace('_', ' ').title() + + @property + def is_on(self): + """Return true if light is on.""" + return self._state + + @property + def supported_features(self): + """Return a list of supported features.""" + return self._supported_features + + @property + def effect_list(self): + """Return a list of available effects. + + Use the Enum element name for display. + """ + import lw12 + return [effect.name.replace('_', ' ').title() + for effect in lw12.LW12_EFFECT] + + @property + def assumed_state(self) -> bool: + """Return True if unable to access real state of the entity.""" + return True + + @property + def shoud_poll(self) -> bool: + """Return False to not poll the state of this entity.""" + return False + + def turn_on(self, **kwargs): + """Instruct the light to turn on.""" + import lw12 + self._light.light_on() + if ATTR_HS_COLOR in kwargs: + self._rgb_color = color_util.color_hs_to_RGB( + *kwargs[ATTR_HS_COLOR]) + self._light.set_color(*self._rgb_color) + self._effect = None + if ATTR_BRIGHTNESS in kwargs: + self._brightness = kwargs.get(ATTR_BRIGHTNESS) + brightness = int(self._brightness / 255 * 100) + self._light.set_light_option(lw12.LW12_LIGHT.BRIGHTNESS, + brightness) + if ATTR_EFFECT in kwargs: + self._effect = kwargs[ATTR_EFFECT].replace(' ', '_').upper() + # Check if a known and supported effect was selected. + if self._effect in [eff.name for eff in lw12.LW12_EFFECT]: + # Selected effect is supported and will be applied. + self._light.set_effect(lw12.LW12_EFFECT[self._effect]) + else: + # Unknown effect was set, recover by disabling the effect + # mode and log an error. + _LOGGER.error("Unknown effect selected: %s", self._effect) + self._effect = None + if ATTR_TRANSITION in kwargs: + transition_speed = int(kwargs[ATTR_TRANSITION]) + self._light.set_light_option(lw12.LW12_LIGHT.FLASH, + transition_speed) + self._state = True + + def turn_off(self, **kwargs): + """Instruct the light to turn off.""" + self._light.light_off() + self._state = False diff --git a/homeassistant/components/light/mqtt.py b/homeassistant/components/light/mqtt.py index a0534ba4e95063..09fa094c1b2cbb 100644 --- a/homeassistant/components/light/mqtt.py +++ b/homeassistant/components/light/mqtt.py @@ -4,25 +4,25 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.mqtt/ """ -import asyncio import logging import voluptuous as vol from homeassistant.core import callback -import homeassistant.components.mqtt as mqtt +from homeassistant.components import mqtt from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_HS_COLOR, ATTR_WHITE_VALUE, Light, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_EFFECT, SUPPORT_COLOR, SUPPORT_WHITE_VALUE) from homeassistant.const import ( CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_EFFECT, CONF_NAME, - CONF_OPTIMISTIC, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, + CONF_OPTIMISTIC, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, STATE_ON, CONF_RGB, CONF_STATE, CONF_VALUE_TEMPLATE, CONF_WHITE_VALUE, CONF_XY) from homeassistant.components.mqtt import ( CONF_AVAILABILITY_TOPIC, CONF_COMMAND_TOPIC, CONF_PAYLOAD_AVAILABLE, CONF_PAYLOAD_NOT_AVAILABLE, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, MqttAvailability) +from homeassistant.helpers.restore_state import async_get_last_state import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util @@ -100,8 +100,8 @@ }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up a MQTT Light.""" if discovery_info is not None: config = PLATFORM_SCHEMA(discovery_info) @@ -205,7 +205,7 @@ def __init__(self, name, effect_list, topic, templates, qos, topic[CONF_COLOR_TEMP_COMMAND_TOPIC] is not None and SUPPORT_COLOR_TEMP) self._supported_features |= ( - topic[CONF_EFFECT_STATE_TOPIC] is not None and + topic[CONF_EFFECT_COMMAND_TOPIC] is not None and SUPPORT_EFFECT) self._supported_features |= ( topic[CONF_WHITE_VALUE_COMMAND_TOPIC] is not None and @@ -213,10 +213,9 @@ def __init__(self, name, effect_list, topic, templates, qos, self._supported_features |= ( topic[CONF_XY_COMMAND_TOPIC] is not None and SUPPORT_COLOR) - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Subscribe to MQTT events.""" - yield from super().async_added_to_hass() + await super().async_added_to_hass() templates = {} for key, tpl in list(self._templates.items()): @@ -226,6 +225,8 @@ def async_added_to_hass(self): tpl.hass = self.hass templates[key] = tpl.async_render_with_possible_json_value + last_state = await async_get_last_state(self.hass, self.entity_id) + @callback def state_received(topic, payload, qos): """Handle new MQTT messages.""" @@ -237,9 +238,11 @@ def state_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_STATE_TOPIC] is not None: - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._topic[CONF_STATE_TOPIC], state_received, self._qos) + elif self._optimistic and last_state: + self._state = last_state.state == STATE_ON @callback def brightness_received(topic, payload, qos): @@ -250,10 +253,13 @@ def brightness_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_BRIGHTNESS_STATE_TOPIC] is not None: - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._topic[CONF_BRIGHTNESS_STATE_TOPIC], brightness_received, self._qos) self._brightness = 255 + elif self._optimistic_brightness and last_state\ + and last_state.attributes.get(ATTR_BRIGHTNESS): + self._brightness = last_state.attributes.get(ATTR_BRIGHTNESS) elif self._topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is not None: self._brightness = 255 else: @@ -268,11 +274,14 @@ def rgb_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_RGB_STATE_TOPIC] is not None: - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._topic[CONF_RGB_STATE_TOPIC], rgb_received, self._qos) self._hs = (0, 0) - if self._topic[CONF_RGB_COMMAND_TOPIC] is not None: + if self._optimistic_rgb and last_state\ + and last_state.attributes.get(ATTR_HS_COLOR): + self._hs = last_state.attributes.get(ATTR_HS_COLOR) + elif self._topic[CONF_RGB_COMMAND_TOPIC] is not None: self._hs = (0, 0) @callback @@ -282,11 +291,14 @@ def color_temp_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_COLOR_TEMP_STATE_TOPIC] is not None: - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._topic[CONF_COLOR_TEMP_STATE_TOPIC], color_temp_received, self._qos) self._color_temp = 150 - if self._topic[CONF_COLOR_TEMP_COMMAND_TOPIC] is not None: + if self._optimistic_color_temp and last_state\ + and last_state.attributes.get(ATTR_COLOR_TEMP): + self._color_temp = last_state.attributes.get(ATTR_COLOR_TEMP) + elif self._topic[CONF_COLOR_TEMP_COMMAND_TOPIC] is not None: self._color_temp = 150 else: self._color_temp = None @@ -298,11 +310,14 @@ def effect_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_EFFECT_STATE_TOPIC] is not None: - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._topic[CONF_EFFECT_STATE_TOPIC], effect_received, self._qos) self._effect = 'none' - if self._topic[CONF_EFFECT_COMMAND_TOPIC] is not None: + if self._optimistic_effect and last_state\ + and last_state.attributes.get(ATTR_EFFECT): + self._effect = last_state.attributes.get(ATTR_EFFECT) + elif self._topic[CONF_EFFECT_COMMAND_TOPIC] is not None: self._effect = 'none' else: self._effect = None @@ -316,10 +331,13 @@ def white_value_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_WHITE_VALUE_STATE_TOPIC] is not None: - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._topic[CONF_WHITE_VALUE_STATE_TOPIC], white_value_received, self._qos) self._white_value = 255 + elif self._optimistic_white_value and last_state\ + and last_state.attributes.get(ATTR_WHITE_VALUE): + self._white_value = last_state.attributes.get(ATTR_WHITE_VALUE) elif self._topic[CONF_WHITE_VALUE_COMMAND_TOPIC] is not None: self._white_value = 255 else: @@ -334,11 +352,14 @@ def xy_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topic[CONF_XY_STATE_TOPIC] is not None: - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._topic[CONF_XY_STATE_TOPIC], xy_received, self._qos) self._hs = (0, 0) - if self._topic[CONF_XY_COMMAND_TOPIC] is not None: + if self._optimistic_xy and last_state\ + and last_state.attributes.get(ATTR_HS_COLOR): + self._hs = last_state.attributes.get(ATTR_HS_COLOR) + elif self._topic[CONF_XY_COMMAND_TOPIC] is not None: self._hs = (0, 0) @property @@ -396,8 +417,7 @@ def supported_features(self): """Flag supported features.""" return self._supported_features - @asyncio.coroutine - def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the device on. This method is a coroutine. @@ -422,8 +442,15 @@ def async_turn_on(self, **kwargs): self._topic[CONF_RGB_COMMAND_TOPIC] is not None: hs_color = kwargs[ATTR_HS_COLOR] - brightness = kwargs.get( - ATTR_BRIGHTNESS, self._brightness if self._brightness else 255) + + # If there's a brightness topic set, we don't want to scale the RGB + # values given using the brightness. + if self._topic[CONF_BRIGHTNESS_COMMAND_TOPIC] is not None: + brightness = 255 + else: + brightness = kwargs.get( + ATTR_BRIGHTNESS, self._brightness if self._brightness else + 255) rgb = color_util.color_hsv_to_RGB( hs_color[0], hs_color[1], brightness / 255 * 100) tpl = self._templates[CONF_RGB_COMMAND_TEMPLATE] @@ -517,8 +544,7 @@ def async_turn_on(self, **kwargs): if should_update: self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the device off. This method is a coroutine. diff --git a/homeassistant/components/light/mqtt_json.py b/homeassistant/components/light/mqtt_json.py index ca5c76e905f613..d17c7dd73bf5a1 100644 --- a/homeassistant/components/light/mqtt_json.py +++ b/homeassistant/components/light/mqtt_json.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.core import callback -import homeassistant.components.mqtt as mqtt +from homeassistant.components import mqtt from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_TRANSITION, ATTR_WHITE_VALUE, ATTR_HS_COLOR, @@ -18,7 +18,7 @@ SUPPORT_TRANSITION, SUPPORT_WHITE_VALUE) from homeassistant.components.light.mqtt import CONF_BRIGHTNESS_SCALE from homeassistant.const import ( - CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_EFFECT, + CONF_BRIGHTNESS, CONF_COLOR_TEMP, CONF_EFFECT, STATE_ON, CONF_NAME, CONF_OPTIMISTIC, CONF_RGB, CONF_WHITE_VALUE, CONF_XY) from homeassistant.components.mqtt import ( CONF_AVAILABILITY_TOPIC, CONF_STATE_TOPIC, CONF_COMMAND_TOPIC, @@ -26,6 +26,7 @@ MqttAvailability) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import HomeAssistantType, ConfigType +from homeassistant.helpers.restore_state import async_get_last_state import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) @@ -177,6 +178,8 @@ async def async_added_to_hass(self): """Subscribe to MQTT events.""" await super().async_added_to_hass() + last_state = await async_get_last_state(self.hass, self.entity_id) + @callback def state_received(topic, payload, qos): """Handle new MQTT messages.""" @@ -260,6 +263,19 @@ def state_received(topic, payload, qos): self.hass, self._topic[CONF_STATE_TOPIC], state_received, self._qos) + if self._optimistic and last_state: + self._state = last_state.state == STATE_ON + if last_state.attributes.get(ATTR_BRIGHTNESS): + self._brightness = last_state.attributes.get(ATTR_BRIGHTNESS) + if last_state.attributes.get(ATTR_HS_COLOR): + self._hs = last_state.attributes.get(ATTR_HS_COLOR) + if last_state.attributes.get(ATTR_COLOR_TEMP): + self._color_temp = last_state.attributes.get(ATTR_COLOR_TEMP) + if last_state.attributes.get(ATTR_EFFECT): + self._effect = last_state.attributes.get(ATTR_EFFECT) + if last_state.attributes.get(ATTR_WHITE_VALUE): + self._white_value = last_state.attributes.get(ATTR_WHITE_VALUE) + @property def brightness(self): """Return the brightness of this light between 0..255.""" @@ -329,9 +345,14 @@ async def async_turn_on(self, **kwargs): hs_color = kwargs[ATTR_HS_COLOR] message['color'] = {} if self._rgb: - brightness = kwargs.get( - ATTR_BRIGHTNESS, - self._brightness if self._brightness else 255) + # If there's a brightness topic set, we don't want to scale the + # RGB values given using the brightness. + if self._brightness is not None: + brightness = 255 + else: + brightness = kwargs.get( + ATTR_BRIGHTNESS, + self._brightness if self._brightness else 255) rgb = color_util.color_hsv_to_RGB( hs_color[0], hs_color[1], brightness / 255 * 100) message['color']['r'] = rgb[0] diff --git a/homeassistant/components/light/mqtt_template.py b/homeassistant/components/light/mqtt_template.py index 06a94cd23b4e27..ffa73aca915246 100644 --- a/homeassistant/components/light/mqtt_template.py +++ b/homeassistant/components/light/mqtt_template.py @@ -4,12 +4,11 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/light.mqtt_template/ """ -import asyncio import logging import voluptuous as vol from homeassistant.core import callback -import homeassistant.components.mqtt as mqtt +from homeassistant.components import mqtt from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_EFFECT, ATTR_FLASH, ATTR_HS_COLOR, ATTR_TRANSITION, ATTR_WHITE_VALUE, Light, PLATFORM_SCHEMA, @@ -22,6 +21,7 @@ MqttAvailability) import homeassistant.helpers.config_validation as cv import homeassistant.util.color as color_util +from homeassistant.helpers.restore_state import async_get_last_state _LOGGER = logging.getLogger(__name__) @@ -66,8 +66,8 @@ }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up a MQTT Template light.""" if discovery_info is not None: config = PLATFORM_SCHEMA(discovery_info) @@ -152,10 +152,11 @@ def __init__(self, hass, name, effect_list, topics, templates, optimistic, if tpl is not None: tpl.hass = hass - @asyncio.coroutine - def async_added_to_hass(self): + async def async_added_to_hass(self): """Subscribe to MQTT events.""" - yield from super().async_added_to_hass() + await super().async_added_to_hass() + + last_state = await async_get_last_state(self.hass, self.entity_id) @callback def state_received(topic, payload, qos): @@ -223,10 +224,23 @@ def state_received(topic, payload, qos): self.async_schedule_update_ha_state() if self._topics[CONF_STATE_TOPIC] is not None: - yield from mqtt.async_subscribe( + await mqtt.async_subscribe( self.hass, self._topics[CONF_STATE_TOPIC], state_received, self._qos) + if self._optimistic and last_state: + self._state = last_state.state == STATE_ON + if last_state.attributes.get(ATTR_BRIGHTNESS): + self._brightness = last_state.attributes.get(ATTR_BRIGHTNESS) + if last_state.attributes.get(ATTR_HS_COLOR): + self._hs = last_state.attributes.get(ATTR_HS_COLOR) + if last_state.attributes.get(ATTR_COLOR_TEMP): + self._color_temp = last_state.attributes.get(ATTR_COLOR_TEMP) + if last_state.attributes.get(ATTR_EFFECT): + self._effect = last_state.attributes.get(ATTR_EFFECT) + if last_state.attributes.get(ATTR_WHITE_VALUE): + self._white_value = last_state.attributes.get(ATTR_WHITE_VALUE) + @property def brightness(self): """Return the brightness of this light between 0..255.""" @@ -280,8 +294,7 @@ def effect(self): """Return the current effect.""" return self._effect - @asyncio.coroutine - def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the entity on. This method is a coroutine. @@ -304,8 +317,15 @@ def async_turn_on(self, **kwargs): if ATTR_HS_COLOR in kwargs: hs_color = kwargs[ATTR_HS_COLOR] - brightness = kwargs.get( - ATTR_BRIGHTNESS, self._brightness if self._brightness else 255) + + # If there's a brightness topic set, we don't want to scale the RGB + # values given using the brightness. + if self._templates[CONF_BRIGHTNESS_TEMPLATE] is not None: + brightness = 255 + else: + brightness = kwargs.get( + ATTR_BRIGHTNESS, self._brightness if self._brightness else + 255) rgb = color_util.color_hsv_to_RGB( hs_color[0], hs_color[1], brightness / 255 * 100) values['red'] = rgb[0] @@ -339,8 +359,7 @@ def async_turn_on(self, **kwargs): if self._optimistic: self.async_schedule_update_ha_state() - @asyncio.coroutine - def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the entity off. This method is a coroutine. diff --git a/homeassistant/components/light/mysensors.py b/homeassistant/components/light/mysensors.py index 6e41e0f569346c..4139abd40fa287 100644 --- a/homeassistant/components/light/mysensors.py +++ b/homeassistant/components/light/mysensors.py @@ -28,7 +28,7 @@ async def async_setup_platform( async_add_devices=async_add_devices) -class MySensorsLight(mysensors.MySensorsEntity, Light): +class MySensorsLight(mysensors.device.MySensorsEntity, Light): """Representation of a MySensors Light child node.""" def __init__(self, *args): @@ -130,7 +130,7 @@ def _turn_on_rgb_and_w(self, hex_template, **kwargs): self._white = white self._values[self.value_type] = hex_color - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the device off.""" value_type = self.gateway.const.SetReq.V_LIGHT self.gateway.set_child_value( @@ -139,7 +139,7 @@ def turn_off(self, **kwargs): # optimistically assume that light has changed state self._state = False self._values[value_type] = STATE_OFF - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() def _async_update_light(self): """Update the controller with values from light child.""" @@ -171,12 +171,12 @@ def supported_features(self): """Flag supported features.""" return SUPPORT_BRIGHTNESS - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the device on.""" self._turn_on_light() self._turn_on_dimmer(**kwargs) if self.gateway.optimistic: - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() async def async_update(self): """Update the controller with the latest value from a sensor.""" @@ -196,13 +196,13 @@ def supported_features(self): return SUPPORT_BRIGHTNESS | SUPPORT_COLOR return SUPPORT_COLOR - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the device on.""" self._turn_on_light() self._turn_on_dimmer(**kwargs) self._turn_on_rgb_and_w('%02x%02x%02x', **kwargs) if self.gateway.optimistic: - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() async def async_update(self): """Update the controller with the latest value from a sensor.""" @@ -225,10 +225,10 @@ def supported_features(self): return SUPPORT_BRIGHTNESS | SUPPORT_MYSENSORS_RGBW return SUPPORT_MYSENSORS_RGBW - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the device on.""" self._turn_on_light() self._turn_on_dimmer(**kwargs) self._turn_on_rgb_and_w('%02x%02x%02x%02x', **kwargs) if self.gateway.optimistic: - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/light/mystrom.py b/homeassistant/components/light/mystrom.py index 8d7fb807c6dbb9..5d4cdcc17d4bdd 100644 --- a/homeassistant/components/light/mystrom.py +++ b/homeassistant/components/light/mystrom.py @@ -13,9 +13,9 @@ Light, PLATFORM_SCHEMA, ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, SUPPORT_EFFECT, ATTR_EFFECT, SUPPORT_FLASH, SUPPORT_COLOR, ATTR_HS_COLOR) -from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME, STATE_UNKNOWN +from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME -REQUIREMENTS = ['python-mystrom==0.4.2'] +REQUIREMENTS = ['python-mystrom==0.4.4'] _LOGGER = logging.getLogger(__name__) @@ -54,9 +54,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): try: if bulb.get_status()['type'] != 'rgblamp': _LOGGER.error("Device %s (%s) is not a myStrom bulb", host, mac) - return False + return except MyStromConnectionError: - _LOGGER.warning("myStrom bulb not online") + _LOGGER.warning("No route to device: %s", host) add_devices([MyStromLight(bulb, name)], True) @@ -107,7 +107,7 @@ def effect_list(self): @property def is_on(self): """Return true if light is on.""" - return self._state['on'] if self._state is not None else STATE_UNKNOWN + return self._state['on'] if self._state is not None else None def turn_on(self, **kwargs): """Turn on the light.""" @@ -136,7 +136,7 @@ def turn_on(self, **kwargs): if effect == EFFECT_RAINBOW: self._bulb.set_rainbow(30) except MyStromConnectionError: - _LOGGER.warning("myStrom bulb not online") + _LOGGER.warning("No route to device") def turn_off(self, **kwargs): """Turn off the bulb.""" @@ -155,7 +155,11 @@ def update(self): self._state = self._bulb.get_status() colors = self._bulb.get_color()['color'] - color_h, color_s, color_v = colors.split(';') + try: + color_h, color_s, color_v = colors.split(';') + except ValueError: + color_s, color_v = colors.split(';') + color_h = 0 self._color_h = int(color_h) self._color_s = int(color_s) @@ -163,5 +167,5 @@ def update(self): self._available = True except MyStromConnectionError: - _LOGGER.warning("myStrom bulb not online") + _LOGGER.warning("No route to device") self._available = False diff --git a/homeassistant/components/light/nanoleaf_aurora.py b/homeassistant/components/light/nanoleaf_aurora.py index 99c07166037e49..6a0d3c36e9f0d9 100644 --- a/homeassistant/components/light/nanoleaf_aurora.py +++ b/homeassistant/components/light/nanoleaf_aurora.py @@ -17,6 +17,7 @@ from homeassistant.util import color as color_util from homeassistant.util.color import \ color_temperature_mired_to_kelvin as mired_to_kelvin +from homeassistant.util.json import load_json, save_json REQUIREMENTS = ['nanoleaf==0.4.1'] @@ -24,6 +25,10 @@ DEFAULT_NAME = 'Aurora' +DATA_NANOLEAF_AURORA = 'nanoleaf_aurora' + +CONFIG_FILE = '.nanoleaf_aurora.conf' + ICON = 'mdi:triangle-outline' SUPPORT_AURORA = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT | @@ -39,31 +44,59 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Nanoleaf Aurora device.""" import nanoleaf - host = config.get(CONF_HOST) - name = config.get(CONF_NAME) - token = config.get(CONF_TOKEN) + import nanoleaf.setup + if DATA_NANOLEAF_AURORA not in hass.data: + hass.data[DATA_NANOLEAF_AURORA] = dict() + + token = '' + if discovery_info is not None: + host = discovery_info['host'] + name = discovery_info['hostname'] + # if device already exists via config, skip discovery setup + if host in hass.data[DATA_NANOLEAF_AURORA]: + return + _LOGGER.info("Discovered a new Aurora: %s", discovery_info) + conf = load_json(hass.config.path(CONFIG_FILE)) + if conf.get(host, {}).get('token'): + token = conf[host]['token'] + else: + host = config[CONF_HOST] + name = config[CONF_NAME] + token = config[CONF_TOKEN] + + if not token: + token = nanoleaf.setup.generate_auth_token(host) + if not token: + _LOGGER.error("Could not generate the auth token, did you press " + "and hold the power button on %s" + "for 5-7 seconds?", name) + return + conf = load_json(hass.config.path(CONFIG_FILE)) + conf[host] = {'token': token} + save_json(hass.config.path(CONFIG_FILE), conf) + aurora_light = nanoleaf.Aurora(host, token) - aurora_light.hass_name = name if aurora_light.on is None: _LOGGER.error( "Could not connect to Nanoleaf Aurora: %s on %s", name, host) return - add_devices([AuroraLight(aurora_light)], True) + hass.data[DATA_NANOLEAF_AURORA][host] = aurora_light + add_devices([AuroraLight(aurora_light, name)], True) class AuroraLight(Light): """Representation of a Nanoleaf Aurora.""" - def __init__(self, light): + def __init__(self, light, name): """Initialize an Aurora light.""" self._brightness = None self._color_temp = None self._effect = None self._effects_list = None self._light = light - self._name = light.hass_name + self._name = name self._hs_color = None self._state = None @@ -92,6 +125,16 @@ def effect_list(self): """Return the list of supported effects.""" return self._effects_list + @property + def min_mireds(self): + """Return the coldest color_temp that this light supports.""" + return 154 + + @property + def max_mireds(self): + """Return the warmest color_temp that this light supports.""" + return 833 + @property def name(self): """Return the display name of this light.""" diff --git a/homeassistant/components/light/osramlightify.py b/homeassistant/components/light/osramlightify.py index 2c44620cacaa68..939d0fe6988842 100644 --- a/homeassistant/components/light/osramlightify.py +++ b/homeassistant/components/light/osramlightify.py @@ -27,8 +27,10 @@ _LOGGER = logging.getLogger(__name__) +CONF_ALLOW_LIGHTIFY_NODES = 'allow_lightify_nodes' CONF_ALLOW_LIGHTIFY_GROUPS = 'allow_lightify_groups' +DEFAULT_ALLOW_LIGHTIFY_NODES = True DEFAULT_ALLOW_LIGHTIFY_GROUPS = True MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(milliseconds=100) @@ -40,6 +42,8 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_ALLOW_LIGHTIFY_NODES, + default=DEFAULT_ALLOW_LIGHTIFY_NODES): cv.boolean, vol.Optional(CONF_ALLOW_LIGHTIFY_GROUPS, default=DEFAULT_ALLOW_LIGHTIFY_GROUPS): cv.boolean, }) @@ -50,6 +54,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): import lightify host = config.get(CONF_HOST) + add_nodes = config.get(CONF_ALLOW_LIGHTIFY_NODES) add_groups = config.get(CONF_ALLOW_LIGHTIFY_GROUPS) try: @@ -60,10 +65,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.exception(msg) return - setup_bridge(bridge, add_devices, add_groups) + setup_bridge(bridge, add_devices, add_nodes, add_groups) -def setup_bridge(bridge, add_devices, add_groups): +def setup_bridge(bridge, add_devices, add_nodes, add_groups): """Set up the Lightify bridge.""" lights = {} @@ -80,14 +85,15 @@ def update_lights(): new_lights = [] - for (light_id, light) in bridge.lights().items(): - if light_id not in lights: - osram_light = OsramLightifyLight( - light_id, light, update_lights) - lights[light_id] = osram_light - new_lights.append(osram_light) - else: - lights[light_id].light = light + if add_nodes: + for (light_id, light) in bridge.lights().items(): + if light_id not in lights: + osram_light = OsramLightifyLight( + light_id, light, update_lights) + lights[light_id] = osram_light + new_lights.append(osram_light) + else: + lights[light_id].light = light if add_groups: for (group_name, group) in bridge.groups().items(): diff --git a/homeassistant/components/light/rfxtrx.py b/homeassistant/components/light/rfxtrx.py index cdfe2fe5671980..293783ee3ab1af 100644 --- a/homeassistant/components/light/rfxtrx.py +++ b/homeassistant/components/light/rfxtrx.py @@ -8,7 +8,7 @@ import voluptuous as vol -import homeassistant.components.rfxtrx as rfxtrx +from homeassistant.components import rfxtrx from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light, PLATFORM_SCHEMA) from homeassistant.const import CONF_NAME diff --git a/homeassistant/components/light/scsgate.py b/homeassistant/components/light/scsgate.py index 214a2d99449ca8..3d567afe09e0b8 100644 --- a/homeassistant/components/light/scsgate.py +++ b/homeassistant/components/light/scsgate.py @@ -8,7 +8,7 @@ import voluptuous as vol -import homeassistant.components.scsgate as scsgate +from homeassistant.components import scsgate from homeassistant.components.light import (Light, PLATFORM_SCHEMA) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_STATE, CONF_DEVICES, CONF_NAME) diff --git a/homeassistant/components/light/sisyphus.py b/homeassistant/components/light/sisyphus.py new file mode 100644 index 00000000000000..ded78716317161 --- /dev/null +++ b/homeassistant/components/light/sisyphus.py @@ -0,0 +1,78 @@ +""" +Support for the light on the Sisyphus Kinetic Art Table. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.sisyphus/ +""" +import logging + +from homeassistant.const import CONF_NAME +from homeassistant.components.light import SUPPORT_BRIGHTNESS, Light +from homeassistant.components.sisyphus import DATA_SISYPHUS + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['sisyphus'] + +SUPPORTED_FEATURES = SUPPORT_BRIGHTNESS + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up a single Sisyphus table.""" + name = discovery_info[CONF_NAME] + add_devices( + [SisyphusLight(name, hass.data[DATA_SISYPHUS][name])], + update_before_add=True) + + +class SisyphusLight(Light): + """Represents a Sisyphus table as a light.""" + + def __init__(self, name, table): + """ + Constructor. + + :param name: name of the table + :param table: sisyphus-control Table object + """ + self._name = name + self._table = table + + async def async_added_to_hass(self): + """Add listeners after this object has been initialized.""" + self._table.add_listener( + lambda: self.async_schedule_update_ha_state(False)) + + @property + def name(self): + """Return the ame of the table.""" + return self._name + + @property + def is_on(self): + """Return True if the table is on.""" + return not self._table.is_sleeping + + @property + def brightness(self): + """Return the current brightness of the table's ring light.""" + return self._table.brightness * 255 + + @property + def supported_features(self): + """Return the features supported by the table; i.e. brightness.""" + return SUPPORTED_FEATURES + + async def async_turn_off(self, **kwargs): + """Put the table to sleep.""" + await self._table.sleep() + _LOGGER.debug("Sisyphus table %s: sleep") + + async def async_turn_on(self, **kwargs): + """Wake up the table if necessary, optionally changes brightness.""" + if not self.is_on: + await self._table.wakeup() + _LOGGER.debug("Sisyphus table %s: wakeup") + + if "brightness" in kwargs: + await self._table.set_brightness(kwargs["brightness"] / 255.0) diff --git a/homeassistant/components/light/tellstick.py b/homeassistant/components/light/tellstick.py index 1bf7d632af5fc1..44e5e40b3b79cc 100644 --- a/homeassistant/components/light/tellstick.py +++ b/homeassistant/components/light/tellstick.py @@ -15,7 +15,6 @@ SUPPORT_TELLSTICK = SUPPORT_BRIGHTNESS -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Tellstick lights.""" if (discovery_info is None or diff --git a/homeassistant/components/light/template.py b/homeassistant/components/light/template.py index 38cac649a1a7f7..ad77b734fbb3d8 100644 --- a/homeassistant/components/light/template.py +++ b/homeassistant/components/light/template.py @@ -248,8 +248,7 @@ def async_update(self): self._state = state in ('true', STATE_ON) else: _LOGGER.error( - 'Received invalid light is_on state: %s. ' + - 'Expected: %s', + 'Received invalid light is_on state: %s. Expected: %s', state, ', '.join(_VALID_STATES)) self._state = None @@ -264,8 +263,7 @@ def async_update(self): self._brightness = int(brightness) else: _LOGGER.error( - 'Received invalid brightness : %s' + - 'Expected: 0-255', + 'Received invalid brightness : %s. Expected: 0-255', brightness) self._brightness = None diff --git a/homeassistant/components/light/tikteck.py b/homeassistant/components/light/tikteck.py index 2079638f7f1046..c21da57ea96f1a 100644 --- a/homeassistant/components/light/tikteck.py +++ b/homeassistant/components/light/tikteck.py @@ -31,7 +31,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Tikteck platform.""" lights = [] diff --git a/homeassistant/components/light/tplink.py b/homeassistant/components/light/tplink.py index 4101eab2150298..9374c1418f0bfb 100644 --- a/homeassistant/components/light/tplink.py +++ b/homeassistant/components/light/tplink.py @@ -19,7 +19,7 @@ from homeassistant.util.color import ( color_temperature_kelvin_to_mired as kelvin_to_mired) -REQUIREMENTS = ['pyHS100==0.3.0'] +REQUIREMENTS = ['pyHS100==0.3.2'] _LOGGER = logging.getLogger(__name__) @@ -66,6 +66,8 @@ def __init__(self, smartbulb: 'SmartBulb', name) -> None: self._brightness = None self._hs = None self._supported_features = 0 + self._min_mireds = None + self._max_mireds = None self._emeter_params = {} @property @@ -104,6 +106,16 @@ def turn_off(self, **kwargs): """Turn the light off.""" self.smartbulb.state = self.smartbulb.BULB_STATE_OFF + @property + def min_mireds(self): + """Return minimum supported color temperature.""" + return self._min_mireds + + @property + def max_mireds(self): + """Return maximum supported color temperature.""" + return self._max_mireds + @property def color_temp(self): """Return the color temperature of this light in mireds for HA.""" @@ -128,8 +140,6 @@ def update(self): """Update the TP-Link Bulb's state.""" from pyHS100 import SmartDeviceException try: - self._available = True - if self._supported_features == 0: self.get_features() @@ -170,9 +180,13 @@ def update(self): # device returned no daily/monthly history pass + self._available = True + except (SmartDeviceException, OSError) as ex: - _LOGGER.warning("Could not read state for %s: %s", self._name, ex) - self._available = False + if self._available: + _LOGGER.warning( + "Could not read state for %s: %s", self._name, ex) + self._available = False @property def supported_features(self): @@ -185,5 +199,9 @@ def get_features(self): self._supported_features += SUPPORT_BRIGHTNESS if self.smartbulb.is_variable_color_temp: self._supported_features += SUPPORT_COLOR_TEMP + self._min_mireds = kelvin_to_mired( + self.smartbulb.valid_temperature_range[1]) + self._max_mireds = kelvin_to_mired( + self.smartbulb.valid_temperature_range[0]) if self.smartbulb.is_color: self._supported_features += SUPPORT_COLOR diff --git a/homeassistant/components/light/tradfri.py b/homeassistant/components/light/tradfri.py index ab53c3669cb722..c30745239ea05c 100644 --- a/homeassistant/components/light/tradfri.py +++ b/homeassistant/components/light/tradfri.py @@ -19,12 +19,16 @@ _LOGGER = logging.getLogger(__name__) +ATTR_DIMMER = 'dimmer' +ATTR_HUE = 'hue' +ATTR_SAT = 'saturation' ATTR_TRANSITION_TIME = 'transition_time' DEPENDENCIES = ['tradfri'] PLATFORM_SCHEMA = LIGHT_PLATFORM_SCHEMA IKEA = 'IKEA of Sweden' TRADFRI_LIGHT_MANAGER = 'Tradfri Light Manager' -SUPPORTED_FEATURES = (SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION) +SUPPORTED_FEATURES = SUPPORT_TRANSITION +SUPPORTED_GROUP_FEATURES = SUPPORT_BRIGHTNESS | SUPPORT_TRANSITION async def async_setup_platform(hass, config, @@ -79,7 +83,7 @@ def unique_id(self): @property def supported_features(self): """Flag supported features.""" - return SUPPORTED_FEATURES + return SUPPORTED_GROUP_FEATURES @property def name(self): @@ -225,75 +229,97 @@ def hs_color(self): """HS color of the light.""" if self._light_control.can_set_color: hsbxy = self._light_data.hsb_xy_color - hue = hsbxy[0] / (65535 / 360) - sat = hsbxy[1] / (65279 / 100) + hue = hsbxy[0] / (self._light_control.max_hue / 360) + sat = hsbxy[1] / (self._light_control.max_saturation / 100) if hue is not None and sat is not None: return hue, sat async def async_turn_off(self, **kwargs): """Instruct the light to turn off.""" - await self._api(self._light_control.set_state(False)) + # This allows transitioning to off, but resets the brightness + # to 1 for the next set_state(True) command + transition_time = None + if ATTR_TRANSITION in kwargs: + transition_time = int(kwargs[ATTR_TRANSITION]) * 10 + + dimmer_data = {ATTR_DIMMER: 0, ATTR_TRANSITION_TIME: + transition_time} + await self._api(self._light_control.set_dimmer(**dimmer_data)) + else: + await self._api(self._light_control.set_state(False)) async def async_turn_on(self, **kwargs): """Instruct the light to turn on.""" - params = {} transition_time = None if ATTR_TRANSITION in kwargs: transition_time = int(kwargs[ATTR_TRANSITION]) * 10 - brightness = kwargs.get(ATTR_BRIGHTNESS) - - if brightness is not None: + dimmer_command = None + if ATTR_BRIGHTNESS in kwargs: + brightness = kwargs[ATTR_BRIGHTNESS] if brightness > 254: brightness = 254 elif brightness < 0: brightness = 0 + dimmer_data = {ATTR_DIMMER: brightness, ATTR_TRANSITION_TIME: + transition_time} + dimmer_command = self._light_control.set_dimmer(**dimmer_data) + transition_time = None + else: + dimmer_command = self._light_control.set_state(True) + color_command = None if ATTR_HS_COLOR in kwargs and self._light_control.can_set_color: - params[ATTR_BRIGHTNESS] = brightness - hue = int(kwargs[ATTR_HS_COLOR][0] * (65535 / 360)) - sat = int(kwargs[ATTR_HS_COLOR][1] * (65279 / 100)) - if brightness is None: - params[ATTR_TRANSITION_TIME] = transition_time - await self._api( - self._light_control.set_hsb(hue, sat, **params)) - return - + hue = int(kwargs[ATTR_HS_COLOR][0] * + (self._light_control.max_hue / 360)) + sat = int(kwargs[ATTR_HS_COLOR][1] * + (self._light_control.max_saturation / 100)) + color_data = {ATTR_HUE: hue, ATTR_SAT: sat, ATTR_TRANSITION_TIME: + transition_time} + color_command = self._light_control.set_hsb(**color_data) + transition_time = None + + temp_command = None if ATTR_COLOR_TEMP in kwargs and (self._light_control.can_set_temp or self._light_control.can_set_color): temp = kwargs[ATTR_COLOR_TEMP] - if temp > self.max_mireds: - temp = self.max_mireds - elif temp < self.min_mireds: - temp = self.min_mireds - - if brightness is None: - params[ATTR_TRANSITION_TIME] = transition_time # White Spectrum bulb - if (self._light_control.can_set_temp and - not self._light_control.can_set_color): - await self._api( - self._light_control.set_color_temp(temp, **params)) + if self._light_control.can_set_temp: + if temp > self.max_mireds: + temp = self.max_mireds + elif temp < self.min_mireds: + temp = self.min_mireds + temp_data = {ATTR_COLOR_TEMP: temp, ATTR_TRANSITION_TIME: + transition_time} + temp_command = self._light_control.set_color_temp(**temp_data) + transition_time = None # Color bulb (CWS) # color_temp needs to be set with hue/saturation - if self._light_control.can_set_color: - params[ATTR_BRIGHTNESS] = brightness + elif self._light_control.can_set_color: temp_k = color_util.color_temperature_mired_to_kelvin(temp) hs_color = color_util.color_temperature_to_hs(temp_k) - hue = int(hs_color[0] * (65535 / 360)) - sat = int(hs_color[1] * (65279 / 100)) - await self._api( - self._light_control.set_hsb(hue, sat, - **params)) - - if brightness is not None: - params[ATTR_TRANSITION_TIME] = transition_time - await self._api( - self._light_control.set_dimmer(brightness, - **params)) + hue = int(hs_color[0] * (self._light_control.max_hue / 360)) + sat = int(hs_color[1] * + (self._light_control.max_saturation / 100)) + color_data = {ATTR_HUE: hue, ATTR_SAT: sat, + ATTR_TRANSITION_TIME: transition_time} + color_command = self._light_control.set_hsb(**color_data) + transition_time = None + + # HSB can always be set, but color temp + brightness is bulb dependant + command = dimmer_command + if command is not None: + command += color_command else: - await self._api( - self._light_control.set_state(True)) + command = color_command + + if self._light_control.can_combine_commands: + await self._api(command + temp_command) + else: + if temp_command is not None: + await self._api(temp_command) + if command is not None: + await self._api(command) @callback def _async_start_observe(self, exc=None): @@ -324,6 +350,8 @@ def _refresh(self, light): self._name = light.name self._features = SUPPORTED_FEATURES + if light.light_control.can_set_dimmer: + self._features |= SUPPORT_BRIGHTNESS if light.light_control.can_set_color: self._features |= SUPPORT_COLOR if light.light_control.can_set_temp: diff --git a/homeassistant/components/light/tuya.py b/homeassistant/components/light/tuya.py new file mode 100644 index 00000000000000..d7691cea0118d6 --- /dev/null +++ b/homeassistant/components/light/tuya.py @@ -0,0 +1,102 @@ +""" +Support for the Tuya light. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/light.tuya/ +""" +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ENTITY_ID_FORMAT, + SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_COLOR, Light) + +from homeassistant.components.tuya import DATA_TUYA, TuyaDevice +from homeassistant.util import color as colorutil + +DEPENDENCIES = ['tuya'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Tuya light platform.""" + if discovery_info is None: + return + tuya = hass.data[DATA_TUYA] + dev_ids = discovery_info.get('dev_ids') + devices = [] + for dev_id in dev_ids: + device = tuya.get_device_by_id(dev_id) + if device is None: + continue + devices.append(TuyaLight(device)) + add_devices(devices) + + +class TuyaLight(TuyaDevice, Light): + """Tuya light device.""" + + def __init__(self, tuya): + """Init Tuya light device.""" + super().__init__(tuya) + self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) + + @property + def brightness(self): + """Return the brightness of the light.""" + return self.tuya.brightness() + + @property + def hs_color(self): + """Return the hs_color of the light.""" + return self.tuya.hs_color() + + @property + def color_temp(self): + """Return the color_temp of the light.""" + color_temp = self.tuya.color_temp() + if color_temp is None: + return None + return colorutil.color_temperature_kelvin_to_mired(color_temp) + + @property + def is_on(self): + """Return true if light is on.""" + return self.tuya.state() + + @property + def min_mireds(self): + """Return color temperature min mireds.""" + return colorutil.color_temperature_kelvin_to_mired( + self.tuya.min_color_temp()) + + @property + def max_mireds(self): + """Return color temperature max mireds.""" + return colorutil.color_temperature_kelvin_to_mired( + self.tuya.max_color_temp()) + + def turn_on(self, **kwargs): + """Turn on or control the light.""" + if (ATTR_BRIGHTNESS not in kwargs + and ATTR_HS_COLOR not in kwargs + and ATTR_COLOR_TEMP not in kwargs): + self.tuya.turn_on() + if ATTR_BRIGHTNESS in kwargs: + self.tuya.set_brightness(kwargs[ATTR_BRIGHTNESS]) + if ATTR_HS_COLOR in kwargs: + self.tuya.set_color(kwargs[ATTR_HS_COLOR]) + if ATTR_COLOR_TEMP in kwargs: + color_temp = colorutil.color_temperature_mired_to_kelvin( + kwargs[ATTR_COLOR_TEMP]) + self.tuya.set_color_temp(color_temp) + + def turn_off(self, **kwargs): + """Instruct the light to turn off.""" + self.tuya.turn_off() + + @property + def supported_features(self): + """Flag supported features.""" + supports = SUPPORT_BRIGHTNESS + if self.tuya.support_color(): + supports = supports | SUPPORT_COLOR + if self.tuya.support_color_temp(): + supports = supports | SUPPORT_COLOR_TEMP + return supports diff --git a/homeassistant/components/light/velbus.py b/homeassistant/components/light/velbus.py deleted file mode 100644 index 8a02b36b75faad..00000000000000 --- a/homeassistant/components/light/velbus.py +++ /dev/null @@ -1,104 +0,0 @@ -""" -Support for Velbus lights. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/light.velbus/ -""" -import asyncio -import logging - -import voluptuous as vol - -from homeassistant.const import CONF_NAME, CONF_DEVICES -from homeassistant.components.light import Light, PLATFORM_SCHEMA -from homeassistant.components.velbus import DOMAIN -import homeassistant.helpers.config_validation as cv - -DEPENDENCIES = ['velbus'] - -_LOGGER = logging.getLogger(__name__) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DEVICES): vol.All(cv.ensure_list, [ - { - vol.Required('module'): cv.positive_int, - vol.Required('channel'): cv.positive_int, - vol.Required(CONF_NAME): cv.string - } - ]) -}) - - -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up Lights.""" - velbus = hass.data[DOMAIN] - add_devices(VelbusLight(light, velbus) for light in config[CONF_DEVICES]) - - -class VelbusLight(Light): - """Representation of a Velbus Light.""" - - def __init__(self, light, velbus): - """Initialize a Velbus light.""" - self._velbus = velbus - self._name = light[CONF_NAME] - self._module = light['module'] - self._channel = light['channel'] - self._state = False - - @asyncio.coroutine - def async_added_to_hass(self): - """Add listener for Velbus messages on bus.""" - def _init_velbus(): - """Initialize Velbus on startup.""" - self._velbus.subscribe(self._on_message) - self.get_status() - - yield from self.hass.async_add_job(_init_velbus) - - def _on_message(self, message): - import velbus - if isinstance(message, velbus.RelayStatusMessage) and \ - message.address == self._module and \ - message.channel == self._channel: - self._state = message.is_on() - self.schedule_update_ha_state() - - @property - def name(self): - """Return the display name of this light.""" - return self._name - - @property - def should_poll(self): - """Disable polling.""" - return False - - @property - def is_on(self): - """Return true if the light is on.""" - return self._state - - def turn_on(self, **kwargs): - """Instruct the light to turn on.""" - import velbus - message = velbus.SwitchRelayOnMessage() - message.set_defaults(self._module) - message.relay_channels = [self._channel] - self._velbus.send(message) - - def turn_off(self, **kwargs): - """Instruct the light to turn off.""" - import velbus - message = velbus.SwitchRelayOffMessage() - message.set_defaults(self._module) - message.relay_channels = [self._channel] - self._velbus.send(message) - - def get_status(self): - """Retrieve current status.""" - import velbus - message = velbus.ModuleStatusRequestMessage() - message.set_defaults(self._module) - message.channels = [self._channel] - self._velbus.send(message) diff --git a/homeassistant/components/light/vera.py b/homeassistant/components/light/vera.py index 6b12e69341d2cc..e62ffaecdff92f 100644 --- a/homeassistant/components/light/vera.py +++ b/homeassistant/components/light/vera.py @@ -18,12 +18,11 @@ DEPENDENCIES = ['vera'] -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Vera lights.""" add_devices( - VeraLight(device, hass.data[VERA_CONTROLLER]) for - device in hass.data[VERA_DEVICES]['light']) + [VeraLight(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['light']], True) class VeraLight(VeraDevice, Light): diff --git a/homeassistant/components/light/wemo.py b/homeassistant/components/light/wemo.py index fcf3d2f7a7d5d9..4c912d60fb7135 100644 --- a/homeassistant/components/light/wemo.py +++ b/homeassistant/components/light/wemo.py @@ -8,7 +8,7 @@ import logging from datetime import timedelta -import homeassistant.util as util +from homeassistant import util from homeassistant.components.light import ( Light, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_TRANSITION, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_COLOR, SUPPORT_TRANSITION) @@ -27,7 +27,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up discovered WeMo switches.""" - import pywemo.discovery as discovery + from pywemo import discovery if discovery_info is not None: location = discovery_info['ssdp_description'] @@ -107,6 +107,11 @@ def supported_features(self): """Flag supported features.""" return SUPPORT_WEMO + @property + def available(self): + """Return if light is available.""" + return self.device.state['available'] + def turn_on(self, **kwargs): """Turn the light on.""" transitiontime = int(kwargs.get(ATTR_TRANSITION, 0)) diff --git a/homeassistant/components/light/wink.py b/homeassistant/components/light/wink.py index 04e9c34b0f64a5..a2cc4fd7aeb5b8 100644 --- a/homeassistant/components/light/wink.py +++ b/homeassistant/components/light/wink.py @@ -16,8 +16,6 @@ DEPENDENCIES = ['wink'] -SUPPORT_WINK = SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_COLOR - def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Wink lights.""" @@ -78,7 +76,14 @@ def color_temp(self): @property def supported_features(self): """Flag supported features.""" - return SUPPORT_WINK + supports = SUPPORT_BRIGHTNESS + if self.wink.supports_temperature(): + supports = supports | SUPPORT_COLOR_TEMP + if self.wink.supports_xy_color(): + supports = supports | SUPPORT_COLOR + elif self.wink.supports_hue_saturation(): + supports = supports | SUPPORT_COLOR + return supports def turn_on(self, **kwargs): """Turn the switch on.""" diff --git a/homeassistant/components/light/xiaomi_aqara.py b/homeassistant/components/light/xiaomi_aqara.py index 37ae60e3494dbb..75c85a4bfcfb7d 100644 --- a/homeassistant/components/light/xiaomi_aqara.py +++ b/homeassistant/components/light/xiaomi_aqara.py @@ -31,7 +31,7 @@ def __init__(self, device, name, xiaomi_hub): """Initialize the XiaomiGatewayLight.""" self._data_key = 'rgb' self._hs = (0, 0) - self._brightness = 180 + self._brightness = 100 XiaomiDevice.__init__(self, device, name, xiaomi_hub) @@ -64,7 +64,7 @@ def parse_data(self, data, raw_data): brightness = rgba[0] rgb = rgba[1:] - self._brightness = int(255 * brightness / 100) + self._brightness = brightness self._hs = color_util.color_RGB_to_hs(*rgb) self._state = True return True @@ -72,7 +72,7 @@ def parse_data(self, data, raw_data): @property def brightness(self): """Return the brightness of this light between 0..255.""" - return self._brightness + return int(255 * self._brightness / 100) @property def hs_color(self): diff --git a/homeassistant/components/light/xiaomi_miio.py b/homeassistant/components/light/xiaomi_miio.py index 24eab7ebd4ad1c..fbb8dd66f013d8 100644 --- a/homeassistant/components/light/xiaomi_miio.py +++ b/homeassistant/components/light/xiaomi_miio.py @@ -42,7 +42,7 @@ 'philips.light.candle2']), }) -REQUIREMENTS = ['python-miio==0.3.9', 'construct==2.9.41'] +REQUIREMENTS = ['python-miio==0.4.0', 'construct==2.9.41'] # The light does not accept cct values < 1 CCT_MIN = 1 @@ -100,7 +100,6 @@ } -# pylint: disable=unused-argument async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the light from config.""" diff --git a/homeassistant/components/light/yeelight.py b/homeassistant/components/light/yeelight.py index 202c6ac594d807..791de291b4803d 100644 --- a/homeassistant/components/light/yeelight.py +++ b/homeassistant/components/light/yeelight.py @@ -310,7 +310,7 @@ def update(self) -> None: bright = self._properties.get('bright', None) if bright: - self._brightness = 255 * (int(bright) / 100) + self._brightness = round(255 * (int(bright) / 100)) temp_in_k = self._properties.get('ct', None) if temp_in_k: diff --git a/homeassistant/components/light/zengge.py b/homeassistant/components/light/zengge.py index 3c77f2d8449cac..35d2bf2388cd3b 100644 --- a/homeassistant/components/light/zengge.py +++ b/homeassistant/components/light/zengge.py @@ -30,7 +30,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Zengge platform.""" lights = [] diff --git a/homeassistant/components/light/zha.py b/homeassistant/components/light/zha.py index 8eb1b3dc9b64c9..bd01a513e0b454 100644 --- a/homeassistant/components/light/zha.py +++ b/homeassistant/components/light/zha.py @@ -6,7 +6,6 @@ """ import logging from homeassistant.components import light, zha -from homeassistant.const import STATE_UNKNOWN import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) @@ -76,7 +75,7 @@ def __init__(self, **kwargs): @property def is_on(self) -> bool: """Return true if entity is on.""" - if self._state == STATE_UNKNOWN: + if self._state is None: return False return bool(self._state) @@ -173,7 +172,8 @@ async def async_update(self): result = await zha.safe_read(self._endpoint.light_color, ['current_x', 'current_y']) if 'current_x' in result and 'current_y' in result: - xy_color = (result['current_x'], result['current_y']) + xy_color = (round(result['current_x']/65535, 3), + round(result['current_y']/65535, 3)) self._hs_color = color_util.color_xy_to_hs(*xy_color) @property diff --git a/homeassistant/components/light/zwave.py b/homeassistant/components/light/zwave.py index 04216780c80252..55feef496f8bf4 100644 --- a/homeassistant/components/light/zwave.py +++ b/homeassistant/components/light/zwave.py @@ -6,15 +6,13 @@ """ import logging -# Because we do not compile openzwave on CI -# pylint: disable=import-error from threading import Timer from homeassistant.components.light import ( ATTR_WHITE_VALUE, ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_TRANSITION, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_COLOR, SUPPORT_TRANSITION, SUPPORT_WHITE_VALUE, DOMAIN, Light) from homeassistant.components import zwave -from homeassistant.components.zwave import async_setup_platform # noqa # pylint: disable=unused-import +from homeassistant.components.zwave import async_setup_platform # noqa pylint: disable=unused-import from homeassistant.const import STATE_OFF, STATE_ON import homeassistant.util.color as color_util @@ -326,9 +324,11 @@ def turn_on(self, **kwargs): else: self._ct = TEMP_COLD_HASS rgbw = '#00000000ff' - elif ATTR_HS_COLOR in kwargs: self._hs = kwargs[ATTR_HS_COLOR] + if ATTR_WHITE_VALUE not in kwargs: + # white LED must be off in order for color to work + self._white = 0 if ATTR_WHITE_VALUE in kwargs or ATTR_HS_COLOR in kwargs: rgbw = '#' diff --git a/homeassistant/components/linode.py b/homeassistant/components/linode.py index 9e87c002482e8b..c98ef16c7ed667 100644 --- a/homeassistant/components/linode.py +++ b/homeassistant/components/linode.py @@ -13,7 +13,7 @@ from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['linode-api==4.1.4b2'] +REQUIREMENTS = ['linode-api==4.1.9b1'] _LOGGER = logging.getLogger(__name__) @@ -62,7 +62,7 @@ def setup(hass, config): return True -class Linode(object): +class Linode: """Handle all communication with the Linode API.""" def __init__(self, access_token): diff --git a/homeassistant/components/lirc.py b/homeassistant/components/lirc.py index 0cd49ab6c9a322..d7ec49e00968fb 100644 --- a/homeassistant/components/lirc.py +++ b/homeassistant/components/lirc.py @@ -4,7 +4,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/lirc/ """ -# pylint: disable=import-error,no-member +# pylint: disable=no-member import threading import time import logging diff --git a/homeassistant/components/lock/__init__.py b/homeassistant/components/lock/__init__.py index b3e4ac8f0ff6a7..f03d028a38f1ee 100644 --- a/homeassistant/components/lock/__init__.py +++ b/homeassistant/components/lock/__init__.py @@ -145,7 +145,6 @@ def changed_by(self): """Last change triggered by.""" return None - # pylint: disable=no-self-use @property def code_format(self): """Regex for code format or None if no code is required.""" diff --git a/homeassistant/components/lock/demo.py b/homeassistant/components/lock/demo.py index d561dd333ab315..8da53a9ef11fba 100644 --- a/homeassistant/components/lock/demo.py +++ b/homeassistant/components/lock/demo.py @@ -8,7 +8,6 @@ from homeassistant.const import (STATE_LOCKED, STATE_UNLOCKED) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Demo lock platform.""" add_devices([ diff --git a/homeassistant/components/lock/isy994.py b/homeassistant/components/lock/isy994.py index 50371fdc9ae8fb..9bcf5a86d08ecc 100644 --- a/homeassistant/components/lock/isy994.py +++ b/homeassistant/components/lock/isy994.py @@ -5,7 +5,7 @@ https://home-assistant.io/components/lock.isy994/ """ import logging -from typing import Callable # noqa +from typing import Callable from homeassistant.components.lock import LockDevice, DOMAIN from homeassistant.components.isy994 import (ISY994_NODES, ISY994_PROGRAMS, @@ -21,7 +21,6 @@ } -# pylint: disable=unused-argument def setup_platform(hass, config: ConfigType, add_devices: Callable[[list], None], discovery_info=None): """Set up the ISY994 lock platform.""" diff --git a/homeassistant/components/lock/kiwi.py b/homeassistant/components/lock/kiwi.py new file mode 100644 index 00000000000000..78ea45525f284c --- /dev/null +++ b/homeassistant/components/lock/kiwi.py @@ -0,0 +1,110 @@ +""" +Support for the KIWI.KI lock platform. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/lock.kiwi/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.lock import (LockDevice, PLATFORM_SCHEMA) +from homeassistant.const import ( + CONF_PASSWORD, CONF_USERNAME, ATTR_ID, ATTR_LONGITUDE, ATTR_LATITUDE, + STATE_LOCKED, STATE_UNLOCKED) +from homeassistant.helpers.event import async_call_later +from homeassistant.core import callback + +REQUIREMENTS = ['kiwiki-client==0.1.1'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_TYPE = 'hardware_type' +ATTR_PERMISSION = 'permission' +ATTR_CAN_INVITE = 'can_invite_others' + +UNLOCK_MAINTAIN_TIME = 5 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the KIWI lock platform.""" + from kiwiki import KiwiClient, KiwiException + try: + kiwi = KiwiClient(config[CONF_USERNAME], config[CONF_PASSWORD]) + except KiwiException as exc: + _LOGGER.error(exc) + return + available_locks = kiwi.get_locks() + if not available_locks: + # No locks found; abort setup routine. + _LOGGER.info("No KIWI locks found in your account.") + return + add_devices([KiwiLock(lock, kiwi) for lock in available_locks], True) + + +class KiwiLock(LockDevice): + """Representation of a Kiwi lock.""" + + def __init__(self, kiwi_lock, client): + """Initialize the lock.""" + self._sensor = kiwi_lock + self._client = client + self.lock_id = kiwi_lock['sensor_id'] + self._state = STATE_LOCKED + + address = kiwi_lock.get('address') + address.update({ + ATTR_LATITUDE: address.pop('lat', None), + ATTR_LONGITUDE: address.pop('lng', None) + }) + + self._device_attrs = { + ATTR_ID: self.lock_id, + ATTR_TYPE: kiwi_lock.get('hardware_type'), + ATTR_PERMISSION: kiwi_lock.get('highest_permission'), + ATTR_CAN_INVITE: kiwi_lock.get('can_invite'), + **address + } + + @property + def name(self): + """Return the name of the lock.""" + name = self._sensor.get('name') + specifier = self._sensor['address'].get('specifier') + return name or specifier + + @property + def is_locked(self): + """Return true if lock is locked.""" + return self._state == STATE_LOCKED + + @property + def device_state_attributes(self): + """Return the device specific state attributes.""" + return self._device_attrs + + @callback + def clear_unlock_state(self, _): + """Clear unlock state automatically.""" + self._state = STATE_LOCKED + self.async_schedule_update_ha_state() + + def unlock(self, **kwargs): + """Unlock the device.""" + from kiwiki import KiwiException + try: + self._client.open_door(self.lock_id) + except KiwiException: + _LOGGER.error("failed to open door") + else: + self._state = STATE_UNLOCKED + self.hass.add_job( + async_call_later, self.hass, UNLOCK_MAINTAIN_TIME, + self.clear_unlock_state + ) diff --git a/homeassistant/components/lock/lockitron.py b/homeassistant/components/lock/lockitron.py index ea79848f60ce11..6bf445ba477529 100644 --- a/homeassistant/components/lock/lockitron.py +++ b/homeassistant/components/lock/lockitron.py @@ -26,7 +26,6 @@ API_ACTION_URL = BASE_URL + '/v2/locks/{}?access_token={}&state={}' -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Lockitron platform.""" access_token = config.get(CONF_ACCESS_TOKEN) diff --git a/homeassistant/components/lock/mqtt.py b/homeassistant/components/lock/mqtt.py index d8af22cd5c3cb8..45029e679a5c7d 100644 --- a/homeassistant/components/lock/mqtt.py +++ b/homeassistant/components/lock/mqtt.py @@ -17,7 +17,7 @@ MqttAvailability) from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE) -import homeassistant.components.mqtt as mqtt +from homeassistant.components import mqtt import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/lock/nello.py b/homeassistant/components/lock/nello.py index 04030c92425774..f67243415c50f8 100644 --- a/homeassistant/components/lock/nello.py +++ b/homeassistant/components/lock/nello.py @@ -27,7 +27,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Nello lock platform.""" from pynello import Nello diff --git a/homeassistant/components/lock/nuki.py b/homeassistant/components/lock/nuki.py index 4fe05279919a60..536c8f2abeb794 100644 --- a/homeassistant/components/lock/nuki.py +++ b/homeassistant/components/lock/nuki.py @@ -50,7 +50,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Nuki lock platform.""" from pynuki import NukiBridge diff --git a/homeassistant/components/lock/sesame.py b/homeassistant/components/lock/sesame.py index 5bc404354860f7..8d9c05e3f26d71 100644 --- a/homeassistant/components/lock/sesame.py +++ b/homeassistant/components/lock/sesame.py @@ -4,7 +4,7 @@ For more details about this platform, please refer to the documentation https://home-assistant.io/components/lock.sesame/ """ -from typing import Callable # noqa +from typing import Callable import voluptuous as vol import homeassistant.helpers.config_validation as cv @@ -24,7 +24,6 @@ }) -# pylint: disable=unused-argument def setup_platform( hass, config: ConfigType, add_devices: Callable[[list], None], discovery_info=None): diff --git a/homeassistant/components/lock/vera.py b/homeassistant/components/lock/vera.py index b3aae5e159fafc..e6e277cdee1417 100644 --- a/homeassistant/components/lock/vera.py +++ b/homeassistant/components/lock/vera.py @@ -19,8 +19,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Find and return Vera locks.""" add_devices( - VeraLock(device, hass.data[VERA_CONTROLLER]) for - device in hass.data[VERA_DEVICES]['lock']) + [VeraLock(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['lock']], True) class VeraLock(VeraDevice, LockDevice): diff --git a/homeassistant/components/lock/volvooncall.py b/homeassistant/components/lock/volvooncall.py index ab1d2fabefe1c5..b6e7383b138251 100644 --- a/homeassistant/components/lock/volvooncall.py +++ b/homeassistant/components/lock/volvooncall.py @@ -12,7 +12,6 @@ _LOGGER = logging.getLogger(__name__) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Volvo On Call lock.""" if discovery_info is None: diff --git a/homeassistant/components/lock/wink.py b/homeassistant/components/lock/wink.py index a5cd18454df590..1c42e427a00c05 100644 --- a/homeassistant/components/lock/wink.py +++ b/homeassistant/components/lock/wink.py @@ -11,7 +11,8 @@ from homeassistant.components.lock import LockDevice from homeassistant.components.wink import DOMAIN, WinkDevice -from homeassistant.const import ATTR_CODE, ATTR_ENTITY_ID, STATE_UNKNOWN +from homeassistant.const import ( + ATTR_CODE, ATTR_ENTITY_ID, ATTR_NAME, STATE_UNKNOWN) import homeassistant.helpers.config_validation as cv DEPENDENCIES = ['wink'] @@ -28,7 +29,6 @@ ATTR_ENABLED = 'enabled' ATTR_SENSITIVITY = 'sensitivity' ATTR_MODE = 'mode' -ATTR_NAME = 'name' ALARM_SENSITIVITY_MAP = { 'low': 0.2, diff --git a/homeassistant/components/lock/xiaomi_aqara.py b/homeassistant/components/lock/xiaomi_aqara.py new file mode 100644 index 00000000000000..9b084a2bc55a73 --- /dev/null +++ b/homeassistant/components/lock/xiaomi_aqara.py @@ -0,0 +1,92 @@ +""" +Support for Xiaomi Aqara Lock. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/lock.xiaomi_aqara/ +""" +import logging +from homeassistant.components.xiaomi_aqara import (PY_XIAOMI_GATEWAY, + XiaomiDevice) +from homeassistant.components.lock import LockDevice +from homeassistant.const import (STATE_LOCKED, STATE_UNLOCKED) +from homeassistant.helpers.event import async_call_later +from homeassistant.core import callback + +_LOGGER = logging.getLogger(__name__) + +FINGER_KEY = 'fing_verified' +PASSWORD_KEY = 'psw_verified' +CARD_KEY = 'card_verified' +VERIFIED_WRONG_KEY = 'verified_wrong' + +ATTR_VERIFIED_WRONG_TIMES = 'verified_wrong_times' + +UNLOCK_MAINTAIN_TIME = 5 + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Perform the setup for Xiaomi devices.""" + devices = [] + + for gateway in hass.data[PY_XIAOMI_GATEWAY].gateways.values(): + for device in gateway.devices['lock']: + model = device['model'] + if model == 'lock.aq1': + devices.append(XiaomiAqaraLock(device, 'Lock', gateway)) + async_add_devices(devices) + + +class XiaomiAqaraLock(LockDevice, XiaomiDevice): + """Representation of a XiaomiAqaraLock.""" + + def __init__(self, device, name, xiaomi_hub): + """Initialize the XiaomiAqaraLock.""" + self._changed_by = 0 + self._verified_wrong_times = 0 + + super().__init__(device, name, xiaomi_hub) + + @property + def is_locked(self) -> bool: + """Return true if lock is locked.""" + if self._state is not None: + return self._state == STATE_LOCKED + + @property + def changed_by(self) -> int: + """Last change triggered by.""" + return self._changed_by + + @property + def device_state_attributes(self) -> dict: + """Return the state attributes.""" + attributes = { + ATTR_VERIFIED_WRONG_TIMES: self._verified_wrong_times, + } + return attributes + + @callback + def clear_unlock_state(self, _): + """Clear unlock state automatically.""" + self._state = STATE_LOCKED + self.async_schedule_update_ha_state() + + def parse_data(self, data, raw_data): + """Parse data sent by gateway.""" + value = data.get(VERIFIED_WRONG_KEY) + if value is not None: + self._verified_wrong_times = int(value) + return True + + for key in (FINGER_KEY, PASSWORD_KEY, CARD_KEY): + value = data.get(key) + if value is not None: + self._changed_by = int(value) + self._verified_wrong_times = 0 + self._state = STATE_UNLOCKED + async_call_later(self.hass, UNLOCK_MAINTAIN_TIME, + self.clear_unlock_state) + return True + + return False diff --git a/homeassistant/components/lock/zwave.py b/homeassistant/components/lock/zwave.py index 8f39d440caed8e..b7bc9f15e19953 100644 --- a/homeassistant/components/lock/zwave.py +++ b/homeassistant/components/lock/zwave.py @@ -4,8 +4,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/lock.zwave/ """ -# Because we do not compile openzwave on CI -# pylint: disable=import-error import asyncio import logging diff --git a/homeassistant/components/logbook.py b/homeassistant/components/logbook.py index 8bab6fe04402bd..c4fcf53a9c1f01 100644 --- a/homeassistant/components/logbook.py +++ b/homeassistant/components/logbook.py @@ -4,44 +4,49 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/logbook/ """ -import logging from datetime import timedelta from itertools import groupby +import logging import voluptuous as vol -from homeassistant.core import callback -import homeassistant.helpers.config_validation as cv -import homeassistant.util.dt as dt_util from homeassistant.components import sun from homeassistant.components.http import HomeAssistantView from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, - STATE_NOT_HOME, STATE_OFF, STATE_ON, ATTR_HIDDEN, HTTP_BAD_REQUEST, - EVENT_LOGBOOK_ENTRY) -from homeassistant.core import State, split_entity_id, DOMAIN as HA_DOMAIN - -DOMAIN = 'logbook' -DEPENDENCIES = ['recorder', 'frontend'] + ATTR_DOMAIN, ATTR_ENTITY_ID, ATTR_HIDDEN, ATTR_NAME, CONF_EXCLUDE, + CONF_INCLUDE, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, + EVENT_LOGBOOK_ENTRY, EVENT_STATE_CHANGED, HTTP_BAD_REQUEST, STATE_NOT_HOME, + STATE_OFF, STATE_ON) +from homeassistant.core import DOMAIN as HA_DOMAIN +from homeassistant.core import State, callback, split_entity_id +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) -CONF_EXCLUDE = 'exclude' -CONF_INCLUDE = 'include' -CONF_ENTITIES = 'entities' +ATTR_MESSAGE = 'message' + CONF_DOMAINS = 'domains' +CONF_ENTITIES = 'entities' +CONTINUOUS_DOMAINS = ['proximity', 'sensor'] + +DEPENDENCIES = ['recorder', 'frontend'] + +DOMAIN = 'logbook' + +GROUP_BY_MINUTES = 15 CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ CONF_EXCLUDE: vol.Schema({ vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, - vol.Optional(CONF_DOMAINS, default=[]): vol.All(cv.ensure_list, - [cv.string]) + vol.Optional(CONF_DOMAINS, default=[]): + vol.All(cv.ensure_list, [cv.string]) }), CONF_INCLUDE: vol.Schema({ vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, - vol.Optional(CONF_DOMAINS, default=[]): vol.All(cv.ensure_list, - [cv.string]) + vol.Optional(CONF_DOMAINS, default=[]): + vol.All(cv.ensure_list, [cv.string]) }) }), }, extra=vol.ALLOW_EXTRA) @@ -51,15 +56,6 @@ EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP ] -GROUP_BY_MINUTES = 15 - -CONTINUOUS_DOMAINS = ['proximity', 'sensor'] - -ATTR_NAME = 'name' -ATTR_MESSAGE = 'message' -ATTR_DOMAIN = 'domain' -ATTR_ENTITY_ID = 'entity_id' - LOG_MESSAGE_SCHEMA = vol.Schema({ vol.Required(ATTR_NAME): cv.string, vol.Required(ATTR_MESSAGE): cv.template, @@ -87,7 +83,7 @@ def async_log_entry(hass, name, message, domain=None, entity_id=None): hass.bus.async_fire(EVENT_LOGBOOK_ENTRY, data) -async def setup(hass, config): +async def async_setup(hass, config): """Listen for download events to download files.""" @callback def log_message(service): @@ -104,7 +100,7 @@ def log_message(service): hass.http.register_view(LogbookView(config.get(DOMAIN, {}))) await hass.components.frontend.async_register_built_in_panel( - 'logbook', 'logbook', 'mdi:format-list-bulleted-type') + 'logbook', 'logbook', 'hass:format-list-bulleted-type') hass.services.async_register( DOMAIN, 'log', log_message, schema=LOG_MESSAGE_SCHEMA) @@ -144,7 +140,7 @@ def json_events(): return await hass.async_add_job(json_events) -class Entry(object): +class Entry: """A human readable version of the log.""" def __init__(self, when=None, name=None, message=None, domain=None, @@ -376,7 +372,6 @@ def _exclude_events(events, config): return filtered_events -# pylint: disable=too-many-return-statements def _entry_message_from_state(domain, state): """Convert a state to a message for the logbook.""" # We pass domain in so we don't have to split entity_id again @@ -385,16 +380,16 @@ def _entry_message_from_state(domain, state): return 'is away' return 'is at {}'.format(state.state) - elif domain == 'sun': + if domain == 'sun': if state.state == sun.STATE_ABOVE_HORIZON: return 'has risen' return 'has set' - elif state.state == STATE_ON: + if state.state == STATE_ON: # Future: combine groups and its entity entries ? return "turned on" - elif state.state == STATE_OFF: + if state.state == STATE_OFF: return "turned off" return "changed to {}".format(state.state) diff --git a/homeassistant/components/logger.py b/homeassistant/components/logger.py index 6e8995a0444cd7..0baca2f341c8c0 100644 --- a/homeassistant/components/logger.py +++ b/homeassistant/components/logger.py @@ -15,6 +15,7 @@ DATA_LOGGER = 'logger' +SERVICE_SET_DEFAULT_LEVEL = 'set_default_level' SERVICE_SET_LEVEL = 'set_level' LOGSEVERITY = { @@ -31,8 +32,11 @@ LOGGER_DEFAULT = 'default' LOGGER_LOGS = 'logs' +ATTR_LEVEL = 'level' + _VALID_LOG_LEVEL = vol.All(vol.Upper, vol.In(LOGSEVERITY)) +SERVICE_SET_DEFAULT_LEVEL_SCHEMA = vol.Schema({ATTR_LEVEL: _VALID_LOG_LEVEL}) SERVICE_SET_LEVEL_SCHEMA = vol.Schema({cv.string: _VALID_LOG_LEVEL}) CONFIG_SCHEMA = vol.Schema({ @@ -51,7 +55,6 @@ def set_level(hass, logs): class HomeAssistantLogFilter(logging.Filter): """A log filter.""" - # pylint: disable=no-init def __init__(self, logfilter): """Initialize the filter.""" super().__init__() @@ -76,12 +79,9 @@ async def async_setup(hass, config): """Set up the logger component.""" logfilter = {} - # Set default log severity - logfilter[LOGGER_DEFAULT] = LOGSEVERITY['DEBUG'] - if LOGGER_DEFAULT in config.get(DOMAIN): - logfilter[LOGGER_DEFAULT] = LOGSEVERITY[ - config.get(DOMAIN)[LOGGER_DEFAULT] - ] + def set_default_log_level(level): + """Set the default log level for components.""" + logfilter[LOGGER_DEFAULT] = LOGSEVERITY[level] def set_log_levels(logpoints): """Set the specified log levels.""" @@ -103,6 +103,12 @@ def set_log_levels(logpoints): ) ) + # Set default log severity + if LOGGER_DEFAULT in config.get(DOMAIN): + set_default_log_level(config.get(DOMAIN)[LOGGER_DEFAULT]) + else: + set_default_log_level('DEBUG') + logger = logging.getLogger('') logger.setLevel(logging.NOTSET) @@ -116,7 +122,14 @@ def set_log_levels(logpoints): async def async_service_handler(service): """Handle logger services.""" - set_log_levels(service.data) + if service.service == SERVICE_SET_DEFAULT_LEVEL: + set_default_log_level(service.data.get(ATTR_LEVEL)) + else: + set_log_levels(service.data) + + hass.services.async_register( + DOMAIN, SERVICE_SET_DEFAULT_LEVEL, async_service_handler, + schema=SERVICE_SET_DEFAULT_LEVEL_SCHEMA) hass.services.async_register( DOMAIN, SERVICE_SET_LEVEL, async_service_handler, diff --git a/homeassistant/components/lutron_caseta.py b/homeassistant/components/lutron_caseta.py index 7b1b7417cfd984..2535fb76120907 100644 --- a/homeassistant/components/lutron_caseta.py +++ b/homeassistant/components/lutron_caseta.py @@ -63,8 +63,8 @@ def async_setup(hass, base_config): _LOGGER.info("Connected to Lutron smartbridge at %s", config[CONF_HOST]) for component in LUTRON_CASETA_COMPONENTS: - hass.async_add_job(discovery.async_load_platform(hass, component, - DOMAIN, {}, config)) + hass.async_create_task(discovery.async_load_platform( + hass, component, DOMAIN, {}, config)) return True diff --git a/homeassistant/components/mailbox/__init__.py b/homeassistant/components/mailbox/__init__.py index 8ff3746889e42e..6a648e4dc47774 100644 --- a/homeassistant/components/mailbox/__init__.py +++ b/homeassistant/components/mailbox/__init__.py @@ -132,7 +132,7 @@ def async_update(self): self.message_count = len(messages) -class Mailbox(object): +class Mailbox: """Represent a mailbox device.""" def __init__(self, hass, name): diff --git a/homeassistant/components/mailbox/demo.py b/homeassistant/components/mailbox/demo.py index ccb371de2f8ac6..8096a4fabb7029 100644 --- a/homeassistant/components/mailbox/demo.py +++ b/homeassistant/components/mailbox/demo.py @@ -9,7 +9,7 @@ import os from hashlib import sha1 -import homeassistant.util.dt as dt +from homeassistant.util import dt from homeassistant.components.mailbox import (Mailbox, CONTENT_TYPE_MPEG, StreamError) diff --git a/homeassistant/components/mailgun.py b/homeassistant/components/mailgun.py index ec480ac12d6061..7cb7ef7151dc37 100644 --- a/homeassistant/components/mailgun.py +++ b/homeassistant/components/mailgun.py @@ -48,4 +48,3 @@ def post(self, request): # pylint: disable=no-self-use hass = request.app['hass'] data = yield from request.post() hass.bus.async_fire(MESSAGE_RECEIVED, dict(data)) - return diff --git a/homeassistant/components/map.py b/homeassistant/components/map.py index 30cb00af69ea98..c0184239a1aefe 100644 --- a/homeassistant/components/map.py +++ b/homeassistant/components/map.py @@ -10,5 +10,5 @@ async def async_setup(hass, config): """Register the built-in map panel.""" await hass.components.frontend.async_register_built_in_panel( - 'map', 'map', 'mdi:account-location') + 'map', 'map', 'hass:account-location') return True diff --git a/homeassistant/components/matrix.py b/homeassistant/components/matrix.py index b2805c994e8739..5f6c30aaeba5f5 100644 --- a/homeassistant/components/matrix.py +++ b/homeassistant/components/matrix.py @@ -96,7 +96,7 @@ def setup(hass, config): return True -class MatrixBot(object): +class MatrixBot: """The Matrix Bot.""" def __init__(self, hass, config_file, homeserver, verify_ssl, diff --git a/homeassistant/components/maxcube.py b/homeassistant/components/maxcube.py index bca7a1b4ab7eca..b574f0bcb152b8 100644 --- a/homeassistant/components/maxcube.py +++ b/homeassistant/components/maxcube.py @@ -79,7 +79,7 @@ def setup(hass, config): return True -class MaxCubeHandle(object): +class MaxCubeHandle: """Keep the cube instance in one place and centralize the update.""" def __init__(self, cube, scan_interval): diff --git a/homeassistant/components/media_extractor.py b/homeassistant/components/media_extractor.py index fe6ebe8e618b27..793d33e52fafb5 100644 --- a/homeassistant/components/media_extractor.py +++ b/homeassistant/components/media_extractor.py @@ -14,7 +14,7 @@ SERVICE_PLAY_MEDIA) from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2018.04.25'] +REQUIREMENTS = ['youtube_dl==2018.08.04'] _LOGGER = logging.getLogger(__name__) @@ -58,7 +58,7 @@ class MEQueryException(Exception): pass -class MediaExtractor(object): +class MediaExtractor: """Class which encapsulates all extraction logic.""" def __init__(self, hass, component_config, call_data): diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index 20a1a473ba8bf9..c475291227ac62 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -6,12 +6,13 @@ """ import asyncio import base64 +import collections from datetime import timedelta import functools as ft -import collections import hashlib import logging from random import SystemRandom +from urllib.parse import urlparse from aiohttp import web from aiohttp.hdrs import CONTENT_TYPE, CACHE_CONTROL @@ -57,6 +58,7 @@ SERVICE_PLAY_MEDIA = 'play_media' SERVICE_SELECT_SOURCE = 'select_source' +SERVICE_SELECT_SOUND_MODE = 'select_sound_mode' SERVICE_CLEAR_PLAYLIST = 'clear_playlist' ATTR_MEDIA_VOLUME_LEVEL = 'volume_level' @@ -81,6 +83,8 @@ ATTR_APP_NAME = 'app_name' ATTR_INPUT_SOURCE = 'source' ATTR_INPUT_SOURCE_LIST = 'source_list' +ATTR_SOUND_MODE = 'sound_mode' +ATTR_SOUND_MODE_LIST = 'sound_mode_list' ATTR_MEDIA_ENQUEUE = 'enqueue' ATTR_MEDIA_SHUFFLE = 'shuffle' @@ -109,6 +113,7 @@ SUPPORT_CLEAR_PLAYLIST = 8192 SUPPORT_PLAY = 16384 SUPPORT_SHUFFLE_SET = 32768 +SUPPORT_SELECT_SOUND_MODE = 65536 # Service call validation schemas MEDIA_PLAYER_SCHEMA = vol.Schema({ @@ -132,6 +137,10 @@ vol.Required(ATTR_INPUT_SOURCE): cv.string, }) +MEDIA_PLAYER_SELECT_SOUND_MODE_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ + vol.Required(ATTR_SOUND_MODE): cv.string, +}) + MEDIA_PLAYER_PLAY_MEDIA_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ vol.Required(ATTR_MEDIA_CONTENT_TYPE): cv.string, vol.Required(ATTR_MEDIA_CONTENT_ID): cv.string, @@ -167,6 +176,9 @@ SERVICE_SELECT_SOURCE: { 'method': 'async_select_source', 'schema': MEDIA_PLAYER_SELECT_SOURCE_SCHEMA}, + SERVICE_SELECT_SOUND_MODE: { + 'method': 'async_select_sound_mode', + 'schema': MEDIA_PLAYER_SELECT_SOUND_MODE_SCHEMA}, SERVICE_PLAY_MEDIA: { 'method': 'async_play_media', 'schema': MEDIA_PLAYER_PLAY_MEDIA_SCHEMA}, @@ -197,6 +209,8 @@ ATTR_APP_NAME, ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, + ATTR_SOUND_MODE, + ATTR_SOUND_MODE_LIST, ATTR_MEDIA_SHUFFLE, ] @@ -346,6 +360,17 @@ def select_source(hass, source, entity_id=None): hass.services.call(DOMAIN, SERVICE_SELECT_SOURCE, data) +@bind_hass +def select_sound_mode(hass, sound_mode, entity_id=None): + """Send the media player the command to select sound mode.""" + data = {ATTR_SOUND_MODE: sound_mode} + + if entity_id: + data[ATTR_ENTITY_ID] = entity_id + + hass.services.call(DOMAIN, SERVICE_SELECT_SOUND_MODE, data) + + @bind_hass def clear_playlist(hass, entity_id=None): """Send the media player the command for clear playlist.""" @@ -399,6 +424,8 @@ async def async_service_handler(service): params['position'] = service.data.get(ATTR_MEDIA_SEEK_POSITION) elif service.service == SERVICE_SELECT_SOURCE: params['source'] = service.data.get(ATTR_INPUT_SOURCE) + elif service.service == SERVICE_SELECT_SOUND_MODE: + params['sound_mode'] = service.data.get(ATTR_SOUND_MODE) elif service.service == SERVICE_PLAY_MEDIA: params['media_type'] = \ service.data.get(ATTR_MEDIA_CONTENT_TYPE) @@ -430,12 +457,21 @@ async def async_service_handler(service): return True +async def async_setup_entry(hass, entry): + """Setup a config entry.""" + return await hass.data[DOMAIN].async_setup_entry(entry) + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + class MediaPlayerDevice(Entity): """ABC for media player devices.""" _access_token = None - # pylint: disable=no-self-use # Implement these for your media player @property def state(self): @@ -580,6 +616,16 @@ def source_list(self): """List of available input sources.""" return None + @property + def sound_mode(self): + """Name of the current sound mode.""" + return None + + @property + def sound_mode_list(self): + """List of available sound modes.""" + return None + @property def shuffle(self): """Boolean if shuffle is enabled.""" @@ -723,6 +769,17 @@ def async_select_source(self, source): """ return self.hass.async_add_job(self.select_source, source) + def select_sound_mode(self, sound_mode): + """Select sound mode.""" + raise NotImplementedError() + + def async_select_sound_mode(self, sound_mode): + """Select sound mode. + + This method must be run in the event loop and returns a coroutine. + """ + return self.hass.async_add_job(self.select_sound_mode, sound_mode) + def clear_playlist(self): """Clear players playlist.""" raise NotImplementedError() @@ -796,6 +853,11 @@ def support_select_source(self): """Boolean if select source command supported.""" return bool(self.supported_features & SUPPORT_SELECT_SOURCE) + @property + def support_select_sound_mode(self): + """Boolean if select sound mode command supported.""" + return bool(self.supported_features & SUPPORT_SELECT_SOUND_MODE) + @property def support_clear_playlist(self): """Boolean if clear playlist command supported.""" @@ -895,6 +957,9 @@ async def _async_fetch_image(hass, url): cache_images = ENTITY_IMAGE_CACHE[CACHE_IMAGES] cache_maxsize = ENTITY_IMAGE_CACHE[CACHE_MAXSIZE] + if urlparse(url).hostname is None: + url = hass.config.api.base_url + url + if url not in cache_images: cache_images[url] = {CACHE_LOCK: asyncio.Lock(loop=hass.loop)} diff --git a/homeassistant/components/media_player/anthemav.py b/homeassistant/components/media_player/anthemav.py index 474751c2574fef..a74629917b34af 100644 --- a/homeassistant/components/media_player/anthemav.py +++ b/homeassistant/components/media_player/anthemav.py @@ -100,7 +100,7 @@ def state(self): if pwrstate is True: return STATE_ON - elif pwrstate is False: + if pwrstate is False: return STATE_OFF return STATE_UNKNOWN diff --git a/homeassistant/components/media_player/apple_tv.py b/homeassistant/components/media_player/apple_tv.py index 37a50b39e950ea..d4a7ad198072ab 100644 --- a/homeassistant/components/media_player/apple_tv.py +++ b/homeassistant/components/media_player/apple_tv.py @@ -100,15 +100,14 @@ def state(self): if self._playing: from pyatv import const state = self._playing.play_state - if state == const.PLAY_STATE_IDLE or \ - state == const.PLAY_STATE_NO_MEDIA or \ - state == const.PLAY_STATE_LOADING: + if state in (const.PLAY_STATE_IDLE, const.PLAY_STATE_NO_MEDIA, + const.PLAY_STATE_LOADING): return STATE_IDLE - elif state == const.PLAY_STATE_PLAYING: + if state == const.PLAY_STATE_PLAYING: return STATE_PLAYING - elif state == const.PLAY_STATE_PAUSED or \ - state == const.PLAY_STATE_FAST_FORWARD or \ - state == const.PLAY_STATE_FAST_BACKWARD: + if state in (const.PLAY_STATE_PAUSED, + const.PLAY_STATE_FAST_FORWARD, + const.PLAY_STATE_FAST_BACKWARD): # Catch fast forward/backward here so "play" is default action return STATE_PAUSED return STATE_STANDBY # Bad or unknown state? @@ -141,9 +140,9 @@ def media_content_type(self): media_type = self._playing.media_type if media_type == const.MEDIA_TYPE_VIDEO: return MEDIA_TYPE_VIDEO - elif media_type == const.MEDIA_TYPE_MUSIC: + if media_type == const.MEDIA_TYPE_MUSIC: return MEDIA_TYPE_MUSIC - elif media_type == const.MEDIA_TYPE_TV: + if media_type == const.MEDIA_TYPE_TV: return MEDIA_TYPE_TVSHOW @property @@ -162,7 +161,7 @@ def media_position(self): def media_position_updated_at(self): """Last valid time of media position.""" state = self.state - if state == STATE_PLAYING or state == STATE_PAUSED: + if state in (STATE_PLAYING, STATE_PAUSED): return dt_util.utcnow() @asyncio.coroutine @@ -222,7 +221,7 @@ def async_media_play_pause(self): state = self.state if state == STATE_PAUSED: return self.atv.remote_control.play() - elif state == STATE_PLAYING: + if state == STATE_PLAYING: return self.atv.remote_control.pause() def async_media_play(self): diff --git a/homeassistant/components/media_player/aquostv.py b/homeassistant/components/media_player/aquostv.py index 6933286f0fe584..93daf5b2f893d6 100644 --- a/homeassistant/components/media_player/aquostv.py +++ b/homeassistant/components/media_player/aquostv.py @@ -59,7 +59,6 @@ 8: 'PC_IN'} -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Sharp Aquos TV platform.""" import sharp_aquos_rc @@ -104,7 +103,6 @@ def wrapper(obj, *args, **kwargs): return wrapper -# pylint: disable=abstract-method class SharpAquosTVDevice(MediaPlayerDevice): """Representation of a Aquos TV.""" diff --git a/homeassistant/components/media_player/blackbird.py b/homeassistant/components/media_player/blackbird.py index 1c976f5eecd32b..3d8e1fde687f74 100644 --- a/homeassistant/components/media_player/blackbird.py +++ b/homeassistant/components/media_player/blackbird.py @@ -61,7 +61,6 @@ })) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Monoprice Blackbird 4k 8x8 HDBaseT Matrix platform.""" if DATA_BLACKBIRD not in hass.data: diff --git a/homeassistant/components/media_player/bluesound.py b/homeassistant/components/media_player/bluesound.py index 283c4af032e63b..5631ec06cf1ab4 100644 --- a/homeassistant/components/media_player/bluesound.py +++ b/homeassistant/components/media_player/bluesound.py @@ -216,12 +216,8 @@ def _try_get_index(string, search_string): async def force_update_sync_status( self, on_updated_cb=None, raise_timeout=False): """Update the internal status.""" - resp = None - try: - resp = await self.send_bluesound_command( - 'SyncStatus', raise_timeout, raise_timeout) - except Exception: - raise + resp = await self.send_bluesound_command( + 'SyncStatus', raise_timeout, raise_timeout) if not resp: return None @@ -333,10 +329,10 @@ async def send_bluesound_command( if response.status == 200: result = await response.text() - if len(result) < 1: - data = None - else: + if result: data = xmltodict.parse(result) + else: + data = None elif response.status == 595: _LOGGER.info("Status 595 returned, treating as timeout") raise BluesoundPlayer._TimeoutException() @@ -528,9 +524,9 @@ def state(self): return STATE_GROUPED status = self._status.get('state', None) - if status == 'pause' or status == 'stop': + if status in ('pause', 'stop'): return STATE_PAUSED - elif status == 'stream' or status == 'play': + if status in ('stream', 'play'): return STATE_PLAYING return STATE_IDLE @@ -640,7 +636,7 @@ def is_volume_muted(self): volume = self.volume_level if not volume: return None - return volume < 0.001 and volume >= 0 + return 0 <= volume < 0.001 @property def name(self): @@ -847,12 +843,12 @@ async def async_select_source(self, source): items = [x for x in self._preset_items if x['title'] == source] - if len(items) < 1: + if not items: items = [x for x in self._services_items if x['title'] == source] - if len(items) < 1: + if not items: items = [x for x in self._capture_items if x['title'] == source] - if len(items) < 1: + if not items: return selected_source = items[0] @@ -974,6 +970,5 @@ async def async_mute_volume(self, mute): if volume > 0: self._lastvol = volume return await self.send_bluesound_command('Volume?level=0') - else: - return await self.send_bluesound_command( - 'Volume?level=' + str(float(self._lastvol) * 100)) + return await self.send_bluesound_command( + 'Volume?level=' + str(float(self._lastvol) * 100)) diff --git a/homeassistant/components/media_player/braviatv.py b/homeassistant/components/media_player/braviatv.py index f0cc93a8b0f3f2..07a379db45cde7 100644 --- a/homeassistant/components/media_player/braviatv.py +++ b/homeassistant/components/media_player/braviatv.py @@ -18,9 +18,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util.json import load_json, save_json -REQUIREMENTS = [ - 'https://github.com/aparraga/braviarc/archive/0.3.7.zip' - '#braviarc==0.3.7'] +REQUIREMENTS = ['braviarc-homeassistant==0.3.7.dev0'] BRAVIA_CONFIG_FILE = 'bravia.conf' @@ -60,7 +58,6 @@ def _get_mac_address(ip_address): return None -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Sony Bravia TV platform.""" host = config.get(CONF_HOST) @@ -91,23 +88,23 @@ def setup_bravia(config, pin, hass, add_devices): if pin is None: request_configuration(config, hass, add_devices) return - else: - mac = _get_mac_address(host) - if mac is not None: - mac = mac.decode('utf8') - # If we came here and configuring this host, mark as done - if host in _CONFIGURING: - request_id = _CONFIGURING.pop(host) - configurator = hass.components.configurator - configurator.request_done(request_id) - _LOGGER.info("Discovery configuration done") - - # Save config - save_json( - hass.config.path(BRAVIA_CONFIG_FILE), - {host: {'pin': pin, 'host': host, 'mac': mac}}) - - add_devices([BraviaTVDevice(host, mac, name, pin)]) + + mac = _get_mac_address(host) + if mac is not None: + mac = mac.decode('utf8') + # If we came here and configuring this host, mark as done + if host in _CONFIGURING: + request_id = _CONFIGURING.pop(host) + configurator = hass.components.configurator + configurator.request_done(request_id) + _LOGGER.info("Discovery configuration done") + + # Save config + save_json( + hass.config.path(BRAVIA_CONFIG_FILE), + {host: {'pin': pin, 'host': host, 'mac': mac}}) + + add_devices([BraviaTVDevice(host, mac, name, pin)]) def request_configuration(config, hass, add_devices): diff --git a/homeassistant/components/media_player/cast.py b/homeassistant/components/media_player/cast.py index a9bea9e4c1d137..099b365c50b159 100644 --- a/homeassistant/components/media_player/cast.py +++ b/homeassistant/components/media_player/cast.py @@ -4,7 +4,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.cast/ """ -# pylint: disable=import-error +import asyncio import logging import threading from typing import Optional, Tuple @@ -17,6 +17,7 @@ from homeassistant.core import callback from homeassistant.helpers.dispatcher import (dispatcher_send, async_dispatcher_connect) +from homeassistant.components.cast import DOMAIN as CAST_DOMAIN from homeassistant.components.media_player import ( MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_MOVIE, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, @@ -28,7 +29,7 @@ import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['pychromecast==2.1.0'] +DEPENDENCIES = ('cast',) _LOGGER = logging.getLogger(__name__) @@ -62,7 +63,7 @@ @attr.s(slots=True, frozen=True) -class ChromecastInfo(object): +class ChromecastInfo: """Class to hold all data about a chromecast for creating connections. This also has the same attributes as the mDNS fields by zeroconf. @@ -186,6 +187,30 @@ def _async_create_cast_device(hass: HomeAssistantType, async def async_setup_platform(hass: HomeAssistantType, config: ConfigType, async_add_devices, discovery_info=None): + """Set up thet Cast platform. + + Deprecated. + """ + _LOGGER.warning( + 'Setting configuration for Cast via platform is deprecated. ' + 'Configure via Cast component instead.') + await _async_setup_platform( + hass, config, async_add_devices, discovery_info) + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up Cast from a config entry.""" + config = hass.data[CAST_DOMAIN].get('media_player', {}) + if not isinstance(config, list): + config = [config] + + await asyncio.wait([ + _async_setup_platform(hass, cfg, async_add_devices, None) + for cfg in config]) + + +async def _async_setup_platform(hass: HomeAssistantType, config: ConfigType, + async_add_devices, discovery_info): """Set up the cast platform.""" import pychromecast @@ -233,7 +258,7 @@ def async_cast_discovered(discover: ChromecastInfo) -> None: hass.async_add_job(_discover_chromecast, hass, info) -class CastStatusListener(object): +class CastStatusListener: """Helper class to handle pychromecast status callbacks. Necessary because a CastDevice entity can create a new socket client @@ -474,13 +499,13 @@ def state(self): """Return the state of the player.""" if self.media_status is None: return None - elif self.media_status.player_is_playing: + if self.media_status.player_is_playing: return STATE_PLAYING - elif self.media_status.player_is_paused: + if self.media_status.player_is_paused: return STATE_PAUSED - elif self.media_status.player_is_idle: + if self.media_status.player_is_idle: return STATE_IDLE - elif self._chromecast is not None and self._chromecast.is_idle: + if self._chromecast is not None and self._chromecast.is_idle: return STATE_OFF return None @@ -509,11 +534,11 @@ def media_content_type(self): """Content type of current playing media.""" if self.media_status is None: return None - elif self.media_status.media_is_tvshow: + if self.media_status.media_is_tvshow: return MEDIA_TYPE_TVSHOW - elif self.media_status.media_is_movie: + if self.media_status.media_is_movie: return MEDIA_TYPE_MOVIE - elif self.media_status.media_is_musictrack: + if self.media_status.media_is_musictrack: return MEDIA_TYPE_MUSIC return None diff --git a/homeassistant/components/media_player/channels.py b/homeassistant/components/media_player/channels.py index 6b41ace6ce21b5..6ccc60617031db 100644 --- a/homeassistant/components/media_player/channels.py +++ b/homeassistant/components/media_player/channels.py @@ -105,7 +105,6 @@ def service_handler(service): class ChannelsPlayer(MediaPlayerDevice): """Representation of a Channels instance.""" - # pylint: disable=too-many-public-methods def __init__(self, name, host, port): """Initialize the Channels app.""" from pychannels import Channels @@ -218,7 +217,7 @@ def media_image_url(self): """Image url of current playing media.""" if self.now_playing_image_url: return self.now_playing_image_url - elif self.channel_image_url: + if self.channel_image_url: return self.channel_image_url return 'https://getchannels.com/assets/img/icon-1024.png' diff --git a/homeassistant/components/media_player/clementine.py b/homeassistant/components/media_player/clementine.py index 6847b87e54f7c7..1ee18576ab8c85 100644 --- a/homeassistant/components/media_player/clementine.py +++ b/homeassistant/components/media_player/clementine.py @@ -43,7 +43,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Clementine platform.""" from clementineremote import ClementineRemote diff --git a/homeassistant/components/media_player/cmus.py b/homeassistant/components/media_player/cmus.py index bcbee5c4ff7b5c..978a1088aa6b7c 100644 --- a/homeassistant/components/media_player/cmus.py +++ b/homeassistant/components/media_player/cmus.py @@ -19,7 +19,7 @@ CONF_PASSWORD) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pycmus==0.1.0'] +REQUIREMENTS = ['pycmus==0.1.1'] _LOGGER = logging.getLogger(__name__) @@ -91,7 +91,7 @@ def state(self): """Return the media state.""" if self.status.get('status') == 'playing': return STATE_PLAYING - elif self.status.get('status') == 'paused': + if self.status.get('status') == 'paused': return STATE_PAUSED return STATE_OFF diff --git a/homeassistant/components/media_player/demo.py b/homeassistant/components/media_player/demo.py index 22fe1d005f711f..9edf69cd9c69ef 100644 --- a/homeassistant/components/media_player/demo.py +++ b/homeassistant/components/media_player/demo.py @@ -8,13 +8,12 @@ MEDIA_TYPE_MUSIC, MEDIA_TYPE_TVSHOW, MEDIA_TYPE_MOVIE, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, - SUPPORT_SELECT_SOURCE, SUPPORT_CLEAR_PLAYLIST, SUPPORT_PLAY, - SUPPORT_SHUFFLE_SET, MediaPlayerDevice) + SUPPORT_SELECT_SOURCE, SUPPORT_SELECT_SOUND_MODE, SUPPORT_CLEAR_PLAYLIST, + SUPPORT_PLAY, SUPPORT_SHUFFLE_SET, MediaPlayerDevice) from homeassistant.const import STATE_OFF, STATE_PAUSED, STATE_PLAYING import homeassistant.util.dt as dt_util -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the media player demo platform.""" add_devices([ @@ -28,22 +27,26 @@ def setup_platform(hass, config, add_devices, discovery_info=None): YOUTUBE_COVER_URL_FORMAT = 'https://img.youtube.com/vi/{}/hqdefault.jpg' +SOUND_MODE_LIST = ['Dummy Music', 'Dummy Movie'] +DEFAULT_SOUND_MODE = 'Dummy Music' YOUTUBE_PLAYER_SUPPORT = \ SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PLAY_MEDIA | SUPPORT_PLAY | \ - SUPPORT_SHUFFLE_SET + SUPPORT_SHUFFLE_SET | SUPPORT_SELECT_SOUND_MODE MUSIC_PLAYER_SUPPORT = \ SUPPORT_PAUSE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_CLEAR_PLAYLIST | \ SUPPORT_PLAY | SUPPORT_SHUFFLE_SET | \ - SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ + SUPPORT_SELECT_SOUND_MODE NETFLIX_PLAYER_SUPPORT = \ SUPPORT_PAUSE | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ SUPPORT_SELECT_SOURCE | SUPPORT_PLAY | SUPPORT_SHUFFLE_SET | \ - SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK + SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | \ + SUPPORT_SELECT_SOUND_MODE class AbstractDemoPlayer(MediaPlayerDevice): @@ -58,6 +61,8 @@ def __init__(self, name): self._volume_level = 1.0 self._volume_muted = False self._shuffle = False + self._sound_mode_list = SOUND_MODE_LIST + self._sound_mode = DEFAULT_SOUND_MODE @property def should_poll(self): @@ -89,6 +94,16 @@ def shuffle(self): """Boolean if shuffling is enabled.""" return self._shuffle + @property + def sound_mode(self): + """Return the current sound mode.""" + return self._sound_mode + + @property + def sound_mode_list(self): + """Return a list of available sound modes.""" + return self._sound_mode_list + def turn_on(self): """Turn the media player on.""" self._player_state = STATE_PLAYING @@ -124,6 +139,11 @@ def set_shuffle(self, shuffle): self._shuffle = shuffle self.schedule_update_ha_state() + def select_sound_mode(self, sound_mode): + """Select sound mode.""" + self._sound_mode = sound_mode + self.schedule_update_ha_state() + class DemoYoutubePlayer(AbstractDemoPlayer): """A Demo media player that only supports YouTube.""" @@ -275,7 +295,6 @@ def media_artist(self): @property def media_album_name(self): """Return the album of current playing media (Music track only).""" - # pylint: disable=no-self-use return "Bounzz" @property diff --git a/homeassistant/components/media_player/denonavr.py b/homeassistant/components/media_player/denonavr.py index fe8fc46c24b260..604fb91451e1e5 100644 --- a/homeassistant/components/media_player/denonavr.py +++ b/homeassistant/components/media_player/denonavr.py @@ -12,19 +12,19 @@ from homeassistant.components.media_player import ( SUPPORT_PAUSE, SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, - SUPPORT_SELECT_SOURCE, SUPPORT_PLAY_MEDIA, MEDIA_TYPE_CHANNEL, - MediaPlayerDevice, PLATFORM_SCHEMA, SUPPORT_TURN_ON, - MEDIA_TYPE_MUSIC, SUPPORT_VOLUME_SET, SUPPORT_PLAY) + SUPPORT_SELECT_SOURCE, SUPPORT_SELECT_SOUND_MODE, + SUPPORT_PLAY_MEDIA, MEDIA_TYPE_CHANNEL, MediaPlayerDevice, + PLATFORM_SCHEMA, SUPPORT_TURN_ON, MEDIA_TYPE_MUSIC, + SUPPORT_VOLUME_SET, SUPPORT_PLAY) from homeassistant.const import ( CONF_HOST, STATE_OFF, STATE_PLAYING, STATE_PAUSED, CONF_NAME, STATE_ON, CONF_ZONE, CONF_TIMEOUT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['denonavr==0.6.1'] +REQUIREMENTS = ['denonavr==0.7.5'] _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = None DEFAULT_SHOW_SOURCES = False DEFAULT_TIMEOUT = 2 CONF_SHOW_ALL_SOURCES = 'show_all_sources' @@ -33,6 +33,8 @@ CONF_INVALID_ZONES_ERR = 'Invalid Zone (expected Zone2 or Zone3)' KEY_DENON_CACHE = 'denonavr_hosts' +ATTR_SOUND_MODE_RAW = 'sound_mode_raw' + SUPPORT_DENON = SUPPORT_VOLUME_STEP | SUPPORT_VOLUME_MUTE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ SUPPORT_SELECT_SOURCE | SUPPORT_VOLUME_SET @@ -61,7 +63,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Denon platform.""" - # pylint: disable=import-error import denonavr # Initialize list with receivers to be started @@ -147,6 +148,20 @@ def __init__(self, receiver): self._frequency = self._receiver.frequency self._station = self._receiver.station + self._sound_mode_support = self._receiver.support_sound_mode + if self._sound_mode_support: + self._sound_mode = self._receiver.sound_mode + self._sound_mode_raw = self._receiver.sound_mode_raw + self._sound_mode_list = self._receiver.sound_mode_list + else: + self._sound_mode = None + self._sound_mode_raw = None + self._sound_mode_list = None + + self._supported_features_base = SUPPORT_DENON + self._supported_features_base |= (self._sound_mode_support and + SUPPORT_SELECT_SOUND_MODE) + def update(self): """Get the latest status information from device.""" self._receiver.update() @@ -164,6 +179,9 @@ def update(self): self._band = self._receiver.band self._frequency = self._receiver.frequency self._station = self._receiver.station + if self._sound_mode_support: + self._sound_mode = self._receiver.sound_mode + self._sound_mode_raw = self._receiver.sound_mode_raw @property def name(self): @@ -197,12 +215,22 @@ def source_list(self): """Return a list of available input sources.""" return self._source_list + @property + def sound_mode(self): + """Return the current matched sound mode.""" + return self._sound_mode + + @property + def sound_mode_list(self): + """Return a list of available sound modes.""" + return self._sound_mode_list + @property def supported_features(self): """Flag media player features that are supported.""" if self._current_source in self._receiver.netaudio_func_list: - return SUPPORT_DENON | SUPPORT_MEDIA_MODES - return SUPPORT_DENON + return self._supported_features_base | SUPPORT_MEDIA_MODES + return self._supported_features_base @property def media_content_id(self): @@ -233,7 +261,7 @@ def media_title(self): """Title of current playing media.""" if self._current_source not in self._receiver.playing_func_list: return self._current_source - elif self._title is not None: + if self._title is not None: return self._title return self._frequency @@ -276,6 +304,15 @@ def media_episode(self): """Episode of current playing media, TV show only.""" return None + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + attributes = {} + if (self._sound_mode_raw is not None and self._sound_mode_support and + self._power == 'ON'): + attributes[ATTR_SOUND_MODE_RAW] = self._sound_mode_raw + return attributes + def media_play_pause(self): """Simulate play pause media player.""" return self._receiver.toggle_play_pause() @@ -292,6 +329,10 @@ def select_source(self, source): """Select input source.""" return self._receiver.set_input_func(source) + def select_sound_mode(self, sound_mode): + """Select sound mode.""" + return self._receiver.set_sound_mode(sound_mode) + def turn_on(self): """Turn on media player.""" if self._receiver.power_on(): diff --git a/homeassistant/components/media_player/directv.py b/homeassistant/components/media_player/directv.py index 25d13e3017a162..89547892550bc0 100644 --- a/homeassistant/components/media_player/directv.py +++ b/homeassistant/components/media_player/directv.py @@ -16,7 +16,7 @@ CONF_DEVICE, CONF_HOST, CONF_NAME, STATE_OFF, STATE_PLAYING, CONF_PORT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['directpy==0.2'] +REQUIREMENTS = ['directpy==0.5'] DEFAULT_DEVICE = '0' DEFAULT_NAME = 'DirecTV Receiver' @@ -140,7 +140,7 @@ def media_series_title(self): """Return the title of current episode of TV show.""" if self._is_standby: return None - elif 'episodeTitle' in self._current: + if 'episodeTitle' in self._current: return self._current['episodeTitle'] return None diff --git a/homeassistant/components/media_player/dlna_dmr.py b/homeassistant/components/media_player/dlna_dmr.py new file mode 100644 index 00000000000000..c40e3ed0ca9d30 --- /dev/null +++ b/homeassistant/components/media_player/dlna_dmr.py @@ -0,0 +1,400 @@ +# -*- coding: utf-8 -*- +""" +Support for DLNA DMR (Device Media Renderer). + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.dlna_dmr/ +""" + +import asyncio +import functools +import logging +from datetime import datetime + +import aiohttp +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.media_player import ( + SUPPORT_PLAY, SUPPORT_PAUSE, SUPPORT_STOP, + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, + SUPPORT_PLAY_MEDIA, + SUPPORT_PREVIOUS_TRACK, SUPPORT_NEXT_TRACK, + MediaPlayerDevice, + PLATFORM_SCHEMA) +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STOP, + CONF_URL, CONF_NAME, + STATE_OFF, STATE_ON, STATE_IDLE, STATE_PLAYING, STATE_PAUSED) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.util import get_local_ip + + +DLNA_DMR_DATA = 'dlna_dmr' + +REQUIREMENTS = [ + 'async-upnp-client==0.12.3', +] + +DEFAULT_NAME = 'DLNA Digital Media Renderer' +DEFAULT_LISTEN_PORT = 8301 + +CONF_LISTEN_IP = 'listen_ip' +CONF_LISTEN_PORT = 'listen_port' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_URL): cv.string, + vol.Optional(CONF_LISTEN_IP): cv.string, + vol.Optional(CONF_LISTEN_PORT, default=DEFAULT_LISTEN_PORT): cv.port, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, +}) + +HOME_ASSISTANT_UPNP_CLASS_MAPPING = { + 'music': 'object.item.audioItem', + 'tvshow': 'object.item.videoItem', + 'video': 'object.item.videoItem', + 'episode': 'object.item.videoItem', + 'channel': 'object.item.videoItem', + 'playlist': 'object.item.playlist', +} +HOME_ASSISTANT_UPNP_MIME_TYPE_MAPPING = { + 'music': 'audio/*', + 'tvshow': 'video/*', + 'video': 'video/*', + 'episode': 'video/*', + 'channel': 'video/*', + 'playlist': 'playlist/*', +} + +_LOGGER = logging.getLogger(__name__) + + +def catch_request_errors(): + """Catch asyncio.TimeoutError, aiohttp.ClientError errors.""" + def call_wrapper(func): + """Call wrapper for decorator.""" + @functools.wraps(func) + def wrapper(self, *args, **kwargs): + """Catch asyncio.TimeoutError, aiohttp.ClientError errors.""" + try: + return func(self, *args, **kwargs) + except (asyncio.TimeoutError, aiohttp.ClientError): + _LOGGER.error("Error during call %s", func.__name__) + + return wrapper + + return call_wrapper + + +async def async_start_event_handler(hass, server_host, server_port, requester): + """Register notify view.""" + hass_data = hass.data[DLNA_DMR_DATA] + if 'event_handler' in hass_data: + return hass_data['event_handler'] + + # start event handler + from async_upnp_client.aiohttp import AiohttpNotifyServer + server = AiohttpNotifyServer(requester, + server_port, + server_host, + hass.loop) + await server.start_server() + _LOGGER.info('UPNP/DLNA event handler listening on: %s', + server.callback_url) + hass_data['notify_server'] = server + hass_data['event_handler'] = server.event_handler + + # register for graceful shutdown + async def async_stop_server(event): + """Stop server.""" + _LOGGER.debug('Stopping UPNP/DLNA event handler') + await server.stop_server() + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, async_stop_server) + + return hass_data['event_handler'] + + +async def async_setup_platform(hass: HomeAssistant, + config, + async_add_devices, + discovery_info=None): + """Set up DLNA DMR platform.""" + if config.get(CONF_URL) is not None: + url = config[CONF_URL] + name = config.get(CONF_NAME) + elif discovery_info is not None: + url = discovery_info['ssdp_description'] + name = discovery_info.get('name') + + if DLNA_DMR_DATA not in hass.data: + hass.data[DLNA_DMR_DATA] = {} + + if 'lock' not in hass.data[DLNA_DMR_DATA]: + hass.data[DLNA_DMR_DATA]['lock'] = asyncio.Lock() + + # build upnp/aiohttp requester + from async_upnp_client.aiohttp import AiohttpSessionRequester + session = async_get_clientsession(hass) + requester = AiohttpSessionRequester(session, True) + + # ensure event handler has been started + with await hass.data[DLNA_DMR_DATA]['lock']: + server_host = config.get(CONF_LISTEN_IP) + if server_host is None: + server_host = get_local_ip() + server_port = config.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT) + event_handler = await async_start_event_handler(hass, + server_host, + server_port, + requester) + + # create upnp device + from async_upnp_client import UpnpFactory + factory = UpnpFactory(requester, disable_state_variable_validation=True) + try: + upnp_device = await factory.async_create_device(url) + except (asyncio.TimeoutError, aiohttp.ClientError): + raise PlatformNotReady() + + # wrap with DmrDevice + from async_upnp_client.dlna import DmrDevice + dlna_device = DmrDevice(upnp_device, event_handler) + + # create our own device + device = DlnaDmrDevice(dlna_device, name) + _LOGGER.debug("Adding device: %s", device) + async_add_devices([device], True) + + +class DlnaDmrDevice(MediaPlayerDevice): + """Representation of a DLNA DMR device.""" + + def __init__(self, dmr_device, name=None): + """Initializer.""" + self._device = dmr_device + self._name = name + + self._available = False + self._subscription_renew_time = None + + async def async_added_to_hass(self): + """Callback when added.""" + self._device.on_event = self._on_event + + # register unsubscribe on stop + bus = self.hass.bus + bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, + self._async_on_hass_stop) + + @property + def available(self): + """Device is available.""" + return self._available + + async def _async_on_hass_stop(self, event): + """Event handler on HASS stop.""" + with await self.hass.data[DLNA_DMR_DATA]['lock']: + await self._device.async_unsubscribe_services() + + async def async_update(self): + """Retrieve the latest data.""" + was_available = self._available + + try: + await self._device.async_update() + self._available = True + except (asyncio.TimeoutError, aiohttp.ClientError): + self._available = False + _LOGGER.debug("Device unavailable") + return + + # do we need to (re-)subscribe? + now = datetime.now() + should_renew = self._subscription_renew_time and \ + now >= self._subscription_renew_time + if should_renew or \ + not was_available and self._available: + try: + timeout = await self._device.async_subscribe_services() + self._subscription_renew_time = datetime.now() + timeout / 2 + except (asyncio.TimeoutError, aiohttp.ClientError): + self._available = False + _LOGGER.debug("Could not (re)subscribe") + + def _on_event(self, service, state_variables): + """State variable(s) changed, let home-assistant know.""" + self.schedule_update_ha_state() + + @property + def supported_features(self): + """Flag media player features that are supported.""" + supported_features = 0 + + if self._device.has_volume_level: + supported_features |= SUPPORT_VOLUME_SET + if self._device.has_volume_mute: + supported_features |= SUPPORT_VOLUME_MUTE + if self._device.has_play: + supported_features |= SUPPORT_PLAY + if self._device.has_pause: + supported_features |= SUPPORT_PAUSE + if self._device.has_stop: + supported_features |= SUPPORT_STOP + if self._device.has_previous: + supported_features |= SUPPORT_PREVIOUS_TRACK + if self._device.has_next: + supported_features |= SUPPORT_NEXT_TRACK + if self._device.has_play_media: + supported_features |= SUPPORT_PLAY_MEDIA + + return supported_features + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self._device.volume_level + + @catch_request_errors() + async def async_set_volume_level(self, volume): + """Set volume level, range 0..1.""" + await self._device.async_set_volume_level(volume) + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self._device.is_volume_muted + + @catch_request_errors() + async def async_mute_volume(self, mute): + """Mute the volume.""" + desired_mute = bool(mute) + await self._device.async_mute_volume(desired_mute) + + @catch_request_errors() + async def async_media_pause(self): + """Send pause command.""" + if not self._device.can_pause: + _LOGGER.debug('Cannot do Pause') + return + + await self._device.async_pause() + + @catch_request_errors() + async def async_media_play(self): + """Send play command.""" + if not self._device.can_play: + _LOGGER.debug('Cannot do Play') + return + + await self._device.async_play() + + @catch_request_errors() + async def async_media_stop(self): + """Send stop command.""" + if not self._device.can_stop: + _LOGGER.debug('Cannot do Stop') + return + + await self._device.async_stop() + + @catch_request_errors() + async def async_play_media(self, media_type, media_id, **kwargs): + """Play a piece of media.""" + title = "Home Assistant" + mime_type = HOME_ASSISTANT_UPNP_MIME_TYPE_MAPPING[media_type] + upnp_class = HOME_ASSISTANT_UPNP_CLASS_MAPPING[media_type] + + # stop current playing media + if self._device.can_stop: + await self.async_media_stop() + + # queue media + await self._device.async_set_transport_uri(media_id, + title, + mime_type, + upnp_class) + await self._device.async_wait_for_can_play() + + # if already playing, no need to call Play + from async_upnp_client import dlna + if self._device.state == dlna.STATE_PLAYING: + return + + # play it + await self.async_media_play() + + @catch_request_errors() + async def async_media_previous_track(self): + """Send previous track command.""" + if not self._device.can_previous: + _LOGGER.debug('Cannot do Previous') + return + + await self._device.async_previous() + + @catch_request_errors() + async def async_media_next_track(self): + """Send next track command.""" + if not self._device.can_next: + _LOGGER.debug('Cannot do Next') + return + + await self._device.async_next() + + @property + def media_title(self): + """Title of current playing media.""" + return self._device.media_title + + @property + def media_image_url(self): + """Image url of current playing media.""" + return self._device.media_image_url + + @property + def state(self): + """State of the player.""" + if not self._available: + return STATE_OFF + + from async_upnp_client import dlna + if self._device.state is None: + return STATE_ON + if self._device.state == dlna.STATE_PLAYING: + return STATE_PLAYING + if self._device.state == dlna.STATE_PAUSED: + return STATE_PAUSED + + return STATE_IDLE + + @property + def media_duration(self): + """Duration of current playing media in seconds.""" + return self._device.media_duration + + @property + def media_position(self): + """Position of current playing media in seconds.""" + return self._device.media_position + + @property + def media_position_updated_at(self): + """When was the position of the current playing media valid. + + Returns value from homeassistant.util.dt.utcnow(). + """ + return self._device.media_position_updated_at + + @property + def name(self) -> str: + """Return the name of the device.""" + if self._name: + return self._name + return self._device.name + + @property + def unique_id(self) -> str: + """Return an unique ID.""" + return self._device.udn diff --git a/homeassistant/components/media_player/dunehd.py b/homeassistant/components/media_player/dunehd.py index efa5e7e607983d..ed20ac25cf90eb 100644 --- a/homeassistant/components/media_player/dunehd.py +++ b/homeassistant/components/media_player/dunehd.py @@ -32,7 +32,6 @@ SUPPORT_PLAY -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the DuneHD media player platform.""" from pdunehd import DuneHDPlayer diff --git a/homeassistant/components/media_player/emby.py b/homeassistant/components/media_player/emby.py index 4f9a4019268242..1dfb19a33bee51 100644 --- a/homeassistant/components/media_player/emby.py +++ b/homeassistant/components/media_player/emby.py @@ -206,11 +206,11 @@ def state(self): state = self.device.state if state == 'Paused': return STATE_PAUSED - elif state == 'Playing': + if state == 'Playing': return STATE_PLAYING - elif state == 'Idle': + if state == 'Idle': return STATE_IDLE - elif state == 'Off': + if state == 'Off': return STATE_OFF @property @@ -230,15 +230,15 @@ def media_content_type(self): media_type = self.device.media_type if media_type == 'Episode': return MEDIA_TYPE_TVSHOW - elif media_type == 'Movie': + if media_type == 'Movie': return MEDIA_TYPE_MOVIE - elif media_type == 'Trailer': + if media_type == 'Trailer': return MEDIA_TYPE_TRAILER - elif media_type == 'Music': + if media_type == 'Music': return MEDIA_TYPE_MUSIC - elif media_type == 'Video': + if media_type == 'Video': return MEDIA_TYPE_GENERIC_VIDEO - elif media_type == 'Audio': + if media_type == 'Audio': return MEDIA_TYPE_MUSIC return None diff --git a/homeassistant/components/media_player/epson.py b/homeassistant/components/media_player/epson.py new file mode 100644 index 00000000000000..b22234a40940fa --- /dev/null +++ b/homeassistant/components/media_player/epson.py @@ -0,0 +1,211 @@ +""" +Support for Epson projector. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/media_player.epson/ +""" +import logging +import voluptuous as vol + +from homeassistant.components.media_player import ( + DOMAIN, MEDIA_PLAYER_SCHEMA, PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, + SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, + MediaPlayerDevice) +from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_PORT, CONF_SSL, STATE_OFF, + STATE_ON) +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['epson-projector==0.1.3'] + +DATA_EPSON = 'epson' +DEFAULT_NAME = 'EPSON Projector' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=80): cv.port, + vol.Optional(CONF_SSL, default=False): cv.boolean +}) + +SERVICE_SELECT_CMODE = 'epson_select_cmode' +ATTR_CMODE = 'cmode' +SUPPORT_CMODE = 33001 + +SUPPORT_EPSON = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE |\ + SUPPORT_CMODE | SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_STEP | \ + SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the Epson media player platform.""" + if DATA_EPSON not in hass.data: + hass.data[DATA_EPSON] = [] + name = config.get(CONF_NAME) + host = config.get(CONF_HOST) + + epson = EpsonProjector(async_get_clientsession(hass, verify_ssl=False), + name, host, + config.get(CONF_PORT), config.get(CONF_SSL)) + hass.data[DATA_EPSON].append(epson) + async_add_devices([epson], update_before_add=True) + + async def async_service_handler(service): + """Handle for services.""" + entity_ids = service.data.get(ATTR_ENTITY_ID) + if entity_ids: + devices = [device for device in hass.data[DATA_EPSON] + if device.entity_id in entity_ids] + else: + devices = hass.data[DATA_EPSON] + for device in devices: + if service.service == SERVICE_SELECT_CMODE: + cmode = service.data.get(ATTR_CMODE) + await device.select_cmode(cmode) + await device.update() + from epson_projector.const import (CMODE_LIST_SET) + epson_schema = MEDIA_PLAYER_SCHEMA.extend({ + vol.Required(ATTR_CMODE): vol.All(cv.string, vol.Any(*CMODE_LIST_SET)) + }) + hass.services.async_register( + DOMAIN, SERVICE_SELECT_CMODE, async_service_handler, + schema=epson_schema) + + +class EpsonProjector(MediaPlayerDevice): + """Representation of Epson Projector Device.""" + + def __init__(self, websession, name, host, port, encryption): + """Initialize entity to control Epson projector.""" + self._name = name + import epson_projector as epson + from epson_projector.const import DEFAULT_SOURCES + self._projector = epson.Projector( + host, + websession=websession, + port=port) + self._cmode = None + self._source_list = list(DEFAULT_SOURCES.values()) + self._source = None + self._volume = None + self._state = None + + async def update(self): + """Update state of device.""" + from epson_projector.const import ( + EPSON_CODES, POWER, + CMODE, CMODE_LIST, SOURCE, VOLUME, + BUSY, SOURCE_LIST) + is_turned_on = await self._projector.get_property(POWER) + _LOGGER.debug("Project turn on/off status: %s", is_turned_on) + if is_turned_on and is_turned_on == EPSON_CODES[POWER]: + self._state = STATE_ON + cmode = await self._projector.get_property(CMODE) + self._cmode = CMODE_LIST.get(cmode, self._cmode) + source = await self._projector.get_property(SOURCE) + self._source = SOURCE_LIST.get(source, self._source) + volume = await self._projector.get_property(VOLUME) + if volume: + self._volume = volume + elif is_turned_on == BUSY: + self._state = STATE_ON + else: + self._state = STATE_OFF + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_EPSON + + async def async_turn_on(self): + """Turn on epson.""" + from epson_projector.const import TURN_ON + await self._projector.send_command(TURN_ON) + + async def async_turn_off(self): + """Turn off epson.""" + from epson_projector.const import TURN_OFF + await self._projector.send_command(TURN_OFF) + + @property + def source_list(self): + """List of available input sources.""" + return self._source_list + + @property + def source(self): + """Get current input sources.""" + return self._source + + @property + def volume_level(self): + """Return the volume level of the media player (0..1).""" + return self._volume + + async def select_cmode(self, cmode): + """Set color mode in Epson.""" + from epson_projector.const import (CMODE_LIST_SET) + await self._projector.send_command(CMODE_LIST_SET[cmode]) + + async def async_select_source(self, source): + """Select input source.""" + from epson_projector.const import INV_SOURCES + selected_source = INV_SOURCES[source] + await self._projector.send_command(selected_source) + + async def async_mute_volume(self, mute): + """Mute (true) or unmute (false) sound.""" + from epson_projector.const import MUTE + await self._projector.send_command(MUTE) + + async def async_volume_up(self): + """Increase volume.""" + from epson_projector.const import VOL_UP + await self._projector.send_command(VOL_UP) + + async def async_volume_down(self): + """Decrease volume.""" + from epson_projector.const import VOL_DOWN + await self._projector.send_command(VOL_DOWN) + + async def async_media_play(self): + """Play media via Epson.""" + from epson_projector.const import PLAY + await self._projector.send_command(PLAY) + + async def async_media_pause(self): + """Pause media via Epson.""" + from epson_projector.const import PAUSE + await self._projector.send_command(PAUSE) + + async def async_media_next_track(self): + """Skip to next.""" + from epson_projector.const import FAST + await self._projector.send_command(FAST) + + async def async_media_previous_track(self): + """Skip to previous.""" + from epson_projector.const import BACK + await self._projector.send_command(BACK) + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + attributes = {} + if self._cmode is not None: + attributes[ATTR_CMODE] = self._cmode + return attributes diff --git a/homeassistant/components/media_player/firetv.py b/homeassistant/components/media_player/firetv.py index 9d66ae77eeff06..979aec57c74a49 100644 --- a/homeassistant/components/media_player/firetv.py +++ b/homeassistant/components/media_player/firetv.py @@ -11,8 +11,8 @@ from homeassistant.components.media_player import ( SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, PLATFORM_SCHEMA, - SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_SET, SUPPORT_PLAY, - MediaPlayerDevice) + SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, + SUPPORT_VOLUME_SET, SUPPORT_PLAY, MediaPlayerDevice) from homeassistant.const import ( STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, STATE_STANDBY, STATE_UNKNOWN, CONF_HOST, CONF_PORT, CONF_SSL, CONF_NAME, CONF_DEVICE, @@ -23,7 +23,8 @@ SUPPORT_FIRETV = SUPPORT_PAUSE | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ - SUPPORT_NEXT_TRACK | SUPPORT_VOLUME_SET | SUPPORT_PLAY + SUPPORT_NEXT_TRACK | SUPPORT_SELECT_SOURCE | SUPPORT_VOLUME_SET | \ + SUPPORT_PLAY DEFAULT_SSL = False DEFAULT_DEVICE = 'default' @@ -33,6 +34,7 @@ DEVICE_ACTION_URL = '{0}://{1}:{2}/devices/action/{3}/{4}' DEVICE_LIST_URL = '{0}://{1}:{2}/devices/list' DEVICE_STATE_URL = '{0}://{1}:{2}/devices/state/{3}' +DEVICE_APPS_URL = '{0}://{1}:{2}/devices/{3}/apps/{4}' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_DEVICE, default=DEFAULT_DEVICE): cv.string, @@ -43,7 +45,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the FireTV platform.""" name = config.get(CONF_NAME) @@ -67,7 +68,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.error("Could not connect to firetv-server at %s", host) -class FireTV(object): +class FireTV: """The firetv-server client. Should a native Python 3 ADB module become available, python-firetv can @@ -99,6 +100,38 @@ def state(self): "Could not retrieve device state for %s", self.device_id) return STATE_UNKNOWN + @property + def current_app(self): + """Return the current app.""" + try: + response = requests.get( + DEVICE_APPS_URL.format( + self.proto, self.host, self.port, self.device_id, 'current' + ), timeout=10).json() + _current_app = response.get('current_app') + if _current_app: + return _current_app.get('package') + + return None + except requests.exceptions.RequestException: + _LOGGER.error( + "Could not retrieve current app for %s", self.device_id) + return None + + @property + def running_apps(self): + """Return a list of running apps.""" + try: + response = requests.get( + DEVICE_APPS_URL.format( + self.proto, self.host, self.port, self.device_id, 'running' + ), timeout=10).json() + return response.get('running_apps') + except requests.exceptions.RequestException: + _LOGGER.error( + "Could not retrieve running apps for %s", self.device_id) + return None + def action(self, action_id): """Perform an action on the device.""" try: @@ -110,6 +143,16 @@ def action(self, action_id): "Action request for %s was not accepted for device %s", action_id, self.device_id) + def start_app(self, app_name): + """Start an app.""" + try: + requests.get(DEVICE_APPS_URL.format( + self.proto, self.host, self.port, self.device_id, + app_name + '/start'), timeout=10) + except requests.exceptions.RequestException: + _LOGGER.error( + "Could not start %s on %s", app_name, self.device_id) + class FireTVDevice(MediaPlayerDevice): """Representation of an Amazon Fire TV device on the network.""" @@ -119,6 +162,8 @@ def __init__(self, proto, host, port, device, name): self._firetv = FireTV(proto, host, port, device) self._name = name self._state = STATE_UNKNOWN + self._running_apps = None + self._current_app = None @property def name(self): @@ -140,6 +185,16 @@ def state(self): """Return the state of the player.""" return self._state + @property + def source(self): + """Return the current app.""" + return self._current_app + + @property + def source_list(self): + """Return a list of running apps.""" + return self._running_apps + def update(self): """Get the latest date and update device state.""" self._state = { @@ -151,6 +206,13 @@ def update(self): 'disconnected': STATE_UNKNOWN, }.get(self._firetv.state, STATE_UNKNOWN) + if self._state not in [STATE_OFF, STATE_UNKNOWN]: + self._running_apps = self._firetv.running_apps + self._current_app = self._firetv.current_app + else: + self._running_apps = None + self._current_app = None + def turn_on(self): """Turn on the device.""" self._firetv.action('turn_on') @@ -186,3 +248,7 @@ def media_previous_track(self): def media_next_track(self): """Send next track command (results in fast-forward).""" self._firetv.action('media_next') + + def select_source(self, source): + """Select input source.""" + self._firetv.start_app(source) diff --git a/homeassistant/components/media_player/frontier_silicon.py b/homeassistant/components/media_player/frontier_silicon.py index 6d95ea675fb812..ab594f47e14d62 100644 --- a/homeassistant/components/media_player/frontier_silicon.py +++ b/homeassistant/components/media_player/frontier_silicon.py @@ -41,7 +41,6 @@ }) -# pylint: disable=unused-argument @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Frontier Silicon platform.""" diff --git a/homeassistant/components/media_player/gpmdp.py b/homeassistant/components/media_player/gpmdp.py index 2f116abebc3311..4a0ec1fa87f4aa 100644 --- a/homeassistant/components/media_player/gpmdp.py +++ b/homeassistant/components/media_player/gpmdp.py @@ -59,7 +59,6 @@ def request_configuration(hass, config, url, add_devices_callback): 'method': 'connect', 'arguments': ['Home Assistant']})) - # pylint: disable=unused-argument def gpmdp_configuration_callback(callback_data): """Handle configuration changes.""" while True: diff --git a/homeassistant/components/media_player/gstreamer.py b/homeassistant/components/media_player/gstreamer.py index 064ca68ea9561c..91cd8d19cc4a59 100644 --- a/homeassistant/components/media_player/gstreamer.py +++ b/homeassistant/components/media_player/gstreamer.py @@ -34,7 +34,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Gstreamer platform.""" from gsp import GstreamerPlayer diff --git a/homeassistant/components/media_player/horizon.py b/homeassistant/components/media_player/horizon.py new file mode 100644 index 00000000000000..9be4143ef2bac4 --- /dev/null +++ b/homeassistant/components/media_player/horizon.py @@ -0,0 +1,187 @@ +""" +Support for the Unitymedia Horizon HD Recorder. + +For more details about this platform, please refer to the documentation +https://home-assistant.io/components/media_player.horizon/ +""" + +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.components.media_player import ( + MediaPlayerDevice, PLATFORM_SCHEMA, MEDIA_TYPE_CHANNEL, + SUPPORT_NEXT_TRACK, SUPPORT_TURN_ON, SUPPORT_TURN_OFF, SUPPORT_PAUSE, + SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK) +from homeassistant.const import (CONF_HOST, CONF_NAME, CONF_PORT, STATE_OFF, + STATE_PAUSED, STATE_PLAYING) +from homeassistant.exceptions import PlatformNotReady +import homeassistant.helpers.config_validation as cv +from homeassistant import util + +REQUIREMENTS = ['einder==0.3.1'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_NAME = "Horizon" +DEFAULT_PORT = 5900 + +MIN_TIME_BETWEEN_FORCED_SCANS = timedelta(seconds=1) +MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) + +SUPPORT_HORIZON = SUPPORT_NEXT_TRACK | SUPPORT_PAUSE | SUPPORT_PLAY | \ + SUPPORT_PLAY_MEDIA | SUPPORT_PREVIOUS_TRACK | SUPPORT_TURN_ON | \ + SUPPORT_TURN_OFF + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Horizon platform.""" + from einder import Client, keys + from einder.exceptions import AuthenticationError + + host = config[CONF_HOST] + name = config[CONF_NAME] + port = config[CONF_PORT] + + try: + client = Client(host, port=port) + except AuthenticationError as msg: + _LOGGER.error("Authentication to %s at %s failed: %s", name, host, msg) + return + except OSError as msg: + # occurs if horizon box is offline + _LOGGER.error("Connection to %s at %s failed: %s", name, host, msg) + raise PlatformNotReady + + _LOGGER.info("Connection to %s at %s established", name, host) + + add_devices([HorizonDevice(client, name, keys)], True) + + +class HorizonDevice(MediaPlayerDevice): + """Representation of a Horizon HD Recorder.""" + + def __init__(self, client, name, keys): + """Initialize the remote.""" + self._client = client + self._name = name + self._state = None + self._keys = keys + + @property + def name(self): + """Return the name of the remote.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_HORIZON + + @util.Throttle(MIN_TIME_BETWEEN_SCANS, MIN_TIME_BETWEEN_FORCED_SCANS) + def update(self): + """Update State using the media server running on the Horizon.""" + if self._client.is_powered_on(): + self._state = STATE_PLAYING + else: + self._state = STATE_OFF + + def turn_on(self): + """Turn the device on.""" + if self._state is STATE_OFF: + self._send_key(self._keys.POWER) + + def turn_off(self): + """Turn the device off.""" + if self._state is not STATE_OFF: + self._send_key(self._keys.POWER) + + def media_previous_track(self): + """Channel down.""" + self._send_key(self._keys.CHAN_DOWN) + self._state = STATE_PLAYING + + def media_next_track(self): + """Channel up.""" + self._send_key(self._keys.CHAN_UP) + self._state = STATE_PLAYING + + def media_play(self): + """Send play command.""" + self._send_key(self._keys.PAUSE) + self._state = STATE_PLAYING + + def media_pause(self): + """Send pause command.""" + self._send_key(self._keys.PAUSE) + self._state = STATE_PAUSED + + def media_play_pause(self): + """Send play/pause command.""" + self._send_key(self._keys.PAUSE) + if self._state == STATE_PAUSED: + self._state = STATE_PLAYING + else: + self._state = STATE_PAUSED + + def play_media(self, media_type, media_id, **kwargs): + """Play media / switch to channel.""" + if MEDIA_TYPE_CHANNEL == media_type: + try: + self._select_channel(int(media_id)) + self._state = STATE_PLAYING + except ValueError: + _LOGGER.error("Invalid channel: %s", media_id) + else: + _LOGGER.error("Invalid media type %s. Supported type: %s", + media_type, MEDIA_TYPE_CHANNEL) + + def _select_channel(self, channel): + """Select a channel (taken from einder library, thx).""" + self._send(channel=channel) + + def _send_key(self, key): + """Send a key to the Horizon device.""" + self._send(key=key) + + def _send(self, key=None, channel=None): + """Send a key to the Horizon device.""" + from einder.exceptions import AuthenticationError + + try: + if key: + self._client.send_key(key) + elif channel: + self._client.select_channel(channel) + except OSError as msg: + _LOGGER.error("%s disconnected: %s. Trying to reconnect...", + self._name, msg) + + # for reconnect, first gracefully disconnect + self._client.disconnect() + + try: + self._client.connect() + self._client.authorize() + except AuthenticationError as msg: + _LOGGER.error("Authentication to %s failed: %s", self._name, + msg) + return + except OSError as msg: + # occurs if horizon box is offline + _LOGGER.error("Reconnect to %s failed: %s", self._name, msg) + return + + self._send(key=key, channel=channel) diff --git a/homeassistant/components/media_player/itunes.py b/homeassistant/components/media_player/itunes.py index ca0979f1752d80..e5f7a2f9432d66 100644 --- a/homeassistant/components/media_player/itunes.py +++ b/homeassistant/components/media_player/itunes.py @@ -41,7 +41,7 @@ }) -class Itunes(object): +class Itunes: """The iTunes API client.""" def __init__(self, host, port, use_ssl): diff --git a/homeassistant/components/media_player/kodi.py b/homeassistant/components/media_player/kodi.py index 770d57b5b8e49f..08de2d00835303 100644 --- a/homeassistant/components/media_player/kodi.py +++ b/homeassistant/components/media_player/kodi.py @@ -160,6 +160,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if DATA_KODI not in hass.data: hass.data[DATA_KODI] = dict() + unique_id = None # Is this a manual configuration? if discovery_info is None: name = config.get(CONF_NAME) @@ -175,6 +176,9 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): tcp_port = DEFAULT_TCP_PORT encryption = DEFAULT_PROXY_SSL websocket = DEFAULT_ENABLE_WEBSOCKET + properties = discovery_info.get('properties') + if properties is not None: + unique_id = properties.get('uuid', None) # Only add a device once, so discovered devices do not override manual # config. @@ -182,6 +186,14 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): if ip_addr in hass.data[DATA_KODI]: return + # If we got an unique id, check that it does not exist already. + # This is necessary as netdisco does not deterministally return the same + # advertisement when the service is offered over multiple IP addresses. + if unique_id is not None: + for device in hass.data[DATA_KODI].values(): + if device.unique_id == unique_id: + return + entity = KodiDevice( hass, name=name, @@ -190,7 +202,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): password=config.get(CONF_PASSWORD), turn_on_action=config.get(CONF_TURN_ON_ACTION), turn_off_action=config.get(CONF_TURN_OFF_ACTION), - timeout=config.get(CONF_TIMEOUT), websocket=websocket) + timeout=config.get(CONF_TIMEOUT), websocket=websocket, + unique_id=unique_id) hass.data[DATA_KODI][ip_addr] = entity async_add_devices([entity], update_before_add=True) @@ -260,12 +273,14 @@ class KodiDevice(MediaPlayerDevice): def __init__(self, hass, name, host, port, tcp_port, encryption=False, username=None, password=None, turn_on_action=None, turn_off_action=None, - timeout=DEFAULT_TIMEOUT, websocket=True): + timeout=DEFAULT_TIMEOUT, websocket=True, + unique_id=None): """Initialize the Kodi device.""" import jsonrpc_async import jsonrpc_websocket self.hass = hass self._name = name + self._unique_id = unique_id kwargs = { 'timeout': timeout, @@ -294,6 +309,7 @@ def __init__(self, hass, name, host, port, tcp_port, encryption=False, # Register notification listeners self._ws_server.Player.OnPause = self.async_on_speed_event self._ws_server.Player.OnPlay = self.async_on_speed_event + self._ws_server.Player.OnResume = self.async_on_speed_event self._ws_server.Player.OnSpeedChanged = self.async_on_speed_event self._ws_server.Player.OnStop = self.async_on_stop self._ws_server.Application.OnVolumeChanged = \ @@ -383,6 +399,11 @@ def _get_players(self): _LOGGER.debug("Unable to fetch kodi data", exc_info=True) return None + @property + def unique_id(self): + """Return the unique id of the device.""" + return self._unique_id + @property def state(self): """Return the state of the device.""" @@ -392,7 +413,7 @@ def state(self): if not self._players: return STATE_IDLE - if self._properties['speed'] == 0 and not self._properties['live']: + if self._properties['speed'] == 0: return STATE_PAUSED return STATE_PLAYING @@ -541,8 +562,8 @@ def media_image_url(self): def media_title(self): """Title of current playing media.""" # find a string we can use as a title - return self._item.get( - 'title', self._item.get('label', self._item.get('file'))) + item = self._item + return item.get('title') or item.get('label') or item.get('file') @property def media_series_title(self): @@ -748,7 +769,7 @@ def async_play_media(self, media_type, media_id, **kwargs): if media_type == "CHANNEL": return self.server.Player.Open( {"item": {"channelid": int(media_id)}}) - elif media_type == "PLAYLIST": + if media_type == "PLAYLIST": return self.server.Player.Open( {"item": {"playlistid": int(media_id)}}) @@ -758,7 +779,7 @@ def async_play_media(self, media_type, media_id, **kwargs): @asyncio.coroutine def async_set_shuffle(self, shuffle): """Set shuffle mode, for the first player.""" - if len(self._players) < 1: + if not self._players: raise RuntimeError("Error: No active player.") yield from self.server.Player.SetShuffle( {"playerid": self._players[0]['playerid'], "shuffle": shuffle}) diff --git a/homeassistant/components/media_player/lg_netcast.py b/homeassistant/components/media_player/lg_netcast.py index edbd6546cca9f5..955ba7ccb3266b 100644 --- a/homeassistant/components/media_player/lg_netcast.py +++ b/homeassistant/components/media_player/lg_netcast.py @@ -18,10 +18,9 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_ACCESS_TOKEN, STATE_OFF, STATE_PLAYING, STATE_PAUSED, STATE_UNKNOWN) -import homeassistant.util as util +from homeassistant import util -REQUIREMENTS = ['https://github.com/wokar/pylgnetcast/archive/' - 'v0.2.0.zip#pylgnetcast==0.2.0'] +REQUIREMENTS = ['pylgnetcast-homeassistant==0.2.0.dev0'] _LOGGER = logging.getLogger(__name__) @@ -43,7 +42,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the LG TV platform.""" from pylgnetcast import LgNetCastClient diff --git a/homeassistant/components/media_player/liveboxplaytv.py b/homeassistant/components/media_player/liveboxplaytv.py index 4fe4da5a94270e..1b5948c964a042 100644 --- a/homeassistant/components/media_player/liveboxplaytv.py +++ b/homeassistant/components/media_player/liveboxplaytv.py @@ -88,6 +88,8 @@ def async_update(self): import pyteleloisirs try: self._state = self.refresh_state() + # Update channel list + self.refresh_channel_list() # Update current channel channel = self._client.channel if channel is not None: @@ -200,7 +202,7 @@ def refresh_state(self): state = self._client.media_state if state == 'PLAY': return STATE_PLAYING - elif state == 'PAUSE': + if state == 'PAUSE': return STATE_PAUSED return STATE_ON if self._client.is_on else STATE_OFF diff --git a/homeassistant/components/media_player/mediaroom.py b/homeassistant/components/media_player/mediaroom.py index f5b7567aa348c9..32f1bb6e0ae811 100644 --- a/homeassistant/components/media_player/mediaroom.py +++ b/homeassistant/components/media_player/mediaroom.py @@ -25,7 +25,7 @@ ) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pymediaroom==0.6.3'] +REQUIREMENTS = ['pymediaroom==0.6.4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/monoprice.py b/homeassistant/components/media_player/monoprice.py index 44d19ac6860391..a951356500f082 100644 --- a/homeassistant/components/media_player/monoprice.py +++ b/homeassistant/components/media_player/monoprice.py @@ -55,7 +55,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Monoprice 6-zone amplifier platform.""" port = config.get(CONF_PORT) diff --git a/homeassistant/components/media_player/mpchc.py b/homeassistant/components/media_player/mpchc.py index a375a585ad40db..773825e0d57d63 100644 --- a/homeassistant/components/media_player/mpchc.py +++ b/homeassistant/components/media_player/mpchc.py @@ -35,7 +35,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the MPC-HC platform.""" name = config.get(CONF_NAME) @@ -94,7 +93,7 @@ def state(self): return STATE_OFF if state == 'playing': return STATE_PLAYING - elif state == 'paused': + if state == 'paused': return STATE_PAUSED return STATE_IDLE diff --git a/homeassistant/components/media_player/mpd.py b/homeassistant/components/media_player/mpd.py index 04dd1ac5f2e1ef..4b3dfc2ccbb545 100644 --- a/homeassistant/components/media_player/mpd.py +++ b/homeassistant/components/media_player/mpd.py @@ -46,7 +46,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the MPD platform.""" host = config.get(CONF_HOST) @@ -142,11 +141,11 @@ def state(self): """Return the media state.""" if self._status is None: return STATE_OFF - elif self._status['state'] == 'play': + if self._status['state'] == 'play': return STATE_PLAYING - elif self._status['state'] == 'pause': + if self._status['state'] == 'pause': return STATE_PAUSED - elif self._status['state'] == 'stop': + if self._status['state'] == 'stop': return STATE_OFF return STATE_OFF @@ -183,9 +182,9 @@ def media_title(self): if file_name is None: return "None" return os.path.basename(file_name) - elif name is None: + if name is None: return title - elif title is None: + if title is None: return name return '{}: {}'.format(name, title) diff --git a/homeassistant/components/media_player/onkyo.py b/homeassistant/components/media_player/onkyo.py index 39c278ff95d8d0..92443ca2b42d99 100644 --- a/homeassistant/components/media_player/onkyo.py +++ b/homeassistant/components/media_player/onkyo.py @@ -24,7 +24,6 @@ CONF_SOURCES = 'sources' CONF_MAX_VOLUME = 'max_volume' -CONF_ZONE2 = 'zone2' DEFAULT_NAME = 'Onkyo Receiver' SUPPORTED_MAX_VOLUME = 80 @@ -33,6 +32,9 @@ SUPPORT_VOLUME_STEP | SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ SUPPORT_SELECT_SOURCE | SUPPORT_PLAY +SUPPORT_ONKYO_WO_VOLUME = SUPPORT_TURN_ON | SUPPORT_TURN_OFF | \ + SUPPORT_SELECT_SOURCE | SUPPORT_PLAY + KNOWN_HOSTS = [] # type: List[str] DEFAULT_SOURCES = {'tv': 'TV', 'bd': 'Bluray', 'game': 'Game', 'aux1': 'Aux1', 'video1': 'Video 1', 'video2': 'Video 2', @@ -47,9 +49,36 @@ vol.All(vol.Coerce(int), vol.Range(min=1, max=SUPPORTED_MAX_VOLUME)), vol.Optional(CONF_SOURCES, default=DEFAULT_SOURCES): {cv.string: cv.string}, - vol.Optional(CONF_ZONE2, default=False): cv.boolean, }) +TIMEOUT_MESSAGE = 'Timeout waiting for response.' + + +def determine_zones(receiver): + """Determine what zones are available for the receiver.""" + out = { + "zone2": False, + "zone3": False, + } + try: + _LOGGER.debug("Checking for zone 2 capability") + receiver.raw("ZPW") + out["zone2"] = True + except ValueError as error: + if str(error) != TIMEOUT_MESSAGE: + raise error + _LOGGER.debug("Zone 2 timed out, assuming no functionality") + try: + _LOGGER.debug("Checking for zone 3 capability") + receiver.raw("PW3") + out["zone3"] = True + except ValueError as error: + if str(error) != TIMEOUT_MESSAGE: + raise error + _LOGGER.debug("Zone 3 timed out, assuming no functionality") + + return out + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Onkyo platform.""" @@ -61,20 +90,31 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if CONF_HOST in config and host not in KNOWN_HOSTS: try: + receiver = eiscp.eISCP(host) hosts.append(OnkyoDevice( - eiscp.eISCP(host), config.get(CONF_SOURCES), + receiver, + config.get(CONF_SOURCES), name=config.get(CONF_NAME), max_volume=config.get(CONF_MAX_VOLUME), )) KNOWN_HOSTS.append(host) - # Add Zone2 if configured - if config.get(CONF_ZONE2): + zones = determine_zones(receiver) + + # Add Zone2 if available + if zones["zone2"]: _LOGGER.debug("Setting up zone 2") - hosts.append(OnkyoDeviceZone2(eiscp.eISCP(host), - config.get(CONF_SOURCES), - name=config.get(CONF_NAME) + - " Zone 2")) + hosts.append(OnkyoDeviceZone( + "2", receiver, + config.get(CONF_SOURCES), + name="{} Zone 2".format(config[CONF_NAME]))) + # Add Zone3 if available + if zones["zone3"]: + _LOGGER.debug("Setting up zone 3") + hosts.append(OnkyoDeviceZone( + "3", receiver, + config.get(CONF_SOURCES), + name="{} Zone 3".format(config[CONF_NAME]))) except OSError: _LOGGER.error("Unable to connect to receiver at %s", host) else: @@ -227,12 +267,18 @@ def select_source(self, source): self.command('input-selector {}'.format(source)) -class OnkyoDeviceZone2(OnkyoDevice): - """Representation of an Onkyo device's zone 2.""" +class OnkyoDeviceZone(OnkyoDevice): + """Representation of an Onkyo device's extra zone.""" + + def __init__(self, zone, receiver, sources, name=None): + """Initialize the Zone with the zone identifier.""" + self._zone = zone + self._supports_volume = True + super(OnkyoDeviceZone, self).__init__(receiver, sources, name) def update(self): """Get the latest state from the device.""" - status = self.command('zone2.power=query') + status = self.command('zone{}.power=query'.format(self._zone)) if not status: return @@ -242,13 +288,23 @@ def update(self): self._pwstate = STATE_OFF return - volume_raw = self.command('zone2.volume=query') - mute_raw = self.command('zone2.muting=query') - current_source_raw = self.command('zone2.selector=query') + volume_raw = self.command('zone{}.volume=query'.format(self._zone)) + mute_raw = self.command('zone{}.muting=query'.format(self._zone)) + current_source_raw = self.command( + 'zone{}.selector=query'.format(self._zone)) + + # If we received a source value, but not a volume value + # it's likely this zone permanently does not support volume. + if current_source_raw and not volume_raw: + self._supports_volume = False if not (volume_raw and mute_raw and current_source_raw): return + # It's possible for some players to have zones set to HDMI with + # no sound control. In this case, the string `N/A` is returned. + self._supports_volume = isinstance(volume_raw[1], (float, int)) + # eiscp can return string or tuple. Make everything tuples. if isinstance(current_source_raw[1], str): current_source_tuples = \ @@ -264,37 +320,46 @@ def update(self): self._current_source = '_'.join( [i for i in current_source_tuples[1]]) self._muted = bool(mute_raw[1] == 'on') - self._volume = volume_raw[1] / 80.0 + + if self._supports_volume: + self._volume = volume_raw[1] / 80.0 + + @property + def supported_features(self): + """Return media player features that are supported.""" + if self._supports_volume: + return SUPPORT_ONKYO + return SUPPORT_ONKYO_WO_VOLUME def turn_off(self): """Turn the media player off.""" - self.command('zone2.power=standby') + self.command('zone{}.power=standby'.format(self._zone)) def set_volume_level(self, volume): """Set volume level, input is range 0..1. Onkyo ranges from 1-80.""" - self.command('zone2.volume={}'.format(int(volume*80))) + self.command('zone{}.volume={}'.format(self._zone, int(volume*80))) def volume_up(self): """Increase volume by 1 step.""" - self.command('zone2.volume=level-up') + self.command('zone{}.volume=level-up'.format(self._zone)) def volume_down(self): """Decrease volume by 1 step.""" - self.command('zone2.volume=level-down') + self.command('zone{}.volume=level-down'.format(self._zone)) def mute_volume(self, mute): """Mute (true) or unmute (false) media player.""" if mute: - self.command('zone2.muting=on') + self.command('zone{}.muting=on'.format(self._zone)) else: - self.command('zone2.muting=off') + self.command('zone{}.muting=off'.format(self._zone)) def turn_on(self): """Turn the media player on.""" - self.command('zone2.power=on') + self.command('zone{}.power=on'.format(self._zone)) def select_source(self, source): """Set the input source.""" if source in self._source_list: source = self._reverse_mapping[source] - self.command('zone2.selector={}'.format(source)) + self.command('zone{}.selector={}'.format(self._zone, source)) diff --git a/homeassistant/components/media_player/openhome.py b/homeassistant/components/media_player/openhome.py index 5e30f9783c7582..5d9c7bd14c578a 100644 --- a/homeassistant/components/media_player/openhome.py +++ b/homeassistant/components/media_player/openhome.py @@ -25,7 +25,6 @@ DEVICES = [] -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Openhome platform.""" from openhomedevice.Device import Device diff --git a/homeassistant/components/media_player/panasonic_viera.py b/homeassistant/components/media_player/panasonic_viera.py index db60de922d998f..549071fde8e5d4 100644 --- a/homeassistant/components/media_player/panasonic_viera.py +++ b/homeassistant/components/media_player/panasonic_viera.py @@ -42,7 +42,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Panasonic Viera TV platform.""" from panasonic_viera import RemoteControl diff --git a/homeassistant/components/media_player/pandora.py b/homeassistant/components/media_player/pandora.py index d66811eed661f1..c4d8b77809552b 100644 --- a/homeassistant/components/media_player/pandora.py +++ b/homeassistant/components/media_player/pandora.py @@ -22,7 +22,7 @@ STATE_IDLE) from homeassistant import util -REQUIREMENTS = ['pexpect==4.0.1'] +REQUIREMENTS = ['pexpect==4.6.0'] _LOGGER = logging.getLogger(__name__) # SUPPORT_VOLUME_SET is close to available but we need volume up/down @@ -43,7 +43,6 @@ STATION_PATTERN = re.compile(r'Station\s"(.+?)"', re.MULTILINE) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Pandora media player platform.""" if not _pianobar_exists(): @@ -254,9 +253,11 @@ def _query_for_playing_status(self): _LOGGER.warning("On unexpected station list page") self._pianobar.sendcontrol('m') # press enter self._pianobar.sendcontrol('m') # do it again b/c an 'i' got in + # pylint: disable=assignment-from-none response = self.update_playing_status() elif match_idx == 3: _LOGGER.debug("Received new playlist list") + # pylint: disable=assignment-from-none response = self.update_playing_status() else: response = self._pianobar.before.decode('utf-8') @@ -295,8 +296,7 @@ def _update_song_position(self): time_remaining = int(cur_minutes) * 60 + int(cur_seconds) self._media_duration = int(total_minutes) * 60 + int(total_seconds) - if (time_remaining != self._time_remaining and - time_remaining != self._media_duration): + if time_remaining not in (self._time_remaining, self._media_duration): self._player_state = STATE_PLAYING elif self._player_state == STATE_PLAYING: self._player_state = STATE_PAUSED diff --git a/homeassistant/components/media_player/philips_js.py b/homeassistant/components/media_player/philips_js.py index d526fbb0387f38..06f054a03f7232 100644 --- a/homeassistant/components/media_player/philips_js.py +++ b/homeassistant/components/media_player/philips_js.py @@ -13,20 +13,22 @@ from homeassistant.components.media_player import ( PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, - SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, SUPPORT_PLAY, MediaPlayerDevice) + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, + SUPPORT_PLAY, MediaPlayerDevice) from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_API_VERSION, STATE_OFF, STATE_ON, STATE_UNKNOWN) from homeassistant.helpers.script import Script from homeassistant.util import Throttle -REQUIREMENTS = ['ha-philipsjs==0.0.3'] +REQUIREMENTS = ['ha-philipsjs==0.0.5'] _LOGGER = logging.getLogger(__name__) MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=30) SUPPORT_PHILIPS_JS = SUPPORT_TURN_OFF | SUPPORT_VOLUME_STEP | \ - SUPPORT_VOLUME_MUTE | SUPPORT_SELECT_SOURCE + SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ + SUPPORT_SELECT_SOURCE SUPPORT_PHILIPS_JS_TV = SUPPORT_PHILIPS_JS | SUPPORT_NEXT_TRACK | \ SUPPORT_PREVIOUS_TRACK | SUPPORT_PLAY @@ -46,7 +48,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Philips TV platform.""" import haphilipsjs @@ -165,6 +166,10 @@ def mute_volume(self, mute): if not self._tv.on: self._state = STATE_OFF + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + self._tv.setVolume(volume) + def media_previous_track(self): """Send rewind command.""" self._tv.sendKey('Previous') @@ -189,12 +194,10 @@ def update(self): self._volume = self._tv.volume self._muted = self._tv.muted if self._tv.source_id: - src = self._tv.sources.get(self._tv.source_id, None) - if src: - self._source = src.get('name', None) + self._source = self._tv.getSourceName(self._tv.source_id) if self._tv.sources and not self._source_list: - for srcid in sorted(self._tv.sources): - srcname = self._tv.sources.get(srcid, dict()).get('name', None) + for srcid in self._tv.sources: + srcname = self._tv.getSourceName(srcid) self._source_list.append(srcname) self._source_mapping[srcname] = srcid if self._tv.on: diff --git a/homeassistant/components/media_player/pjlink.py b/homeassistant/components/media_player/pjlink.py new file mode 100644 index 00000000000000..5d3122256ea670 --- /dev/null +++ b/homeassistant/components/media_player/pjlink.py @@ -0,0 +1,157 @@ +""" +Support for controlling projector via the PJLink protocol. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.pjlink/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.media_player import ( + SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, + SUPPORT_SELECT_SOURCE, PLATFORM_SCHEMA, MediaPlayerDevice) +from homeassistant.const import ( + STATE_OFF, STATE_ON, CONF_HOST, + CONF_NAME, CONF_PASSWORD, CONF_PORT) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pypjlink2==1.2.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_ENCODING = 'encoding' + +DEFAULT_PORT = 4352 +DEFAULT_ENCODING = 'utf-8' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): cv.string, + vol.Optional(CONF_PASSWORD): cv.string, +}) + +SUPPORT_PJLINK = SUPPORT_VOLUME_MUTE | \ + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the PJLink platform.""" + host = config.get(CONF_HOST) + port = config.get(CONF_PORT) + name = config.get(CONF_NAME) + encoding = config.get(CONF_ENCODING) + password = config.get(CONF_PASSWORD) + + if 'pjlink' not in hass.data: + hass.data['pjlink'] = {} + hass_data = hass.data['pjlink'] + + device_label = "{}:{}".format(host, port) + if device_label in hass_data: + return + + device = PjLinkDevice(host, port, name, encoding, password) + hass_data[device_label] = device + add_devices([device], True) + + +def format_input_source(input_source_name, input_source_number): + """Format input source for display in UI.""" + return "{} {}".format(input_source_name, input_source_number) + + +class PjLinkDevice(MediaPlayerDevice): + """Representation of a PJLink device.""" + + def __init__(self, host, port, name, encoding, password): + """Iinitialize the PJLink device.""" + self._host = host + self._port = port + self._name = name + self._password = password + self._encoding = encoding + self._muted = False + self._pwstate = STATE_OFF + self._current_source = None + with self.projector() as projector: + if not self._name: + self._name = projector.get_name() + inputs = projector.get_inputs() + self._source_name_mapping = \ + {format_input_source(*x): x for x in inputs} + self._source_list = sorted(self._source_name_mapping.keys()) + + def projector(self): + """Create PJLink Projector instance.""" + from pypjlink import Projector + projector = Projector.from_address(self._host, self._port, + self._encoding) + projector.authenticate(self._password) + return projector + + def update(self): + """Get the latest state from the device.""" + with self.projector() as projector: + pwstate = projector.get_power() + if pwstate == 'off': + self._pwstate = STATE_OFF + else: + self._pwstate = STATE_ON + self._muted = projector.get_mute()[1] + self._current_source = \ + format_input_source(*projector.get_input()) + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._pwstate + + @property + def is_volume_muted(self): + """Return boolean indicating mute status.""" + return self._muted + + @property + def source(self): + """Return current input source.""" + return self._current_source + + @property + def source_list(self): + """Return all available input sources.""" + return self._source_list + + @property + def supported_features(self): + """Return projector supported features.""" + return SUPPORT_PJLINK + + def turn_off(self): + """Turn projector off.""" + with self.projector() as projector: + projector.set_power('off') + + def turn_on(self): + """Turn projector on.""" + with self.projector() as projector: + projector.set_power('on') + + def mute_volume(self, mute): + """Mute (true) of unmute (false) media player.""" + with self.projector() as projector: + from pypjlink import MUTE_AUDIO + projector.set_mute(MUTE_AUDIO, mute) + + def select_source(self, source): + """Set the input source.""" + source = self._source_name_mapping[source] + with self.projector() as projector: + projector.set_input(*source) diff --git a/homeassistant/components/media_player/plex.py b/homeassistant/components/media_player/plex.py index 6690382846fd15..e3c6f453c35b92 100644 --- a/homeassistant/components/media_player/plex.py +++ b/homeassistant/components/media_player/plex.py @@ -573,11 +573,11 @@ def media_content_type(self): _LOGGER.debug("Clip content type detected, " "compatibility may vary: %s", self.entity_id) return MEDIA_TYPE_TVSHOW - elif self._session_type == 'episode': + if self._session_type == 'episode': return MEDIA_TYPE_TVSHOW - elif self._session_type == 'movie': + if self._session_type == 'movie': return MEDIA_TYPE_MOVIE - elif self._session_type == 'track': + if self._session_type == 'track': return MEDIA_TYPE_MUSIC return None @@ -654,7 +654,7 @@ def supported_features(self): if not self._make: return None # no mute support - elif self.make.lower() == "shield android tv": + if self.make.lower() == "shield android tv": _LOGGER.debug( "Shield Android TV client detected, disabling mute " "controls: %s", self.entity_id) @@ -663,7 +663,7 @@ def supported_features(self): SUPPORT_VOLUME_SET | SUPPORT_PLAY | SUPPORT_TURN_OFF) # Only supports play,pause,stop (and off which really is stop) - elif self.make.lower().startswith("tivo"): + if self.make.lower().startswith("tivo"): _LOGGER.debug( "Tivo client detected, only enabling pause, play, " "stop, and off controls: %s", self.entity_id) @@ -671,7 +671,7 @@ def supported_features(self): SUPPORT_TURN_OFF) # Not all devices support playback functionality # Playback includes volume, stop/play/pause, etc. - elif self.device and 'playback' in self._device_protocol_capabilities: + if self.device and 'playback' in self._device_protocol_capabilities: return (SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | SUPPORT_NEXT_TRACK | SUPPORT_STOP | SUPPORT_VOLUME_SET | SUPPORT_PLAY | @@ -747,7 +747,6 @@ def media_previous_track(self): if self.device and 'playback' in self._device_protocol_capabilities: self.device.skipPrevious(self._active_media_plexapi_type) - # pylint: disable=W0613 def play_media(self, media_type, media_id, **kwargs): """Play a piece of media.""" if not (self.device and diff --git a/homeassistant/components/media_player/roku.py b/homeassistant/components/media_player/roku.py index 87129f30db5716..5f28660f4bdff2 100644 --- a/homeassistant/components/media_player/roku.py +++ b/homeassistant/components/media_player/roku.py @@ -134,9 +134,9 @@ def state(self): if (self.current_app.name == "Power Saver" or self.current_app.is_screensaver): return STATE_IDLE - elif self.current_app.name == "Roku": + if self.current_app.name == "Roku": return STATE_HOME - elif self.current_app.name is not None: + if self.current_app.name is not None: return STATE_PLAYING return STATE_UNKNOWN @@ -146,14 +146,19 @@ def supported_features(self): """Flag media player features that are supported.""" return SUPPORT_ROKU + @property + def unique_id(self): + """Return a unique, HASS-friendly identifier for this entity.""" + return self.device_info.sernum + @property def media_content_type(self): """Content type of current playing media.""" if self.current_app is None: return None - elif self.current_app.name == "Power Saver": + if self.current_app.name == "Power Saver": return None - elif self.current_app.name == "Roku": + if self.current_app.name == "Roku": return None return MEDIA_TYPE_MOVIE @@ -162,11 +167,11 @@ def media_image_url(self): """Image url of current playing media.""" if self.current_app is None: return None - elif self.current_app.name == "Roku": + if self.current_app.name == "Roku": return None - elif self.current_app.name == "Power Saver": + if self.current_app.name == "Power Saver": return None - elif self.current_app.id is None: + if self.current_app.id is None: return None return 'http://{0}:{1}/query/icon/{2}'.format( diff --git a/homeassistant/components/media_player/russound_rio.py b/homeassistant/components/media_player/russound_rio.py index 31b04ceb3cdee8..e9f8ab5f199a7b 100644 --- a/homeassistant/components/media_player/russound_rio.py +++ b/homeassistant/components/media_player/russound_rio.py @@ -100,8 +100,7 @@ def _source_na_var(self, name): if value in (None, "", "------"): return None return value - else: - return None + return None def _zone_callback_handler(self, zone_id, *args): if zone_id == self._zone_id: @@ -134,7 +133,7 @@ def state(self): status = self._zone_var('status', "OFF") if status == 'ON': return STATE_ON - elif status == 'OFF': + if status == 'OFF': return STATE_OFF @property diff --git a/homeassistant/components/media_player/samsungtv.py b/homeassistant/components/media_player/samsungtv.py index 0b7fc3c078e811..55b3fb0ea4f5ba 100644 --- a/homeassistant/components/media_player/samsungtv.py +++ b/homeassistant/components/media_player/samsungtv.py @@ -4,6 +4,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/media_player.samsungtv/ """ +import asyncio import logging import socket from datetime import timedelta @@ -15,8 +16,9 @@ from homeassistant.components.media_player import ( SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, - SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, - SUPPORT_PLAY, MediaPlayerDevice, PLATFORM_SCHEMA, SUPPORT_TURN_ON) + SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_PLAY, + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, SUPPORT_PLAY_MEDIA, + MediaPlayerDevice, PLATFORM_SCHEMA, MEDIA_TYPE_CHANNEL) from homeassistant.const import ( CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN, CONF_PORT, CONF_MAC) @@ -32,12 +34,13 @@ DEFAULT_NAME = 'Samsung TV Remote' DEFAULT_PORT = 55000 DEFAULT_TIMEOUT = 0 +KEY_PRESS_TIMEOUT = 1.2 KNOWN_DEVICES_KEY = 'samsungtv_known_devices' SUPPORT_SAMSUNGTV = SUPPORT_PAUSE | SUPPORT_VOLUME_STEP | \ SUPPORT_VOLUME_MUTE | SUPPORT_PREVIOUS_TRACK | \ - SUPPORT_NEXT_TRACK | SUPPORT_TURN_OFF | SUPPORT_PLAY + SUPPORT_NEXT_TRACK | SUPPORT_TURN_OFF | SUPPORT_PLAY | SUPPORT_PLAY_MEDIA PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -47,7 +50,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Samsung TV platform.""" known_devices = hass.data.get(KNOWN_DEVICES_KEY) @@ -151,20 +153,29 @@ def get_remote(self): def send_key(self, key): """Send a key to the tv and handles exceptions.""" if self._power_off_in_progress() \ - and not (key == 'KEY_POWER' or key == 'KEY_POWEROFF'): + and key not in ('KEY_POWER', 'KEY_POWEROFF'): _LOGGER.info("TV is powering off, not sending command: %s", key) return try: - self.get_remote().control(key) + # recreate connection if connection was dead + retry_count = 1 + for _ in range(retry_count + 1): + try: + self.get_remote().control(key) + break + except (self._exceptions_class.ConnectionClosed, + BrokenPipeError): + # BrokenPipe can occur when the commands is sent to fast + self._remote = None self._state = STATE_ON except (self._exceptions_class.UnhandledResponse, - self._exceptions_class.AccessDenied, BrokenPipeError): + self._exceptions_class.AccessDenied): # We got a response so it's on. - # BrokenPipe can occur when the commands is sent to fast self._state = STATE_ON self._remote = None + _LOGGER.debug("Failed sending command %s", key, exc_info=True) return - except (self._exceptions_class.ConnectionClosed, OSError): + except OSError: self._state = STATE_OFF self._remote = None if self._power_off_in_progress(): @@ -207,6 +218,7 @@ def turn_off(self): # Force closing of remote session to provide instant UI feedback try: self.get_remote().close() + self._remote = None except OSError: _LOGGER.debug("Could not establish connection.") @@ -247,6 +259,23 @@ def media_previous_track(self): """Send the previous track command.""" self.send_key('KEY_REWIND') + async def async_play_media(self, media_type, media_id, **kwargs): + """Support changing a channel.""" + if media_type != MEDIA_TYPE_CHANNEL: + _LOGGER.error('Unsupported media type') + return + + # media_id should only be a channel number + try: + cv.positive_int(media_id) + except vol.Invalid: + _LOGGER.error('Media ID must be positive integer') + return + + for digit in media_id: + await self.hass.async_add_job(self.send_key, 'KEY_' + digit) + await asyncio.sleep(KEY_PRESS_TIMEOUT, self.hass.loop) + def turn_on(self): """Turn the media player on.""" if self._mac: diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index 0a6c413a688b88..3c91f19469b2b3 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -144,6 +144,16 @@ select_source: description: Name of the source to switch to. Platform dependent. example: 'video1' +select_sound_mode: + description: Send the media player the command to change sound mode. + fields: + entity_id: + description: Name(s) of entities to change sound mode on. + example: 'media_player.marantz' + sound_mode: + description: Name of the sound mode to switch to. + example: 'Music' + clear_playlist: description: Send the media player the command to clear players playlist. fields: @@ -412,3 +422,13 @@ blackbird_set_all_zones: source: description: Name of source to switch to. example: 'Source 1' + +epson_select_cmode: + description: Select Color mode of Epson projector + fields: + entity_id: + description: Name of projector + example: 'media_player.epson_projector' + cmode: + description: Name of Cmode + example: 'cinema' diff --git a/homeassistant/components/media_player/sisyphus.py b/homeassistant/components/media_player/sisyphus.py new file mode 100644 index 00000000000000..9a94da158a19da --- /dev/null +++ b/homeassistant/components/media_player/sisyphus.py @@ -0,0 +1,197 @@ +""" +Support for track controls on the Sisyphus Kinetic Art Table. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.sisyphus/ +""" +import logging + +from homeassistant.components.media_player import ( + SUPPORT_NEXT_TRACK, + SUPPORT_PAUSE, + SUPPORT_PLAY, + SUPPORT_PREVIOUS_TRACK, + SUPPORT_SHUFFLE_SET, + SUPPORT_TURN_OFF, + SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, + SUPPORT_VOLUME_SET, + MediaPlayerDevice) +from homeassistant.components.sisyphus import DATA_SISYPHUS +from homeassistant.const import CONF_HOST, CONF_NAME, STATE_PLAYING, \ + STATE_PAUSED, STATE_IDLE, STATE_OFF + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['sisyphus'] + +MEDIA_TYPE_TRACK = "sisyphus_track" + +SUPPORTED_FEATURES = SUPPORT_VOLUME_MUTE \ + | SUPPORT_VOLUME_SET \ + | SUPPORT_TURN_OFF \ + | SUPPORT_TURN_ON \ + | SUPPORT_PAUSE \ + | SUPPORT_SHUFFLE_SET \ + | SUPPORT_PREVIOUS_TRACK \ + | SUPPORT_NEXT_TRACK \ + | SUPPORT_PLAY + + +# pylint: disable=unused-argument +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up a media player entity for a Sisyphus table.""" + name = discovery_info[CONF_NAME] + host = discovery_info[CONF_HOST] + add_devices( + [SisyphusPlayer(name, host, hass.data[DATA_SISYPHUS][name])], + update_before_add=True) + + +class SisyphusPlayer(MediaPlayerDevice): + """Represents a single Sisyphus table as a media player device.""" + + def __init__(self, name, host, table): + """ + Constructor. + + :param name: name of the table + :param host: hostname or ip address + :param table: sisyphus-control Table object + """ + self._name = name + self._host = host + self._table = table + + async def async_added_to_hass(self): + """Add listeners after this object has been initialized.""" + self._table.add_listener( + lambda: self.async_schedule_update_ha_state(False)) + + @property + def name(self): + """Return the name of the table.""" + return self._name + + @property + def state(self): + """Return the current state of the table; sleeping maps to off.""" + if self._table.state in ["homing", "playing"]: + return STATE_PLAYING + if self._table.state == "paused": + if self._table.is_sleeping: + return STATE_OFF + + return STATE_PAUSED + if self._table.state == "waiting": + return STATE_IDLE + + return None + + @property + def volume_level(self): + """Return the current playback speed (0..1).""" + return self._table.speed + + @property + def shuffle(self): + """Return True if the current playlist is in shuffle mode.""" + return self._table.is_shuffle + + async def async_set_shuffle(self, shuffle): + """ + Change the shuffle mode of the current playlist. + + :param shuffle: True to shuffle, False not to + """ + await self._table.set_shuffle(shuffle) + + @property + def media_playlist(self): + """Return the name of the current playlist.""" + return self._table.active_playlist.name \ + if self._table.active_playlist \ + else None + + @property + def media_title(self): + """Return the title of the current track.""" + return self._table.active_track.name \ + if self._table.active_track \ + else None + + @property + def media_content_type(self): + """Return the content type currently playing; i.e. a Sisyphus track.""" + return MEDIA_TYPE_TRACK + + @property + def media_content_id(self): + """Return the track ID of the current track.""" + return self._table.active_track.id \ + if self._table.active_track \ + else None + + @property + def supported_features(self): + """Return the features supported by this table.""" + return SUPPORTED_FEATURES + + @property + def media_image_url(self): + """Return the URL for a thumbnail image of the current track.""" + from sisyphus_control import Track + if self._table.active_track: + return self._table.active_track.get_thumbnail_url( + Track.ThumbnailSize.LARGE) + + return super.media_image_url() + + async def async_turn_on(self): + """Wake up a sleeping table.""" + await self._table.wakeup() + + async def async_turn_off(self): + """Put the table to sleep.""" + await self._table.sleep() + + async def async_volume_down(self): + """Slow down playback.""" + await self._table.set_speed(max(0, self._table.speed - 0.1)) + + async def async_volume_up(self): + """Speed up playback.""" + await self._table.set_speed(min(1.0, self._table.speed + 0.1)) + + async def async_set_volume_level(self, volume): + """Set playback speed (0..1).""" + await self._table.set_speed(volume) + + async def async_media_play(self): + """Start playing.""" + await self._table.play() + + async def async_media_pause(self): + """Pause.""" + await self._table.pause() + + async def async_media_next_track(self): + """Skip to next track.""" + cur_track_index = self._get_current_track_index() + + await self._table.active_playlist.play( + self._table.active_playlist.tracks[cur_track_index + 1]) + + async def async_media_previous_track(self): + """Skip to previous track.""" + cur_track_index = self._get_current_track_index() + + await self._table.active_playlist.play( + self._table.active_playlist.tracks[cur_track_index - 1]) + + def _get_current_track_index(self): + for index, track in enumerate(self._table.active_playlist.tracks): + if track.id == self._table.active_track.id: + return index + + return -1 diff --git a/homeassistant/components/media_player/snapcast.py b/homeassistant/components/media_player/snapcast.py index 793800a3d2259e..a880d3c920d150 100644 --- a/homeassistant/components/media_player/snapcast.py +++ b/homeassistant/components/media_player/snapcast.py @@ -46,7 +46,6 @@ }) -# pylint: disable=unused-argument @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Snapcast platform.""" @@ -80,8 +79,11 @@ def _handle_service(service): host, port) return - groups = [SnapcastGroupDevice(group) for group in server.groups] - clients = [SnapcastClientDevice(client) for client in server.clients] + # Note: Host part is needed, when using multiple snapservers + hpid = '{}:{}'.format(host, port) + + groups = [SnapcastGroupDevice(group, hpid) for group in server.groups] + clients = [SnapcastClientDevice(client, hpid) for client in server.clients] devices = groups + clients hass.data[DATA_KEY] = devices async_add_devices(devices) @@ -90,10 +92,12 @@ def _handle_service(service): class SnapcastGroupDevice(MediaPlayerDevice): """Representation of a Snapcast group device.""" - def __init__(self, group): + def __init__(self, group, uid_part): """Initialize the Snapcast group device.""" group.set_callback(self.schedule_update_ha_state) self._group = group + self._uid = '{}{}_{}'.format(GROUP_PREFIX, uid_part, + self._group.identifier) @property def state(self): @@ -104,6 +108,11 @@ def state(self): 'unknown': STATE_UNKNOWN, }.get(self._group.stream_status, STATE_UNKNOWN) + @property + def unique_id(self): + """Return the ID of snapcast group.""" + return self._uid + @property def name(self): """Return the name of the device.""" @@ -180,10 +189,21 @@ def async_restore(self): class SnapcastClientDevice(MediaPlayerDevice): """Representation of a Snapcast client device.""" - def __init__(self, client): + def __init__(self, client, uid_part): """Initialize the Snapcast client device.""" client.set_callback(self.schedule_update_ha_state) self._client = client + self._uid = '{}{}_{}'.format(CLIENT_PREFIX, uid_part, + self._client.identifier) + + @property + def unique_id(self): + """ + Return the ID of this snapcast client. + + Note: Host part is needed, when using multiple snapservers + """ + return self._uid @property def name(self): diff --git a/homeassistant/components/media_player/songpal.py b/homeassistant/components/media_player/songpal.py index 955456f2465299..5d0962775f05c7 100644 --- a/homeassistant/components/media_player/songpal.py +++ b/homeassistant/components/media_player/songpal.py @@ -151,8 +151,8 @@ async def async_update(self): return if len(volumes) > 1: - _LOGGER.warning("Got %s volume controls, using the first one", - volumes) + _LOGGER.debug("Got %s volume controls, using the first one", + volumes) volume = volumes[0] _LOGGER.debug("Current volume: %s", volume) diff --git a/homeassistant/components/media_player/sonos.py b/homeassistant/components/media_player/sonos.py index cc10355abe899a..5375001f75c53f 100644 --- a/homeassistant/components/media_player/sonos.py +++ b/homeassistant/components/media_player/sonos.py @@ -20,13 +20,14 @@ SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SEEK, SUPPORT_SELECT_SOURCE, SUPPORT_SHUFFLE_SET, SUPPORT_STOP, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, MediaPlayerDevice) +from homeassistant.components.sonos import DOMAIN as SONOS_DOMAIN from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_TIME, CONF_HOSTS, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING) import homeassistant.helpers.config_validation as cv from homeassistant.util.dt import utcnow -REQUIREMENTS = ['SoCo==0.14'] +DEPENDENCIES = ('sonos',) _LOGGER = logging.getLogger(__name__) @@ -49,7 +50,7 @@ SERVICE_UPDATE_ALARM = 'sonos_update_alarm' SERVICE_SET_OPTION = 'sonos_set_option' -DATA_SONOS = 'sonos' +DATA_SONOS = 'sonos_devices' SOURCE_LINEIN = 'Line-in' SOURCE_TV = 'TV' @@ -118,6 +119,26 @@ def __init__(self): def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Sonos platform. + + Deprecated. + """ + _LOGGER.warning('Loading Sonos via platform config is deprecated.') + _setup_platform(hass, config, add_devices, discovery_info) + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up Sonos from a config entry.""" + def add_devices(devices, update_before_add=False): + """Sync version of async add devices.""" + hass.add_job(async_add_devices, devices, update_before_add) + + hass.add_job(_setup_platform, hass, + hass.data[SONOS_DOMAIN].get('media_player', {}), + add_devices, None) + + +def _setup_platform(hass, config, add_devices, discovery_info): """Set up the Sonos platform.""" import soco import soco.events @@ -426,16 +447,23 @@ def _set_basic_information(self): self.update_volume() - self._favorites = [] - for fav in self.soco.music_library.get_sonos_favorites(): - # SoCo 0.14 raises a generic Exception on invalid xml in favorites. - # Filter those out now so our list is safe to use. - try: - if fav.reference.get_uri(): - self._favorites.append(fav) - # pylint: disable=broad-except - except Exception: - _LOGGER.debug("Ignoring invalid favorite '%s'", fav.title) + self._set_favorites() + + def _set_favorites(self): + """Set available favorites.""" + # SoCo 0.14 raises a generic Exception on invalid xml in favorites. + # Filter those out now so our list is safe to use. + # pylint: disable=broad-except + try: + self._favorites = [] + for fav in self.soco.music_library.get_sonos_favorites(): + try: + if fav.reference.get_uri(): + self._favorites.append(fav) + except Exception: + _LOGGER.debug("Ignoring invalid favorite '%s'", fav.title) + except Exception: + _LOGGER.debug("Ignoring invalid favorite list") def _radio_artwork(self, url): """Return the private URL with artwork for a radio stream.""" @@ -469,6 +497,9 @@ def _subscribe_to_player_events(self): queue = _ProcessSonosEventQueue(self.update_groups) player.zoneGroupTopology.subscribe(auto_renew=True, event_queue=queue) + queue = _ProcessSonosEventQueue(self.update_content) + player.contentDirectory.subscribe(auto_renew=True, event_queue=queue) + def update(self): """Retrieve latest state.""" available = self._check_available() @@ -682,11 +713,15 @@ def update_groups(self, event=None): if group: # New group information is pushed coordinator_uid, *slave_uids = group.split(',') - else: + elif self.soco.group: # Use SoCo cache for existing topology coordinator_uid = self.soco.group.coordinator.uid slave_uids = [p.uid for p in self.soco.group.members if p.uid != coordinator_uid] + else: + # Not yet in the cache, this can happen when a speaker boots + coordinator_uid = self.unique_id + slave_uids = [] if self.unique_id == coordinator_uid: sonos_group = [] @@ -707,6 +742,11 @@ def update_groups(self, event=None): slave._sonos_group = sonos_group slave.schedule_update_ha_state() + def update_content(self, event=None): + """Update information about available content.""" + self._set_favorites() + self.schedule_update_ha_state() + @property def volume_level(self): """Volume level of the media player (0..1).""" @@ -837,9 +877,10 @@ def source_list(self): """List of available input sources.""" sources = [fav.title for fav in self._favorites] - if 'PLAY:5' in self._model or 'CONNECT' in self._model: + model = self._model.upper() + if 'PLAY:5' in model or 'CONNECT' in model: sources += [SOURCE_LINEIN] - elif 'PLAYBAR' in self._model: + elif 'PLAYBAR' in model: sources += [SOURCE_LINEIN, SOURCE_TV] return sources diff --git a/homeassistant/components/media_player/soundtouch.py b/homeassistant/components/media_player/soundtouch.py index 9c4a0e9fa17e1c..8f14031481ada4 100644 --- a/homeassistant/components/media_player/soundtouch.py +++ b/homeassistant/components/media_player/soundtouch.py @@ -269,7 +269,7 @@ def media_title(self): """Title of current playing media.""" if self._status.station_name is not None: return self._status.station_name - elif self._status.artist is not None: + if self._status.artist is not None: return self._status.artist + " - " + self._status.track return None diff --git a/homeassistant/components/media_player/spotify.py b/homeassistant/components/media_player/spotify.py index 963258f1861df6..73ec8a175b1f17 100644 --- a/homeassistant/components/media_player/spotify.py +++ b/homeassistant/components/media_player/spotify.py @@ -20,9 +20,7 @@ CONF_NAME, STATE_PLAYING, STATE_PAUSED, STATE_IDLE, STATE_UNKNOWN) import homeassistant.helpers.config_validation as cv -COMMIT = '544614f4b1d508201d363e84e871f86c90aa26b2' -REQUIREMENTS = ['https://github.com/happyleavesaoc/spotipy/' - 'archive/%s.zip#spotipy==2.4.4' % COMMIT] +REQUIREMENTS = ['spotipy-homeassistant==2.4.4.dev1'] DEPENDENCIES = ['http'] diff --git a/homeassistant/components/media_player/squeezebox.py b/homeassistant/components/media_player/squeezebox.py index 371ad89036414a..8eb4c85f6b27bf 100644 --- a/homeassistant/components/media_player/squeezebox.py +++ b/homeassistant/components/media_player/squeezebox.py @@ -143,7 +143,7 @@ def async_service_handler(service): return True -class LogitechMediaServer(object): +class LogitechMediaServer: """Representation of a Logitech media server.""" def __init__(self, hass, host, port, username, password): diff --git a/homeassistant/components/media_player/universal.py b/homeassistant/components/media_player/universal.py index fa4f03f117913e..66d12190320f4b 100644 --- a/homeassistant/components/media_player/universal.py +++ b/homeassistant/components/media_player/universal.py @@ -5,7 +5,6 @@ https://home-assistant.io/components/media_player.universal/ """ import logging -# pylint: disable=import-error from copy import copy import voluptuous as vol @@ -30,7 +29,8 @@ SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PLAY_PAUSE, SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_MEDIA_SEEK, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, SERVICE_VOLUME_UP, - SERVICE_SHUFFLE_SET, STATE_IDLE, STATE_OFF, STATE_ON, SERVICE_MEDIA_STOP) + SERVICE_SHUFFLE_SET, STATE_IDLE, STATE_OFF, STATE_ON, STATE_UNAVAILABLE, + SERVICE_MEDIA_STOP) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_call_from_config @@ -45,7 +45,7 @@ ATTR_DATA = 'data' CONF_STATE = 'state' -OFF_STATES = [STATE_IDLE, STATE_OFF] +OFF_STATES = [STATE_IDLE, STATE_OFF, STATE_UNAVAILABLE] REQUIREMENTS = [] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/media_player/vizio.py b/homeassistant/components/media_player/vizio.py index 381482a4839d2c..046aecbb92e244 100644 --- a/homeassistant/components/media_player/vizio.py +++ b/homeassistant/components/media_player/vizio.py @@ -12,12 +12,13 @@ from homeassistant.components.media_player import ( PLATFORM_SCHEMA, SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, - SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP, MediaPlayerDevice) + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP, + MediaPlayerDevice) from homeassistant.const import ( CONF_ACCESS_TOKEN, CONF_HOST, CONF_NAME, STATE_OFF, STATE_ON, STATE_UNKNOWN) from homeassistant.helpers import config_validation as cv -import homeassistant.util as util +from homeassistant import util REQUIREMENTS = ['pyvizio==0.0.3'] @@ -39,7 +40,8 @@ SUPPORTED_COMMANDS = SUPPORT_TURN_ON | SUPPORT_TURN_OFF \ | SUPPORT_SELECT_SOURCE \ | SUPPORT_NEXT_TRACK | SUPPORT_PREVIOUS_TRACK \ - | SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_STEP + | SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_STEP \ + | SUPPORT_VOLUME_SET PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -93,12 +95,14 @@ def update(self): if is_on is None: self._state = STATE_UNKNOWN return - elif is_on is False: + if is_on is False: self._state = STATE_OFF else: self._state = STATE_ON - self._volume_level = self._device.get_current_volume() + volume = self._device.get_current_volume() + if volume is not None: + self._volume_level = float(volume) / 100. input_ = self._device.get_current_input() if input_ is not None: self._current_input = input_.meta_name @@ -167,12 +171,26 @@ def select_source(self, source): def volume_up(self): """Increasing volume of the TV.""" + self._volume_level += self._volume_step / 100. self._device.vol_up(num=self._volume_step) def volume_down(self): """Decreasing volume of the TV.""" + self._volume_level -= self._volume_step / 100. self._device.vol_down(num=self._volume_step) def validate_setup(self): """Validate if host is available and key is correct.""" return self._device.get_current_volume() is not None + + def set_volume_level(self, volume): + """Set volume level.""" + if self._volume_level is not None: + if volume > self._volume_level: + num = int(100*(volume - self._volume_level)) + self._volume_level = volume + self._device.vol_up(num=num) + elif volume < self._volume_level: + num = int(100*(self._volume_level - volume)) + self._volume_level = volume + self._device.vol_down(num=num) diff --git a/homeassistant/components/media_player/vlc.py b/homeassistant/components/media_player/vlc.py index abd8252d813c44..45e1a91c510fda 100644 --- a/homeassistant/components/media_player/vlc.py +++ b/homeassistant/components/media_player/vlc.py @@ -34,7 +34,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the vlc platform.""" add_devices([VlcDevice(config.get(CONF_NAME, DEFAULT_NAME), diff --git a/homeassistant/components/media_player/volumio.py b/homeassistant/components/media_player/volumio.py index 11ab16156172da..c4ddd38fc4f471 100644 --- a/homeassistant/components/media_player/volumio.py +++ b/homeassistant/components/media_player/volumio.py @@ -142,7 +142,7 @@ def state(self): status = self._state.get('status', None) if status == 'pause': return STATE_PAUSED - elif status == 'play': + if status == 'play': return STATE_PLAYING return STATE_IDLE diff --git a/homeassistant/components/media_player/webostv.py b/homeassistant/components/media_player/webostv.py index c3426e454048f5..362095daee6605 100644 --- a/homeassistant/components/media_player/webostv.py +++ b/homeassistant/components/media_player/webostv.py @@ -24,7 +24,7 @@ STATE_OFF, STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.script import Script -import homeassistant.util as util +from homeassistant import util REQUIREMENTS = ['pylgtv==0.1.7', 'websockets==3.2'] @@ -61,7 +61,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the LG WebOS TV platform.""" if discovery_info is not None: @@ -139,7 +138,6 @@ def request_configuration( _CONFIGURING[host], 'Failed to pair, please try again.') return - # pylint: disable=unused-argument def lgtv_configuration_callback(data): """Handle actions when configuration callback is called.""" setup_tv(host, name, customize, config, timeout, hass, diff --git a/homeassistant/components/media_player/xiaomi_tv.py b/homeassistant/components/media_player/xiaomi_tv.py index be40bf7d010752..d44ac138e4171f 100644 --- a/homeassistant/components/media_player/xiaomi_tv.py +++ b/homeassistant/components/media_player/xiaomi_tv.py @@ -13,7 +13,7 @@ SUPPORT_TURN_ON, SUPPORT_TURN_OFF, MediaPlayerDevice, PLATFORM_SCHEMA, SUPPORT_VOLUME_STEP) -REQUIREMENTS = ['pymitv==1.0.0'] +REQUIREMENTS = ['pymitv==1.4.0'] DEFAULT_NAME = "Xiaomi TV" @@ -39,7 +39,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if host is not None: # Check if there's a valid TV at the IP address. - if not Discover().checkIp(host): + if not Discover().check_ip(host): _LOGGER.error( "Could not find Xiaomi TV with specified IP: %s", host ) diff --git a/homeassistant/components/media_player/yamaha.py b/homeassistant/components/media_player/yamaha.py index 5b8ac2ad2365d9..cf36345806745e 100644 --- a/homeassistant/components/media_player/yamaha.py +++ b/homeassistant/components/media_player/yamaha.py @@ -6,32 +6,44 @@ """ import logging +import requests import voluptuous as vol from homeassistant.components.media_player import ( + DOMAIN, MEDIA_PLAYER_SCHEMA, MEDIA_TYPE_MUSIC, PLATFORM_SCHEMA, + SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, + SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, - SUPPORT_SELECT_SOURCE, SUPPORT_PLAY_MEDIA, SUPPORT_PAUSE, SUPPORT_STOP, - SUPPORT_NEXT_TRACK, SUPPORT_PREVIOUS_TRACK, SUPPORT_PLAY, - MEDIA_TYPE_MUSIC, MEDIA_PLAYER_SCHEMA, DOMAIN, - MediaPlayerDevice, PLATFORM_SCHEMA) -from homeassistant.const import (CONF_NAME, CONF_HOST, STATE_OFF, STATE_ON, - STATE_PLAYING, STATE_IDLE, ATTR_ENTITY_ID) + MediaPlayerDevice) +from homeassistant.const import ( + ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, STATE_IDLE, STATE_OFF, STATE_ON, + STATE_PLAYING) import homeassistant.helpers.config_validation as cv REQUIREMENTS = ['rxv==0.5.1'] _LOGGER = logging.getLogger(__name__) -SUPPORT_YAMAHA = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ - SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE | SUPPORT_PLAY +ATTR_ENABLED = 'enabled' +ATTR_PORT = 'port' -CONF_SOURCE_NAMES = 'source_names' CONF_SOURCE_IGNORE = 'source_ignore' -CONF_ZONE_NAMES = 'zone_names' +CONF_SOURCE_NAMES = 'source_names' CONF_ZONE_IGNORE = 'zone_ignore' +CONF_ZONE_NAMES = 'zone_names' -DEFAULT_NAME = 'Yamaha Receiver' DATA_YAMAHA = 'yamaha_known_receivers' +DEFAULT_NAME = "Yamaha Receiver" + +ENABLE_OUTPUT_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ + vol.Required(ATTR_ENABLED): cv.boolean, + vol.Required(ATTR_PORT): cv.string, +}) + +SERVICE_ENABLE_OUTPUT = 'yamaha_enable_output' + +SUPPORT_YAMAHA = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_SELECT_SOURCE | SUPPORT_PLAY PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, @@ -44,16 +56,6 @@ vol.Optional(CONF_ZONE_NAMES, default={}): {cv.string: cv.string}, }) -SERVICE_ENABLE_OUTPUT = 'yamaha_enable_output' - -ATTR_PORT = 'port' -ATTR_ENABLED = 'enabled' - -ENABLE_OUTPUT_SCHEMA = MEDIA_PLAYER_SCHEMA.extend({ - vol.Required(ATTR_PORT): cv.string, - vol.Required(ATTR_ENABLED): cv.boolean -}) - def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Yamaha platform.""" @@ -80,7 +82,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): receivers = rxv.RXV( ctrl_url, model_name=model, friendly_name=name, unit_desc_url=desc_url).zone_controllers() - _LOGGER.info("Receivers: %s", receivers) + _LOGGER.debug("Receivers: %s", receivers) # when we are dynamically discovered config is empty zone_ignore = [] elif host is None: @@ -96,15 +98,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if receiver.zone in zone_ignore: continue - device = YamahaDevice(name, receiver, source_ignore, - source_names, zone_names) + device = YamahaDevice( + name, receiver, source_ignore, source_names, zone_names) # Only add device if it's not already added if device.zone_id not in hass.data[DATA_YAMAHA]: hass.data[DATA_YAMAHA][device.zone_id] = device devices.append(device) else: - _LOGGER.debug('Ignoring duplicate receiver %s', name) + _LOGGER.debug("Ignoring duplicate receiver: %s", name) def service_handler(service): """Handle for services.""" @@ -130,8 +132,8 @@ def service_handler(service): class YamahaDevice(MediaPlayerDevice): """Representation of a Yamaha device.""" - def __init__(self, name, receiver, source_ignore, - source_names, zone_names): + def __init__( + self, name, receiver, source_ignore, source_names, zone_names): """Initialize the Yamaha Receiver.""" self.receiver = receiver self._muted = False @@ -151,7 +153,12 @@ def __init__(self, name, receiver, source_ignore, def update(self): """Get the latest details from the device.""" - self._play_status = self.receiver.play_status() + try: + self._play_status = self.receiver.play_status() + except requests.exceptions.ConnectionError: + _LOGGER.info("Receiver is offline: %s", self._name) + return + if self.receiver.on: if self._play_status is None: self._pwstate = STATE_ON @@ -222,7 +229,7 @@ def source_list(self): @property def zone_id(self): - """Return an zone_id to ensure 1 media player per zone.""" + """Return a zone_id to ensure 1 media player per zone.""" return '{0}:{1}'.format(self.receiver.ctrl_url, self._zone) @property @@ -231,11 +238,13 @@ def supported_features(self): supported_features = SUPPORT_YAMAHA supports = self._playback_support - mapping = {'play': (SUPPORT_PLAY | SUPPORT_PLAY_MEDIA), - 'pause': SUPPORT_PAUSE, - 'stop': SUPPORT_STOP, - 'skip_f': SUPPORT_NEXT_TRACK, - 'skip_r': SUPPORT_PREVIOUS_TRACK} + mapping = { + 'play': (SUPPORT_PLAY | SUPPORT_PLAY_MEDIA), + 'pause': SUPPORT_PAUSE, + 'stop': SUPPORT_STOP, + 'skip_f': SUPPORT_NEXT_TRACK, + 'skip_r': SUPPORT_PREVIOUS_TRACK, + } for attr, feature in mapping.items(): if getattr(supports, attr, False): supported_features |= feature diff --git a/homeassistant/components/microsoft_face.py b/homeassistant/components/microsoft_face.py index 7c167f93142f32..e0e0e716d2e87e 100644 --- a/homeassistant/components/microsoft_face.py +++ b/homeassistant/components/microsoft_face.py @@ -1,5 +1,5 @@ """ -Support for microsoft face recognition. +Support for Microsoft face recognition. For more details about this component, please refer to the documentation at https://home-assistant.io/components/microsoft_face/ @@ -13,7 +13,7 @@ import async_timeout import voluptuous as vol -from homeassistant.const import CONF_API_KEY, CONF_TIMEOUT +from homeassistant.const import CONF_API_KEY, CONF_TIMEOUT, ATTR_NAME from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -22,28 +22,25 @@ _LOGGER = logging.getLogger(__name__) -DOMAIN = 'microsoft_face' -DEPENDENCIES = ['camera'] +ATTR_CAMERA_ENTITY = 'camera_entity' +ATTR_GROUP = 'group' +ATTR_PERSON = 'person' -FACE_API_URL = "api.cognitive.microsoft.com/face/v1.0/{0}" +CONF_AZURE_REGION = 'azure_region' DATA_MICROSOFT_FACE = 'microsoft_face' +DEFAULT_TIMEOUT = 10 +DEPENDENCIES = ['camera'] +DOMAIN = 'microsoft_face' -CONF_AZURE_REGION = 'azure_region' +FACE_API_URL = "api.cognitive.microsoft.com/face/v1.0/{0}" SERVICE_CREATE_GROUP = 'create_group' -SERVICE_DELETE_GROUP = 'delete_group' -SERVICE_TRAIN_GROUP = 'train_group' SERVICE_CREATE_PERSON = 'create_person' +SERVICE_DELETE_GROUP = 'delete_group' SERVICE_DELETE_PERSON = 'delete_person' SERVICE_FACE_PERSON = 'face_person' - -ATTR_GROUP = 'group' -ATTR_PERSON = 'person' -ATTR_CAMERA_ENTITY = 'camera_entity' -ATTR_NAME = 'name' - -DEFAULT_TIMEOUT = 10 +SERVICE_TRAIN_GROUP = 'train_group' CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ @@ -111,7 +108,7 @@ def face_person(hass, group, person, camera_entity): @asyncio.coroutine def async_setup(hass, config): - """Set up microsoft face.""" + """Set up Microsoft Face.""" entities = {} face = MicrosoftFace( hass, @@ -292,7 +289,7 @@ def device_state_attributes(self): return attr -class MicrosoftFace(object): +class MicrosoftFace: """Microsoft Face api for HomeAssistant.""" def __init__(self, hass, server_loc, api_key, timeout, entities): diff --git a/homeassistant/components/mochad.py b/homeassistant/components/mochad.py index 9f53f84e020a7c..7e6738b95f8c23 100644 --- a/homeassistant/components/mochad.py +++ b/homeassistant/components/mochad.py @@ -61,7 +61,7 @@ def start_mochad(event): return True -class MochadCtrl(object): +class MochadCtrl: """Mochad controller.""" def __init__(self, host, port): diff --git a/homeassistant/components/modbus.py b/homeassistant/components/modbus.py index a928c0d3aca031..f484cb31a6c9e6 100644 --- a/homeassistant/components/modbus.py +++ b/homeassistant/components/modbus.py @@ -75,11 +75,10 @@ def setup(hass, config): """Set up Modbus component.""" # Modbus connection type - # pylint: disable=global-statement, import-error client_type = config[DOMAIN][CONF_TYPE] # Connect to Modbus network - # pylint: disable=global-statement, import-error + # pylint: disable=import-error if client_type == 'serial': from pymodbus.client.sync import ModbusSerialClient as ModbusClient @@ -158,7 +157,7 @@ def write_coil(service): return True -class ModbusHub(object): +class ModbusHub: """Thread safe wrapper class for pymodbus.""" def __init__(self, modbus_client): diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 55d99a0817e1fe..19bacbc8d4c2ce 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -32,7 +32,8 @@ from homeassistant.const import ( EVENT_HOMEASSISTANT_STOP, CONF_VALUE_TEMPLATE, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, CONF_PROTOCOL, CONF_PAYLOAD) -from homeassistant.components.mqtt.server import HBMQTT_CONFIG_SCHEMA + +from .server import HBMQTT_CONFIG_SCHEMA REQUIREMENTS = ['paho-mqtt==1.3.1'] @@ -306,7 +307,8 @@ async def _async_setup_server(hass: HomeAssistantType, return None success, broker_config = \ - await server.async_start(hass, conf.get(CONF_EMBEDDED)) + await server.async_start( + hass, conf.get(CONF_PASSWORD), conf.get(CONF_EMBEDDED)) if not success: return None @@ -349,6 +351,16 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: if CONF_EMBEDDED not in conf and CONF_BROKER in conf: broker_config = None else: + if (conf.get(CONF_PASSWORD) is None and + config.get('http') is not None and + config['http'].get('api_password') is not None): + _LOGGER.error( + "Starting from release 0.76, the embedded MQTT broker does not" + " use api_password as default password anymore. Please set" + " password configuration. See https://home-assistant.io/docs/" + "mqtt/broker#embedded-broker for details") + return False + broker_config = await _async_setup_server(hass, config) if CONF_BROKER in conf: @@ -462,7 +474,7 @@ async def async_publish_service(call: ServiceCall): @attr.s(slots=True, frozen=True) -class Subscription(object): +class Subscription: """Class to hold data about an active subscription.""" topic = attr.ib(type=str) @@ -472,7 +484,7 @@ class Subscription(object): @attr.s(slots=True, frozen=True) -class Message(object): +class Message: """MQTT Message.""" topic = attr.ib(type=str) @@ -481,7 +493,7 @@ class Message(object): retain = attr.ib(type=bool, default=False) -class MQTT(object): +class MQTT: """Home Assistant MQTT client.""" def __init__(self, hass: HomeAssistantType, broker: str, port: int, diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index d5a3b4a2efb7e7..128c45f1311ade 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -8,7 +8,7 @@ import logging import re -import homeassistant.components.mqtt as mqtt +from homeassistant.components import mqtt from homeassistant.helpers.discovery import async_load_platform from homeassistant.const import CONF_PLATFORM from homeassistant.components.mqtt import CONF_STATE_TOPIC @@ -21,7 +21,8 @@ SUPPORTED_COMPONENTS = [ 'binary_sensor', 'camera', 'cover', 'fan', - 'light', 'sensor', 'switch', 'lock'] + 'light', 'sensor', 'switch', 'lock', 'climate', + 'alarm_control_panel'] ALLOWED_PLATFORMS = { 'binary_sensor': ['mqtt'], @@ -32,6 +33,8 @@ 'lock': ['mqtt'], 'sensor': ['mqtt'], 'switch': ['mqtt'], + 'climate': ['mqtt'], + 'alarm_control_panel': ['mqtt'], } ALREADY_DISCOVERED = 'mqtt_discovered_components' diff --git a/homeassistant/components/mqtt/server.py b/homeassistant/components/mqtt/server.py index 8a012928792588..5fc365342aec6b 100644 --- a/homeassistant/components/mqtt/server.py +++ b/homeassistant/components/mqtt/server.py @@ -27,27 +27,29 @@ }) }, extra=vol.ALLOW_EXTRA)) +_LOGGER = logging.getLogger(__name__) + @asyncio.coroutine -def async_start(hass, server_config): +def async_start(hass, password, server_config): """Initialize MQTT Server. This method is a coroutine. """ from hbmqtt.broker import Broker, BrokerException + passwd = tempfile.NamedTemporaryFile() try: - passwd = tempfile.NamedTemporaryFile() - if server_config is None: - server_config, client_config = generate_config(hass, passwd) + server_config, client_config = generate_config( + hass, passwd, password) else: client_config = None broker = Broker(server_config, hass.loop) yield from broker.start() except BrokerException: - logging.getLogger(__name__).exception("Error initializing MQTT server") + _LOGGER.exception("Error initializing MQTT server") return False, None finally: passwd.close() @@ -63,9 +65,10 @@ def async_shutdown_mqtt_server(event): return True, client_config -def generate_config(hass, passwd): +def generate_config(hass, passwd, password): """Generate a configuration based on current Home Assistant instance.""" - from homeassistant.components.mqtt import PROTOCOL_311 + from . import PROTOCOL_311 + config = { 'listeners': { 'default': { @@ -79,29 +82,26 @@ def generate_config(hass, passwd): }, }, 'auth': { - 'allow-anonymous': hass.config.api.api_password is None + 'allow-anonymous': password is None }, 'plugins': ['auth_anonymous'], } - if hass.config.api.api_password: + if password: username = 'homeassistant' - password = hass.config.api.api_password # Encrypt with what hbmqtt uses to verify from passlib.apps import custom_app_context passwd.write( 'homeassistant:{}\n'.format( - custom_app_context.encrypt( - hass.config.api.api_password)).encode('utf-8')) + custom_app_context.encrypt(password)).encode('utf-8')) passwd.flush() config['auth']['password-file'] = passwd.name config['plugins'].append('auth_file') else: username = None - password = None client_config = ('localhost', 1883, username, password, None, PROTOCOL_311) diff --git a/homeassistant/components/mqtt_eventstream.py b/homeassistant/components/mqtt_eventstream.py index aa670578172374..ea4463f5c2347e 100644 --- a/homeassistant/components/mqtt_eventstream.py +++ b/homeassistant/components/mqtt_eventstream.py @@ -116,5 +116,4 @@ def _event_receiver(topic, payload, qos): if sub_topic: yield from mqtt.async_subscribe(sub_topic, _event_receiver) - hass.states.async_set('{domain}.initialized'.format(domain=DOMAIN), True) return True diff --git a/homeassistant/components/mychevy.py b/homeassistant/components/mychevy.py index 678cdf10c567c3..292e56418fc104 100644 --- a/homeassistant/components/mychevy.py +++ b/homeassistant/components/mychevy.py @@ -16,7 +16,7 @@ from homeassistant.helpers import discovery from homeassistant.util import Throttle -REQUIREMENTS = ["mychevy==0.1.1"] +REQUIREMENTS = ["mychevy==0.4.0"] DOMAIN = 'mychevy' UPDATE_TOPIC = DOMAIN @@ -41,7 +41,7 @@ }, extra=vol.ALLOW_EXTRA) -class EVSensorConfig(object): +class EVSensorConfig: """The EV sensor configuration.""" def __init__(self, name, attr, unit_of_measurement=None, icon=None): @@ -52,7 +52,7 @@ def __init__(self, name, attr, unit_of_measurement=None, icon=None): self.icon = icon -class EVBinarySensorConfig(object): +class EVBinarySensorConfig: """The EV binary sensor configuration.""" def __init__(self, name, attr, device_class=None): @@ -73,9 +73,6 @@ def setup(hass, base_config): hass.data[DOMAIN] = MyChevyHub(mc.MyChevy(email, password), hass) hass.data[DOMAIN].start() - discovery.load_platform(hass, 'sensor', DOMAIN, {}, config) - discovery.load_platform(hass, 'binary_sensor', DOMAIN, {}, config) - return True @@ -98,8 +95,9 @@ def __init__(self, client, hass): super().__init__() self._client = client self.hass = hass - self.car = None + self.cars = [] self.status = None + self.ready = False @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): @@ -109,7 +107,22 @@ def update(self): (like 2 to 3 minutes long time) """ - self.car = self._client.data() + self._client.login() + self._client.get_cars() + self.cars = self._client.cars + if self.ready is not True: + discovery.load_platform(self.hass, 'sensor', DOMAIN, {}, {}) + discovery.load_platform(self.hass, 'binary_sensor', DOMAIN, {}, {}) + self.ready = True + self.cars = self._client.update_cars() + + def get_car(self, vid): + """Compatibility to work with one car.""" + if self.cars: + for car in self.cars: + if car.vid == vid: + return car + return None def run(self): """Thread run loop.""" diff --git a/homeassistant/components/mysensors.py b/homeassistant/components/mysensors.py deleted file mode 100644 index 9b3944579733d9..00000000000000 --- a/homeassistant/components/mysensors.py +++ /dev/null @@ -1,640 +0,0 @@ -""" -Connect to a MySensors gateway via pymysensors API. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/mysensors/ -""" -from collections import defaultdict -import logging -import os -import socket -import sys -from timeit import default_timer as timer - -import voluptuous as vol - -from homeassistant.components.mqtt import ( - valid_publish_topic, valid_subscribe_topic) -from homeassistant.const import ( - ATTR_BATTERY_LEVEL, CONF_NAME, CONF_OPTIMISTIC, EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, STATE_OFF, STATE_ON) -from homeassistant.core import callback -from homeassistant.helpers import discovery -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.dispatcher import ( - async_dispatcher_connect, dispatcher_send) -from homeassistant.helpers.entity import Entity -from homeassistant.setup import setup_component - -REQUIREMENTS = ['pymysensors==0.11.1'] - -_LOGGER = logging.getLogger(__name__) - -ATTR_CHILD_ID = 'child_id' -ATTR_DESCRIPTION = 'description' -ATTR_DEVICE = 'device' -ATTR_DEVICES = 'devices' -ATTR_NODE_ID = 'node_id' - -CONF_BAUD_RATE = 'baud_rate' -CONF_DEBUG = 'debug' -CONF_DEVICE = 'device' -CONF_GATEWAYS = 'gateways' -CONF_PERSISTENCE = 'persistence' -CONF_PERSISTENCE_FILE = 'persistence_file' -CONF_RETAIN = 'retain' -CONF_TCP_PORT = 'tcp_port' -CONF_TOPIC_IN_PREFIX = 'topic_in_prefix' -CONF_TOPIC_OUT_PREFIX = 'topic_out_prefix' -CONF_VERSION = 'version' - -CONF_NODES = 'nodes' -CONF_NODE_NAME = 'name' - -DEFAULT_BAUD_RATE = 115200 -DEFAULT_TCP_PORT = 5003 -DEFAULT_VERSION = '1.4' -DOMAIN = 'mysensors' - -MQTT_COMPONENT = 'mqtt' -MYSENSORS_GATEWAYS = 'mysensors_gateways' -MYSENSORS_PLATFORM_DEVICES = 'mysensors_devices_{}' -PLATFORM = 'platform' -SCHEMA = 'schema' -SIGNAL_CALLBACK = 'mysensors_callback_{}_{}_{}_{}' -TYPE = 'type' - - -def is_socket_address(value): - """Validate that value is a valid address.""" - try: - socket.getaddrinfo(value, None) - return value - except OSError: - raise vol.Invalid('Device is not a valid domain name or ip address') - - -def has_parent_dir(value): - """Validate that value is in an existing directory which is writeable.""" - parent = os.path.dirname(os.path.realpath(value)) - is_dir_writable = os.path.isdir(parent) and os.access(parent, os.W_OK) - if not is_dir_writable: - raise vol.Invalid( - '{} directory does not exist or is not writeable'.format(parent)) - return value - - -def has_all_unique_files(value): - """Validate that all persistence files are unique and set if any is set.""" - persistence_files = [ - gateway.get(CONF_PERSISTENCE_FILE) for gateway in value] - if None in persistence_files and any( - name is not None for name in persistence_files): - raise vol.Invalid( - 'persistence file name of all devices must be set if any is set') - if not all(name is None for name in persistence_files): - schema = vol.Schema(vol.Unique()) - schema(persistence_files) - return value - - -def is_persistence_file(value): - """Validate that persistence file path ends in either .pickle or .json.""" - if value.endswith(('.json', '.pickle')): - return value - else: - raise vol.Invalid( - '{} does not end in either `.json` or `.pickle`'.format(value)) - - -def is_serial_port(value): - """Validate that value is a windows serial port or a unix device.""" - if sys.platform.startswith('win'): - ports = ('COM{}'.format(idx + 1) for idx in range(256)) - if value in ports: - return value - else: - raise vol.Invalid('{} is not a serial port'.format(value)) - else: - return cv.isdevice(value) - - -def deprecated(key): - """Mark key as deprecated in configuration.""" - def validator(config): - """Check if key is in config, log warning and remove key.""" - if key not in config: - return config - _LOGGER.warning( - '%s option for %s is deprecated. Please remove %s from your ' - 'configuration file', key, DOMAIN, key) - config.pop(key) - return config - return validator - - -NODE_SCHEMA = vol.Schema({ - cv.positive_int: { - vol.Required(CONF_NODE_NAME): cv.string - } -}) - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema(vol.All(deprecated(CONF_DEBUG), { - vol.Required(CONF_GATEWAYS): vol.All( - cv.ensure_list, has_all_unique_files, - [{ - vol.Required(CONF_DEVICE): - vol.Any(MQTT_COMPONENT, is_socket_address, is_serial_port), - vol.Optional(CONF_PERSISTENCE_FILE): - vol.All(cv.string, is_persistence_file, has_parent_dir), - vol.Optional(CONF_BAUD_RATE, default=DEFAULT_BAUD_RATE): - cv.positive_int, - vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port, - vol.Optional(CONF_TOPIC_IN_PREFIX): valid_subscribe_topic, - vol.Optional(CONF_TOPIC_OUT_PREFIX): valid_publish_topic, - vol.Optional(CONF_NODES, default={}): NODE_SCHEMA, - }] - ), - vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, - vol.Optional(CONF_PERSISTENCE, default=True): cv.boolean, - vol.Optional(CONF_RETAIN, default=True): cv.boolean, - vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): cv.string, - })) -}, extra=vol.ALLOW_EXTRA) - - -# MySensors const schemas -BINARY_SENSOR_SCHEMA = {PLATFORM: 'binary_sensor', TYPE: 'V_TRIPPED'} -CLIMATE_SCHEMA = {PLATFORM: 'climate', TYPE: 'V_HVAC_FLOW_STATE'} -LIGHT_DIMMER_SCHEMA = { - PLATFORM: 'light', TYPE: 'V_DIMMER', - SCHEMA: {'V_DIMMER': cv.string, 'V_LIGHT': cv.string}} -LIGHT_PERCENTAGE_SCHEMA = { - PLATFORM: 'light', TYPE: 'V_PERCENTAGE', - SCHEMA: {'V_PERCENTAGE': cv.string, 'V_STATUS': cv.string}} -LIGHT_RGB_SCHEMA = { - PLATFORM: 'light', TYPE: 'V_RGB', SCHEMA: { - 'V_RGB': cv.string, 'V_STATUS': cv.string}} -LIGHT_RGBW_SCHEMA = { - PLATFORM: 'light', TYPE: 'V_RGBW', SCHEMA: { - 'V_RGBW': cv.string, 'V_STATUS': cv.string}} -NOTIFY_SCHEMA = {PLATFORM: 'notify', TYPE: 'V_TEXT'} -DEVICE_TRACKER_SCHEMA = {PLATFORM: 'device_tracker', TYPE: 'V_POSITION'} -DUST_SCHEMA = [ - {PLATFORM: 'sensor', TYPE: 'V_DUST_LEVEL'}, - {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}] -SWITCH_LIGHT_SCHEMA = {PLATFORM: 'switch', TYPE: 'V_LIGHT'} -SWITCH_STATUS_SCHEMA = {PLATFORM: 'switch', TYPE: 'V_STATUS'} -MYSENSORS_CONST_SCHEMA = { - 'S_DOOR': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], - 'S_MOTION': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], - 'S_SMOKE': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], - 'S_SPRINKLER': [ - BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_STATUS'}], - 'S_WATER_LEAK': [ - BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], - 'S_SOUND': [ - BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}, - {PLATFORM: 'switch', TYPE: 'V_ARMED'}], - 'S_VIBRATION': [ - BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}, - {PLATFORM: 'switch', TYPE: 'V_ARMED'}], - 'S_MOISTURE': [ - BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}, - {PLATFORM: 'switch', TYPE: 'V_ARMED'}], - 'S_HVAC': [CLIMATE_SCHEMA], - 'S_COVER': [ - {PLATFORM: 'cover', TYPE: 'V_DIMMER'}, - {PLATFORM: 'cover', TYPE: 'V_PERCENTAGE'}, - {PLATFORM: 'cover', TYPE: 'V_LIGHT'}, - {PLATFORM: 'cover', TYPE: 'V_STATUS'}], - 'S_DIMMER': [LIGHT_DIMMER_SCHEMA, LIGHT_PERCENTAGE_SCHEMA], - 'S_RGB_LIGHT': [LIGHT_RGB_SCHEMA], - 'S_RGBW_LIGHT': [LIGHT_RGBW_SCHEMA], - 'S_INFO': [NOTIFY_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_TEXT'}], - 'S_GPS': [ - DEVICE_TRACKER_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_POSITION'}], - 'S_TEMP': [{PLATFORM: 'sensor', TYPE: 'V_TEMP'}], - 'S_HUM': [{PLATFORM: 'sensor', TYPE: 'V_HUM'}], - 'S_BARO': [ - {PLATFORM: 'sensor', TYPE: 'V_PRESSURE'}, - {PLATFORM: 'sensor', TYPE: 'V_FORECAST'}], - 'S_WIND': [ - {PLATFORM: 'sensor', TYPE: 'V_WIND'}, - {PLATFORM: 'sensor', TYPE: 'V_GUST'}, - {PLATFORM: 'sensor', TYPE: 'V_DIRECTION'}], - 'S_RAIN': [ - {PLATFORM: 'sensor', TYPE: 'V_RAIN'}, - {PLATFORM: 'sensor', TYPE: 'V_RAINRATE'}], - 'S_UV': [{PLATFORM: 'sensor', TYPE: 'V_UV'}], - 'S_WEIGHT': [ - {PLATFORM: 'sensor', TYPE: 'V_WEIGHT'}, - {PLATFORM: 'sensor', TYPE: 'V_IMPEDANCE'}], - 'S_POWER': [ - {PLATFORM: 'sensor', TYPE: 'V_WATT'}, - {PLATFORM: 'sensor', TYPE: 'V_KWH'}, - {PLATFORM: 'sensor', TYPE: 'V_VAR'}, - {PLATFORM: 'sensor', TYPE: 'V_VA'}, - {PLATFORM: 'sensor', TYPE: 'V_POWER_FACTOR'}], - 'S_DISTANCE': [{PLATFORM: 'sensor', TYPE: 'V_DISTANCE'}], - 'S_LIGHT_LEVEL': [ - {PLATFORM: 'sensor', TYPE: 'V_LIGHT_LEVEL'}, - {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}], - 'S_IR': [ - {PLATFORM: 'sensor', TYPE: 'V_IR_RECEIVE'}, - {PLATFORM: 'switch', TYPE: 'V_IR_SEND', - SCHEMA: {'V_IR_SEND': cv.string, 'V_LIGHT': cv.string}}], - 'S_WATER': [ - {PLATFORM: 'sensor', TYPE: 'V_FLOW'}, - {PLATFORM: 'sensor', TYPE: 'V_VOLUME'}], - 'S_CUSTOM': [ - {PLATFORM: 'sensor', TYPE: 'V_VAR1'}, - {PLATFORM: 'sensor', TYPE: 'V_VAR2'}, - {PLATFORM: 'sensor', TYPE: 'V_VAR3'}, - {PLATFORM: 'sensor', TYPE: 'V_VAR4'}, - {PLATFORM: 'sensor', TYPE: 'V_VAR5'}, - {PLATFORM: 'sensor', TYPE: 'V_CUSTOM'}], - 'S_SCENE_CONTROLLER': [ - {PLATFORM: 'sensor', TYPE: 'V_SCENE_ON'}, - {PLATFORM: 'sensor', TYPE: 'V_SCENE_OFF'}], - 'S_COLOR_SENSOR': [{PLATFORM: 'sensor', TYPE: 'V_RGB'}], - 'S_MULTIMETER': [ - {PLATFORM: 'sensor', TYPE: 'V_VOLTAGE'}, - {PLATFORM: 'sensor', TYPE: 'V_CURRENT'}, - {PLATFORM: 'sensor', TYPE: 'V_IMPEDANCE'}], - 'S_GAS': [ - {PLATFORM: 'sensor', TYPE: 'V_FLOW'}, - {PLATFORM: 'sensor', TYPE: 'V_VOLUME'}], - 'S_WATER_QUALITY': [ - {PLATFORM: 'sensor', TYPE: 'V_TEMP'}, - {PLATFORM: 'sensor', TYPE: 'V_PH'}, - {PLATFORM: 'sensor', TYPE: 'V_ORP'}, - {PLATFORM: 'sensor', TYPE: 'V_EC'}, - {PLATFORM: 'switch', TYPE: 'V_STATUS'}], - 'S_AIR_QUALITY': DUST_SCHEMA, - 'S_DUST': DUST_SCHEMA, - 'S_LIGHT': [SWITCH_LIGHT_SCHEMA], - 'S_BINARY': [SWITCH_STATUS_SCHEMA], - 'S_LOCK': [{PLATFORM: 'switch', TYPE: 'V_LOCK_STATUS'}], -} - - -def setup(hass, config): - """Set up the MySensors component.""" - import mysensors.mysensors as mysensors - - version = config[DOMAIN].get(CONF_VERSION) - persistence = config[DOMAIN].get(CONF_PERSISTENCE) - - def setup_gateway(device, persistence_file, baud_rate, tcp_port, in_prefix, - out_prefix): - """Return gateway after setup of the gateway.""" - if device == MQTT_COMPONENT: - if not setup_component(hass, MQTT_COMPONENT, config): - return - mqtt = hass.components.mqtt - retain = config[DOMAIN].get(CONF_RETAIN) - - def pub_callback(topic, payload, qos, retain): - """Call MQTT publish function.""" - mqtt.publish(topic, payload, qos, retain) - - def sub_callback(topic, sub_cb, qos): - """Call MQTT subscribe function.""" - mqtt.subscribe(topic, sub_cb, qos) - gateway = mysensors.MQTTGateway( - pub_callback, sub_callback, - event_callback=None, persistence=persistence, - persistence_file=persistence_file, - protocol_version=version, in_prefix=in_prefix, - out_prefix=out_prefix, retain=retain) - else: - try: - is_serial_port(device) - gateway = mysensors.SerialGateway( - device, event_callback=None, persistence=persistence, - persistence_file=persistence_file, - protocol_version=version, baud=baud_rate) - except vol.Invalid: - try: - socket.getaddrinfo(device, None) - # valid ip address - gateway = mysensors.TCPGateway( - device, event_callback=None, persistence=persistence, - persistence_file=persistence_file, - protocol_version=version, port=tcp_port) - except OSError: - # invalid ip address - return - gateway.metric = hass.config.units.is_metric - gateway.optimistic = config[DOMAIN].get(CONF_OPTIMISTIC) - gateway.device = device - gateway.event_callback = gw_callback_factory(hass) - - def gw_start(event): - """Trigger to start of the gateway and any persistence.""" - if persistence: - discover_persistent_devices(hass, gateway) - gateway.start() - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, - lambda event: gateway.stop()) - - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, gw_start) - - return gateway - - # Setup all devices from config - gateways = {} - conf_gateways = config[DOMAIN][CONF_GATEWAYS] - - for index, gway in enumerate(conf_gateways): - device = gway[CONF_DEVICE] - persistence_file = gway.get( - CONF_PERSISTENCE_FILE, - hass.config.path('mysensors{}.pickle'.format(index + 1))) - baud_rate = gway.get(CONF_BAUD_RATE) - tcp_port = gway.get(CONF_TCP_PORT) - in_prefix = gway.get(CONF_TOPIC_IN_PREFIX, '') - out_prefix = gway.get(CONF_TOPIC_OUT_PREFIX, '') - ready_gateway = setup_gateway( - device, persistence_file, baud_rate, tcp_port, in_prefix, - out_prefix) - if ready_gateway is not None: - ready_gateway.nodes_config = gway.get(CONF_NODES) - gateways[id(ready_gateway)] = ready_gateway - - if not gateways: - _LOGGER.error( - "No devices could be setup as gateways, check your configuration") - return False - - hass.data[MYSENSORS_GATEWAYS] = gateways - - return True - - -def validate_child(gateway, node_id, child): - """Validate that a child has the correct values according to schema. - - Return a dict of platform with a list of device ids for validated devices. - """ - validated = defaultdict(list) - - if not child.values: - _LOGGER.debug( - "No child values for node %s child %s", node_id, child.id) - return validated - if gateway.sensors[node_id].sketch_name is None: - _LOGGER.debug("Node %s is missing sketch name", node_id) - return validated - pres = gateway.const.Presentation - set_req = gateway.const.SetReq - s_name = next( - (member.name for member in pres if member.value == child.type), None) - if s_name not in MYSENSORS_CONST_SCHEMA: - _LOGGER.warning("Child type %s is not supported", s_name) - return validated - child_schemas = MYSENSORS_CONST_SCHEMA[s_name] - - def msg(name): - """Return a message for an invalid schema.""" - return "{} requires value_type {}".format( - pres(child.type).name, set_req[name].name) - - for schema in child_schemas: - platform = schema[PLATFORM] - v_name = schema[TYPE] - value_type = next( - (member.value for member in set_req if member.name == v_name), - None) - if value_type is None: - continue - _child_schema = child.get_schema(gateway.protocol_version) - vol_schema = _child_schema.extend( - {vol.Required(set_req[key].value, msg=msg(key)): - _child_schema.schema.get(set_req[key].value, val) - for key, val in schema.get(SCHEMA, {v_name: cv.string}).items()}, - extra=vol.ALLOW_EXTRA) - try: - vol_schema(child.values) - except vol.Invalid as exc: - level = (logging.WARNING if value_type in child.values - else logging.DEBUG) - _LOGGER.log( - level, - "Invalid values: %s: %s platform: node %s child %s: %s", - child.values, platform, node_id, child.id, exc) - continue - dev_id = id(gateway), node_id, child.id, value_type - validated[platform].append(dev_id) - return validated - - -def discover_mysensors_platform(hass, platform, new_devices): - """Discover a MySensors platform.""" - discovery.load_platform( - hass, platform, DOMAIN, {ATTR_DEVICES: new_devices, CONF_NAME: DOMAIN}) - - -def discover_persistent_devices(hass, gateway): - """Discover platforms for devices loaded via persistence file.""" - new_devices = defaultdict(list) - for node_id in gateway.sensors: - node = gateway.sensors[node_id] - for child in node.children.values(): - validated = validate_child(gateway, node_id, child) - for platform, dev_ids in validated.items(): - new_devices[platform].extend(dev_ids) - for platform, dev_ids in new_devices.items(): - discover_mysensors_platform(hass, platform, dev_ids) - - -def get_mysensors_devices(hass, domain): - """Return MySensors devices for a platform.""" - if MYSENSORS_PLATFORM_DEVICES.format(domain) not in hass.data: - hass.data[MYSENSORS_PLATFORM_DEVICES.format(domain)] = {} - return hass.data[MYSENSORS_PLATFORM_DEVICES.format(domain)] - - -def gw_callback_factory(hass): - """Return a new callback for the gateway.""" - def mysensors_callback(msg): - """Handle messages from a MySensors gateway.""" - start = timer() - _LOGGER.debug( - "Node update: node %s child %s", msg.node_id, msg.child_id) - - child = msg.gateway.sensors[msg.node_id].children.get(msg.child_id) - if child is None: - _LOGGER.debug("Not a child update for node %s", msg.node_id) - return - - signals = [] - - # Update all platforms for the device via dispatcher. - # Add/update entity if schema validates to true. - validated = validate_child(msg.gateway, msg.node_id, child) - for platform, dev_ids in validated.items(): - devices = get_mysensors_devices(hass, platform) - new_dev_ids = [] - for dev_id in dev_ids: - if dev_id in devices: - signals.append(SIGNAL_CALLBACK.format(*dev_id)) - else: - new_dev_ids.append(dev_id) - if new_dev_ids: - discover_mysensors_platform(hass, platform, new_dev_ids) - for signal in set(signals): - # Only one signal per device is needed. - # A device can have multiple platforms, ie multiple schemas. - # FOR LATER: Add timer to not signal if another update comes in. - dispatcher_send(hass, signal) - end = timer() - if end - start > 0.1: - _LOGGER.debug( - "Callback for node %s child %s took %.3f seconds", - msg.node_id, msg.child_id, end - start) - return mysensors_callback - - -def get_mysensors_name(gateway, node_id, child_id): - """Return a name for a node child.""" - node_name = '{} {}'.format( - gateway.sensors[node_id].sketch_name, node_id) - node_name = next( - (node[CONF_NODE_NAME] for conf_id, node in gateway.nodes_config.items() - if node.get(CONF_NODE_NAME) is not None and conf_id == node_id), - node_name) - return '{} {}'.format(node_name, child_id) - - -def get_mysensors_gateway(hass, gateway_id): - """Return MySensors gateway.""" - if MYSENSORS_GATEWAYS not in hass.data: - hass.data[MYSENSORS_GATEWAYS] = {} - gateways = hass.data.get(MYSENSORS_GATEWAYS) - return gateways.get(gateway_id) - - -@callback -def setup_mysensors_platform( - hass, domain, discovery_info, device_class, device_args=None, - async_add_devices=None): - """Set up a MySensors platform.""" - # Only act if called via mysensors by discovery event. - # Otherwise gateway is not setup. - if not discovery_info: - return - if device_args is None: - device_args = () - new_devices = [] - new_dev_ids = discovery_info[ATTR_DEVICES] - for dev_id in new_dev_ids: - devices = get_mysensors_devices(hass, domain) - if dev_id in devices: - continue - gateway_id, node_id, child_id, value_type = dev_id - gateway = get_mysensors_gateway(hass, gateway_id) - if not gateway: - continue - device_class_copy = device_class - if isinstance(device_class, dict): - child = gateway.sensors[node_id].children[child_id] - s_type = gateway.const.Presentation(child.type).name - device_class_copy = device_class[s_type] - name = get_mysensors_name(gateway, node_id, child_id) - - args_copy = (*device_args, gateway, node_id, child_id, name, - value_type) - devices[dev_id] = device_class_copy(*args_copy) - new_devices.append(devices[dev_id]) - if new_devices: - _LOGGER.info("Adding new devices: %s", new_devices) - if async_add_devices is not None: - async_add_devices(new_devices, True) - return new_devices - - -class MySensorsDevice(object): - """Representation of a MySensors device.""" - - def __init__(self, gateway, node_id, child_id, name, value_type): - """Set up the MySensors device.""" - self.gateway = gateway - self.node_id = node_id - self.child_id = child_id - self._name = name - self.value_type = value_type - child = gateway.sensors[node_id].children[child_id] - self.child_type = child.type - self._values = {} - - @property - def name(self): - """Return the name of this entity.""" - return self._name - - @property - def device_state_attributes(self): - """Return device specific state attributes.""" - node = self.gateway.sensors[self.node_id] - child = node.children[self.child_id] - attr = { - ATTR_BATTERY_LEVEL: node.battery_level, - ATTR_CHILD_ID: self.child_id, - ATTR_DESCRIPTION: child.description, - ATTR_DEVICE: self.gateway.device, - ATTR_NODE_ID: self.node_id, - } - - set_req = self.gateway.const.SetReq - - for value_type, value in self._values.items(): - attr[set_req(value_type).name] = value - - return attr - - async def async_update(self): - """Update the controller with the latest value from a sensor.""" - node = self.gateway.sensors[self.node_id] - child = node.children[self.child_id] - set_req = self.gateway.const.SetReq - for value_type, value in child.values.items(): - _LOGGER.debug( - "Entity update: %s: value_type %s, value = %s", - self._name, value_type, value) - if value_type in (set_req.V_ARMED, set_req.V_LIGHT, - set_req.V_LOCK_STATUS, set_req.V_TRIPPED): - self._values[value_type] = ( - STATE_ON if int(value) == 1 else STATE_OFF) - elif value_type == set_req.V_DIMMER: - self._values[value_type] = int(value) - else: - self._values[value_type] = value - - -class MySensorsEntity(MySensorsDevice, Entity): - """Representation of a MySensors entity.""" - - @property - def should_poll(self): - """Return the polling state. The gateway pushes its states.""" - return False - - @property - def available(self): - """Return true if entity is available.""" - return self.value_type in self._values - - @callback - def async_update_callback(self): - """Update the entity.""" - self.async_schedule_update_ha_state(True) - - async def async_added_to_hass(self): - """Register update callback.""" - dev_id = id(self.gateway), self.node_id, self.child_id, self.value_type - async_dispatcher_connect( - self.hass, SIGNAL_CALLBACK.format(*dev_id), - self.async_update_callback) diff --git a/homeassistant/components/mysensors/__init__.py b/homeassistant/components/mysensors/__init__.py new file mode 100644 index 00000000000000..e498539f2f9e78 --- /dev/null +++ b/homeassistant/components/mysensors/__init__.py @@ -0,0 +1,166 @@ +""" +Connect to a MySensors gateway via pymysensors API. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/mysensors/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.mqtt import ( + valid_publish_topic, valid_subscribe_topic) +from homeassistant.const import CONF_OPTIMISTIC +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv + +from .const import ( + ATTR_DEVICES, CONF_BAUD_RATE, CONF_DEVICE, CONF_GATEWAYS, + CONF_NODES, CONF_PERSISTENCE, CONF_PERSISTENCE_FILE, CONF_RETAIN, + CONF_TCP_PORT, CONF_TOPIC_IN_PREFIX, CONF_TOPIC_OUT_PREFIX, CONF_VERSION, + DOMAIN, MYSENSORS_GATEWAYS) +from .device import get_mysensors_devices +from .gateway import get_mysensors_gateway, setup_gateways, finish_setup + +REQUIREMENTS = ['pymysensors==0.17.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_DEBUG = 'debug' +CONF_NODE_NAME = 'name' + +DEFAULT_BAUD_RATE = 115200 +DEFAULT_TCP_PORT = 5003 +DEFAULT_VERSION = '1.4' + + +def has_all_unique_files(value): + """Validate that all persistence files are unique and set if any is set.""" + persistence_files = [ + gateway.get(CONF_PERSISTENCE_FILE) for gateway in value] + if None in persistence_files and any( + name is not None for name in persistence_files): + raise vol.Invalid( + 'persistence file name of all devices must be set if any is set') + if not all(name is None for name in persistence_files): + schema = vol.Schema(vol.Unique()) + schema(persistence_files) + return value + + +def is_persistence_file(value): + """Validate that persistence file path ends in either .pickle or .json.""" + if value.endswith(('.json', '.pickle')): + return value + raise vol.Invalid( + '{} does not end in either `.json` or `.pickle`'.format(value)) + + +def deprecated(key): + """Mark key as deprecated in configuration.""" + def validator(config): + """Check if key is in config, log warning and remove key.""" + if key not in config: + return config + _LOGGER.warning( + '%s option for %s is deprecated. Please remove %s from your ' + 'configuration file', key, DOMAIN, key) + config.pop(key) + return config + return validator + + +NODE_SCHEMA = vol.Schema({ + cv.positive_int: { + vol.Required(CONF_NODE_NAME): cv.string + } +}) + +GATEWAY_SCHEMA = { + vol.Required(CONF_DEVICE): cv.string, + vol.Optional(CONF_PERSISTENCE_FILE): + vol.All(cv.string, is_persistence_file), + vol.Optional(CONF_BAUD_RATE, default=DEFAULT_BAUD_RATE): + cv.positive_int, + vol.Optional(CONF_TCP_PORT, default=DEFAULT_TCP_PORT): cv.port, + vol.Optional(CONF_TOPIC_IN_PREFIX): valid_subscribe_topic, + vol.Optional(CONF_TOPIC_OUT_PREFIX): valid_publish_topic, + vol.Optional(CONF_NODES, default={}): NODE_SCHEMA, +} + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema(vol.All(deprecated(CONF_DEBUG), { + vol.Required(CONF_GATEWAYS): vol.All( + cv.ensure_list, has_all_unique_files, [GATEWAY_SCHEMA]), + vol.Optional(CONF_OPTIMISTIC, default=False): cv.boolean, + vol.Optional(CONF_PERSISTENCE, default=True): cv.boolean, + vol.Optional(CONF_RETAIN, default=True): cv.boolean, + vol.Optional(CONF_VERSION, default=DEFAULT_VERSION): cv.string, + })) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the MySensors component.""" + gateways = await setup_gateways(hass, config) + + if not gateways: + _LOGGER.error( + "No devices could be setup as gateways, check your configuration") + return False + + hass.data[MYSENSORS_GATEWAYS] = gateways + + hass.async_add_job(finish_setup(hass, gateways)) + + return True + + +def _get_mysensors_name(gateway, node_id, child_id): + """Return a name for a node child.""" + node_name = '{} {}'.format( + gateway.sensors[node_id].sketch_name, node_id) + node_name = next( + (node[CONF_NODE_NAME] for conf_id, node in gateway.nodes_config.items() + if node.get(CONF_NODE_NAME) is not None and conf_id == node_id), + node_name) + return '{} {}'.format(node_name, child_id) + + +@callback +def setup_mysensors_platform( + hass, domain, discovery_info, device_class, device_args=None, + async_add_devices=None): + """Set up a MySensors platform.""" + # Only act if called via MySensors by discovery event. + # Otherwise gateway is not setup. + if not discovery_info: + return + if device_args is None: + device_args = () + new_devices = [] + new_dev_ids = discovery_info[ATTR_DEVICES] + for dev_id in new_dev_ids: + devices = get_mysensors_devices(hass, domain) + if dev_id in devices: + continue + gateway_id, node_id, child_id, value_type = dev_id + gateway = get_mysensors_gateway(hass, gateway_id) + if not gateway: + continue + device_class_copy = device_class + if isinstance(device_class, dict): + child = gateway.sensors[node_id].children[child_id] + s_type = gateway.const.Presentation(child.type).name + device_class_copy = device_class[s_type] + name = _get_mysensors_name(gateway, node_id, child_id) + + args_copy = (*device_args, gateway, node_id, child_id, name, + value_type) + devices[dev_id] = device_class_copy(*args_copy) + new_devices.append(devices[dev_id]) + if new_devices: + _LOGGER.info("Adding new devices: %s", new_devices) + if async_add_devices is not None: + async_add_devices(new_devices, True) + return new_devices diff --git a/homeassistant/components/mysensors/const.py b/homeassistant/components/mysensors/const.py new file mode 100644 index 00000000000000..4f9718a39dbf2d --- /dev/null +++ b/homeassistant/components/mysensors/const.py @@ -0,0 +1,138 @@ +"""MySensors constants.""" +import homeassistant.helpers.config_validation as cv + +ATTR_DEVICES = 'devices' + +CONF_BAUD_RATE = 'baud_rate' +CONF_DEVICE = 'device' +CONF_GATEWAYS = 'gateways' +CONF_NODES = 'nodes' +CONF_PERSISTENCE = 'persistence' +CONF_PERSISTENCE_FILE = 'persistence_file' +CONF_RETAIN = 'retain' +CONF_TCP_PORT = 'tcp_port' +CONF_TOPIC_IN_PREFIX = 'topic_in_prefix' +CONF_TOPIC_OUT_PREFIX = 'topic_out_prefix' +CONF_VERSION = 'version' + +DOMAIN = 'mysensors' +MYSENSORS_GATEWAYS = 'mysensors_gateways' +PLATFORM = 'platform' +SCHEMA = 'schema' +SIGNAL_CALLBACK = 'mysensors_callback_{}_{}_{}_{}' +TYPE = 'type' + +# MySensors const schemas +BINARY_SENSOR_SCHEMA = {PLATFORM: 'binary_sensor', TYPE: 'V_TRIPPED'} +CLIMATE_SCHEMA = {PLATFORM: 'climate', TYPE: 'V_HVAC_FLOW_STATE'} +LIGHT_DIMMER_SCHEMA = { + PLATFORM: 'light', TYPE: 'V_DIMMER', + SCHEMA: {'V_DIMMER': cv.string, 'V_LIGHT': cv.string}} +LIGHT_PERCENTAGE_SCHEMA = { + PLATFORM: 'light', TYPE: 'V_PERCENTAGE', + SCHEMA: {'V_PERCENTAGE': cv.string, 'V_STATUS': cv.string}} +LIGHT_RGB_SCHEMA = { + PLATFORM: 'light', TYPE: 'V_RGB', SCHEMA: { + 'V_RGB': cv.string, 'V_STATUS': cv.string}} +LIGHT_RGBW_SCHEMA = { + PLATFORM: 'light', TYPE: 'V_RGBW', SCHEMA: { + 'V_RGBW': cv.string, 'V_STATUS': cv.string}} +NOTIFY_SCHEMA = {PLATFORM: 'notify', TYPE: 'V_TEXT'} +DEVICE_TRACKER_SCHEMA = {PLATFORM: 'device_tracker', TYPE: 'V_POSITION'} +DUST_SCHEMA = [ + {PLATFORM: 'sensor', TYPE: 'V_DUST_LEVEL'}, + {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}] +SWITCH_LIGHT_SCHEMA = {PLATFORM: 'switch', TYPE: 'V_LIGHT'} +SWITCH_STATUS_SCHEMA = {PLATFORM: 'switch', TYPE: 'V_STATUS'} +MYSENSORS_CONST_SCHEMA = { + 'S_DOOR': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_MOTION': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_SMOKE': [BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_SPRINKLER': [ + BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_STATUS'}], + 'S_WATER_LEAK': [ + BINARY_SENSOR_SCHEMA, {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_SOUND': [ + BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}, + {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_VIBRATION': [ + BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}, + {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_MOISTURE': [ + BINARY_SENSOR_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}, + {PLATFORM: 'switch', TYPE: 'V_ARMED'}], + 'S_HVAC': [CLIMATE_SCHEMA], + 'S_COVER': [ + {PLATFORM: 'cover', TYPE: 'V_DIMMER'}, + {PLATFORM: 'cover', TYPE: 'V_PERCENTAGE'}, + {PLATFORM: 'cover', TYPE: 'V_LIGHT'}, + {PLATFORM: 'cover', TYPE: 'V_STATUS'}], + 'S_DIMMER': [LIGHT_DIMMER_SCHEMA, LIGHT_PERCENTAGE_SCHEMA], + 'S_RGB_LIGHT': [LIGHT_RGB_SCHEMA], + 'S_RGBW_LIGHT': [LIGHT_RGBW_SCHEMA], + 'S_INFO': [NOTIFY_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_TEXT'}], + 'S_GPS': [ + DEVICE_TRACKER_SCHEMA, {PLATFORM: 'sensor', TYPE: 'V_POSITION'}], + 'S_TEMP': [{PLATFORM: 'sensor', TYPE: 'V_TEMP'}], + 'S_HUM': [{PLATFORM: 'sensor', TYPE: 'V_HUM'}], + 'S_BARO': [ + {PLATFORM: 'sensor', TYPE: 'V_PRESSURE'}, + {PLATFORM: 'sensor', TYPE: 'V_FORECAST'}], + 'S_WIND': [ + {PLATFORM: 'sensor', TYPE: 'V_WIND'}, + {PLATFORM: 'sensor', TYPE: 'V_GUST'}, + {PLATFORM: 'sensor', TYPE: 'V_DIRECTION'}], + 'S_RAIN': [ + {PLATFORM: 'sensor', TYPE: 'V_RAIN'}, + {PLATFORM: 'sensor', TYPE: 'V_RAINRATE'}], + 'S_UV': [{PLATFORM: 'sensor', TYPE: 'V_UV'}], + 'S_WEIGHT': [ + {PLATFORM: 'sensor', TYPE: 'V_WEIGHT'}, + {PLATFORM: 'sensor', TYPE: 'V_IMPEDANCE'}], + 'S_POWER': [ + {PLATFORM: 'sensor', TYPE: 'V_WATT'}, + {PLATFORM: 'sensor', TYPE: 'V_KWH'}, + {PLATFORM: 'sensor', TYPE: 'V_VAR'}, + {PLATFORM: 'sensor', TYPE: 'V_VA'}, + {PLATFORM: 'sensor', TYPE: 'V_POWER_FACTOR'}], + 'S_DISTANCE': [{PLATFORM: 'sensor', TYPE: 'V_DISTANCE'}], + 'S_LIGHT_LEVEL': [ + {PLATFORM: 'sensor', TYPE: 'V_LIGHT_LEVEL'}, + {PLATFORM: 'sensor', TYPE: 'V_LEVEL'}], + 'S_IR': [ + {PLATFORM: 'sensor', TYPE: 'V_IR_RECEIVE'}, + {PLATFORM: 'switch', TYPE: 'V_IR_SEND', + SCHEMA: {'V_IR_SEND': cv.string, 'V_LIGHT': cv.string}}], + 'S_WATER': [ + {PLATFORM: 'sensor', TYPE: 'V_FLOW'}, + {PLATFORM: 'sensor', TYPE: 'V_VOLUME'}], + 'S_CUSTOM': [ + {PLATFORM: 'sensor', TYPE: 'V_VAR1'}, + {PLATFORM: 'sensor', TYPE: 'V_VAR2'}, + {PLATFORM: 'sensor', TYPE: 'V_VAR3'}, + {PLATFORM: 'sensor', TYPE: 'V_VAR4'}, + {PLATFORM: 'sensor', TYPE: 'V_VAR5'}, + {PLATFORM: 'sensor', TYPE: 'V_CUSTOM'}], + 'S_SCENE_CONTROLLER': [ + {PLATFORM: 'sensor', TYPE: 'V_SCENE_ON'}, + {PLATFORM: 'sensor', TYPE: 'V_SCENE_OFF'}], + 'S_COLOR_SENSOR': [{PLATFORM: 'sensor', TYPE: 'V_RGB'}], + 'S_MULTIMETER': [ + {PLATFORM: 'sensor', TYPE: 'V_VOLTAGE'}, + {PLATFORM: 'sensor', TYPE: 'V_CURRENT'}, + {PLATFORM: 'sensor', TYPE: 'V_IMPEDANCE'}], + 'S_GAS': [ + {PLATFORM: 'sensor', TYPE: 'V_FLOW'}, + {PLATFORM: 'sensor', TYPE: 'V_VOLUME'}], + 'S_WATER_QUALITY': [ + {PLATFORM: 'sensor', TYPE: 'V_TEMP'}, + {PLATFORM: 'sensor', TYPE: 'V_PH'}, + {PLATFORM: 'sensor', TYPE: 'V_ORP'}, + {PLATFORM: 'sensor', TYPE: 'V_EC'}, + {PLATFORM: 'switch', TYPE: 'V_STATUS'}], + 'S_AIR_QUALITY': DUST_SCHEMA, + 'S_DUST': DUST_SCHEMA, + 'S_LIGHT': [SWITCH_LIGHT_SCHEMA], + 'S_BINARY': [SWITCH_STATUS_SCHEMA], + 'S_LOCK': [{PLATFORM: 'switch', TYPE: 'V_LOCK_STATUS'}], +} diff --git a/homeassistant/components/mysensors/device.py b/homeassistant/components/mysensors/device.py new file mode 100644 index 00000000000000..3ae99f61d17f79 --- /dev/null +++ b/homeassistant/components/mysensors/device.py @@ -0,0 +1,109 @@ +"""Handle MySensors devices.""" +import logging + +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, STATE_OFF, STATE_ON) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import SIGNAL_CALLBACK + +_LOGGER = logging.getLogger(__name__) + +ATTR_CHILD_ID = 'child_id' +ATTR_DESCRIPTION = 'description' +ATTR_DEVICE = 'device' +ATTR_NODE_ID = 'node_id' +MYSENSORS_PLATFORM_DEVICES = 'mysensors_devices_{}' + + +def get_mysensors_devices(hass, domain): + """Return MySensors devices for a platform.""" + if MYSENSORS_PLATFORM_DEVICES.format(domain) not in hass.data: + hass.data[MYSENSORS_PLATFORM_DEVICES.format(domain)] = {} + return hass.data[MYSENSORS_PLATFORM_DEVICES.format(domain)] + + +class MySensorsDevice: + """Representation of a MySensors device.""" + + def __init__(self, gateway, node_id, child_id, name, value_type): + """Set up the MySensors device.""" + self.gateway = gateway + self.node_id = node_id + self.child_id = child_id + self._name = name + self.value_type = value_type + child = gateway.sensors[node_id].children[child_id] + self.child_type = child.type + self._values = {} + + @property + def name(self): + """Return the name of this entity.""" + return self._name + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + node = self.gateway.sensors[self.node_id] + child = node.children[self.child_id] + attr = { + ATTR_BATTERY_LEVEL: node.battery_level, + ATTR_CHILD_ID: self.child_id, + ATTR_DESCRIPTION: child.description, + ATTR_DEVICE: self.gateway.device, + ATTR_NODE_ID: self.node_id, + } + + set_req = self.gateway.const.SetReq + + for value_type, value in self._values.items(): + attr[set_req(value_type).name] = value + + return attr + + async def async_update(self): + """Update the controller with the latest value from a sensor.""" + node = self.gateway.sensors[self.node_id] + child = node.children[self.child_id] + set_req = self.gateway.const.SetReq + for value_type, value in child.values.items(): + _LOGGER.debug( + "Entity update: %s: value_type %s, value = %s", + self._name, value_type, value) + if value_type in (set_req.V_ARMED, set_req.V_LIGHT, + set_req.V_LOCK_STATUS, set_req.V_TRIPPED): + self._values[value_type] = ( + STATE_ON if int(value) == 1 else STATE_OFF) + elif value_type == set_req.V_DIMMER: + self._values[value_type] = int(value) + else: + self._values[value_type] = value + + +class MySensorsEntity(MySensorsDevice, Entity): + """Representation of a MySensors entity.""" + + @property + def should_poll(self): + """Return the polling state. The gateway pushes its states.""" + return False + + @property + def available(self): + """Return true if entity is available.""" + return self.value_type in self._values + + @callback + def async_update_callback(self): + """Update the entity.""" + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Register update callback.""" + dev_id = id(self.gateway), self.node_id, self.child_id, self.value_type + async_dispatcher_connect( + self.hass, SIGNAL_CALLBACK.format(*dev_id), + self.async_update_callback) diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py new file mode 100644 index 00000000000000..88725e67940d68 --- /dev/null +++ b/homeassistant/components/mysensors/gateway.py @@ -0,0 +1,330 @@ +"""Handle MySensors gateways.""" +import asyncio +from collections import defaultdict +import logging +import socket +import sys +from timeit import default_timer as timer + +import async_timeout +import voluptuous as vol + +from homeassistant.const import ( + CONF_NAME, CONF_OPTIMISTIC, EVENT_HOMEASSISTANT_STOP) +from homeassistant.core import callback +from homeassistant.helpers import discovery +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.setup import async_setup_component + +from .const import ( + ATTR_DEVICES, CONF_BAUD_RATE, CONF_DEVICE, CONF_GATEWAYS, CONF_NODES, + CONF_PERSISTENCE, CONF_PERSISTENCE_FILE, CONF_RETAIN, CONF_TCP_PORT, + CONF_TOPIC_IN_PREFIX, CONF_TOPIC_OUT_PREFIX, CONF_VERSION, DOMAIN, + MYSENSORS_CONST_SCHEMA, MYSENSORS_GATEWAYS, PLATFORM, SCHEMA, + SIGNAL_CALLBACK, TYPE) +from .device import get_mysensors_devices + +_LOGGER = logging.getLogger(__name__) + +GATEWAY_READY_TIMEOUT = 15.0 +MQTT_COMPONENT = 'mqtt' +MYSENSORS_GATEWAY_READY = 'mysensors_gateway_ready_{}' + + +def is_serial_port(value): + """Validate that value is a windows serial port or a unix device.""" + if sys.platform.startswith('win'): + ports = ('COM{}'.format(idx + 1) for idx in range(256)) + if value in ports: + return value + raise vol.Invalid('{} is not a serial port'.format(value)) + return cv.isdevice(value) + + +def is_socket_address(value): + """Validate that value is a valid address.""" + try: + socket.getaddrinfo(value, None) + return value + except OSError: + raise vol.Invalid('Device is not a valid domain name or ip address') + + +def get_mysensors_gateway(hass, gateway_id): + """Return MySensors gateway.""" + if MYSENSORS_GATEWAYS not in hass.data: + hass.data[MYSENSORS_GATEWAYS] = {} + gateways = hass.data.get(MYSENSORS_GATEWAYS) + return gateways.get(gateway_id) + + +async def setup_gateways(hass, config): + """Set up all gateways.""" + conf = config[DOMAIN] + gateways = {} + + for index, gateway_conf in enumerate(conf[CONF_GATEWAYS]): + persistence_file = gateway_conf.get( + CONF_PERSISTENCE_FILE, + hass.config.path('mysensors{}.pickle'.format(index + 1))) + ready_gateway = await _get_gateway( + hass, config, gateway_conf, persistence_file) + if ready_gateway is not None: + gateways[id(ready_gateway)] = ready_gateway + + return gateways + + +async def _get_gateway(hass, config, gateway_conf, persistence_file): + """Return gateway after setup of the gateway.""" + from mysensors import mysensors + + conf = config[DOMAIN] + persistence = conf[CONF_PERSISTENCE] + version = conf[CONF_VERSION] + device = gateway_conf[CONF_DEVICE] + baud_rate = gateway_conf[CONF_BAUD_RATE] + tcp_port = gateway_conf[CONF_TCP_PORT] + in_prefix = gateway_conf.get(CONF_TOPIC_IN_PREFIX, '') + out_prefix = gateway_conf.get(CONF_TOPIC_OUT_PREFIX, '') + + if device == MQTT_COMPONENT: + if not await async_setup_component(hass, MQTT_COMPONENT, config): + return None + mqtt = hass.components.mqtt + retain = conf[CONF_RETAIN] + + def pub_callback(topic, payload, qos, retain): + """Call MQTT publish function.""" + mqtt.async_publish(topic, payload, qos, retain) + + def sub_callback(topic, sub_cb, qos): + """Call MQTT subscribe function.""" + @callback + def internal_callback(*args): + """Call callback.""" + sub_cb(*args) + + hass.async_add_job( + mqtt.async_subscribe(topic, internal_callback, qos)) + + gateway = mysensors.AsyncMQTTGateway( + pub_callback, sub_callback, in_prefix=in_prefix, + out_prefix=out_prefix, retain=retain, loop=hass.loop, + event_callback=None, persistence=persistence, + persistence_file=persistence_file, + protocol_version=version) + else: + try: + await hass.async_add_job(is_serial_port, device) + gateway = mysensors.AsyncSerialGateway( + device, baud=baud_rate, loop=hass.loop, + event_callback=None, persistence=persistence, + persistence_file=persistence_file, + protocol_version=version) + except vol.Invalid: + try: + await hass.async_add_job(is_socket_address, device) + # valid ip address + gateway = mysensors.AsyncTCPGateway( + device, port=tcp_port, loop=hass.loop, event_callback=None, + persistence=persistence, persistence_file=persistence_file, + protocol_version=version) + except vol.Invalid: + # invalid ip address + return None + gateway.metric = hass.config.units.is_metric + gateway.optimistic = conf[CONF_OPTIMISTIC] + gateway.device = device + gateway.event_callback = _gw_callback_factory(hass) + gateway.nodes_config = gateway_conf[CONF_NODES] + if persistence: + await gateway.start_persistence() + + return gateway + + +async def finish_setup(hass, gateways): + """Load any persistent devices and platforms and start gateway.""" + discover_tasks = [] + start_tasks = [] + for gateway in gateways.values(): + discover_tasks.append(_discover_persistent_devices(hass, gateway)) + start_tasks.append(_gw_start(hass, gateway)) + if discover_tasks: + # Make sure all devices and platforms are loaded before gateway start. + await asyncio.wait(discover_tasks, loop=hass.loop) + if start_tasks: + await asyncio.wait(start_tasks, loop=hass.loop) + + +async def _discover_persistent_devices(hass, gateway): + """Discover platforms for devices loaded via persistence file.""" + tasks = [] + new_devices = defaultdict(list) + for node_id in gateway.sensors: + node = gateway.sensors[node_id] + for child in node.children.values(): + validated = _validate_child(gateway, node_id, child) + for platform, dev_ids in validated.items(): + new_devices[platform].extend(dev_ids) + for platform, dev_ids in new_devices.items(): + tasks.append(_discover_mysensors_platform(hass, platform, dev_ids)) + if tasks: + await asyncio.wait(tasks, loop=hass.loop) + + +@callback +def _discover_mysensors_platform(hass, platform, new_devices): + """Discover a MySensors platform.""" + task = hass.async_create_task(discovery.async_load_platform( + hass, platform, DOMAIN, + {ATTR_DEVICES: new_devices, CONF_NAME: DOMAIN})) + return task + + +async def _gw_start(hass, gateway): + """Start the gateway.""" + # Don't use hass.async_create_task to avoid holding up setup indefinitely. + connect_task = hass.loop.create_task(gateway.start()) + + @callback + def gw_stop(event): + """Trigger to stop the gateway.""" + hass.async_add_job(gateway.stop()) + if not connect_task.done(): + connect_task.cancel() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, gw_stop) + if gateway.device == 'mqtt': + # Gatways connected via mqtt doesn't send gateway ready message. + return + gateway_ready = asyncio.Future() + gateway_ready_key = MYSENSORS_GATEWAY_READY.format(id(gateway)) + hass.data[gateway_ready_key] = gateway_ready + + try: + with async_timeout.timeout(GATEWAY_READY_TIMEOUT, loop=hass.loop): + await gateway_ready + except asyncio.TimeoutError: + _LOGGER.warning( + "Gateway %s not ready after %s secs so continuing with setup", + gateway.device, GATEWAY_READY_TIMEOUT) + finally: + hass.data.pop(gateway_ready_key, None) + + +def _gw_callback_factory(hass): + """Return a new callback for the gateway.""" + @callback + def mysensors_callback(msg): + """Handle messages from a MySensors gateway.""" + start = timer() + _LOGGER.debug( + "Node update: node %s child %s", msg.node_id, msg.child_id) + + _set_gateway_ready(hass, msg) + + try: + child = msg.gateway.sensors[msg.node_id].children[msg.child_id] + except KeyError: + _LOGGER.debug("Not a child update for node %s", msg.node_id) + return + + signals = [] + + # Update all platforms for the device via dispatcher. + # Add/update entity if schema validates to true. + validated = _validate_child(msg.gateway, msg.node_id, child) + for platform, dev_ids in validated.items(): + devices = get_mysensors_devices(hass, platform) + new_dev_ids = [] + for dev_id in dev_ids: + if dev_id in devices: + signals.append(SIGNAL_CALLBACK.format(*dev_id)) + else: + new_dev_ids.append(dev_id) + if new_dev_ids: + _discover_mysensors_platform(hass, platform, new_dev_ids) + for signal in set(signals): + # Only one signal per device is needed. + # A device can have multiple platforms, ie multiple schemas. + # FOR LATER: Add timer to not signal if another update comes in. + async_dispatcher_send(hass, signal) + end = timer() + if end - start > 0.1: + _LOGGER.debug( + "Callback for node %s child %s took %.3f seconds", + msg.node_id, msg.child_id, end - start) + return mysensors_callback + + +@callback +def _set_gateway_ready(hass, msg): + """Set asyncio future result if gateway is ready.""" + if (msg.type != msg.gateway.const.MessageType.internal or + msg.sub_type != msg.gateway.const.Internal.I_GATEWAY_READY): + return + gateway_ready = hass.data.get(MYSENSORS_GATEWAY_READY.format( + id(msg.gateway))) + if gateway_ready is None or gateway_ready.cancelled(): + return + gateway_ready.set_result(True) + + +def _validate_child(gateway, node_id, child): + """Validate that a child has the correct values according to schema. + + Return a dict of platform with a list of device ids for validated devices. + """ + validated = defaultdict(list) + + if not child.values: + _LOGGER.debug( + "No child values for node %s child %s", node_id, child.id) + return validated + if gateway.sensors[node_id].sketch_name is None: + _LOGGER.debug("Node %s is missing sketch name", node_id) + return validated + pres = gateway.const.Presentation + set_req = gateway.const.SetReq + s_name = next( + (member.name for member in pres if member.value == child.type), None) + if s_name not in MYSENSORS_CONST_SCHEMA: + _LOGGER.warning("Child type %s is not supported", s_name) + return validated + child_schemas = MYSENSORS_CONST_SCHEMA[s_name] + + def msg(name): + """Return a message for an invalid schema.""" + return "{} requires value_type {}".format( + pres(child.type).name, set_req[name].name) + + for schema in child_schemas: + platform = schema[PLATFORM] + v_name = schema[TYPE] + value_type = next( + (member.value for member in set_req if member.name == v_name), + None) + if value_type is None: + continue + _child_schema = child.get_schema(gateway.protocol_version) + vol_schema = _child_schema.extend( + {vol.Required(set_req[key].value, msg=msg(key)): + _child_schema.schema.get(set_req[key].value, val) + for key, val in schema.get(SCHEMA, {v_name: cv.string}).items()}, + extra=vol.ALLOW_EXTRA) + try: + vol_schema(child.values) + except vol.Invalid as exc: + level = (logging.WARNING if value_type in child.values + else logging.DEBUG) + _LOGGER.log( + level, + "Invalid values: %s: %s platform: node %s child %s: %s", + child.values, platform, node_id, child.id, exc) + continue + dev_id = id(gateway), node_id, child.id, value_type + validated[platform].append(dev_id) + return validated diff --git a/homeassistant/components/neato.py b/homeassistant/components/neato.py index 7402bb18843ad2..25da38e7f755c1 100644 --- a/homeassistant/components/neato.py +++ b/homeassistant/components/neato.py @@ -17,8 +17,7 @@ _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['https://github.com/jabesq/pybotvac/archive/v0.0.5.zip' - '#pybotvac==0.0.5'] +REQUIREMENTS = ['pybotvac==0.0.9'] DOMAIN = 'neato' NEATO_ROBOTS = 'neato_robots' @@ -55,7 +54,12 @@ 7: 'Updating...', 8: 'Copying logs...', 9: 'Calculating position...', - 10: 'IEC test' + 10: 'IEC test', + 11: 'Map cleaning', + 12: 'Exploring map (creating a persistent map)', + 13: 'Acquiring Persistent Map IDs', + 14: 'Creating & Uploading Map', + 15: 'Suspended Exploration' } ERRORS = { @@ -71,12 +75,30 @@ 'ui_error_navigation_pathproblems_returninghome': 'Cannot return to base', 'ui_error_navigation_falling': 'Clear my path', 'ui_error_picked_up': 'Picked up', - 'ui_error_stuck': 'Stuck!' + 'ui_error_stuck': 'Stuck!', + 'dustbin_full': 'Dust bin full', + 'dustbin_missing': 'Dust bin missing', + 'maint_brush_stuck': 'Brush stuck', + 'maint_brush_overload': 'Brush overloaded', + 'maint_bumper_stuck': 'Bumper stuck', + 'maint_vacuum_stuck': 'Vacuum is stuck', + 'maint_left_drop_stuck': 'Vacuum is stuck', + 'maint_left_wheel_stuck': 'Vacuum is stuck', + 'maint_right_drop_stuck': 'Vacuum is stuck', + 'maint_right_wheel_stuck': 'Vacuum is stuck', + 'not_on_charge_base': 'Not on the charge base', + 'nav_robot_falling': 'Clear my path', + 'nav_no_path': 'Clear my path', + 'nav_path_problem': 'Clear my path' } ALERTS = { 'ui_alert_dust_bin_full': 'Please empty dust bin', - 'ui_alert_recovering_location': 'Returning to start' + 'ui_alert_recovering_location': 'Returning to start', + 'dustbin_full': 'Please empty dust bin', + 'maint_brush_change': 'Change the brush', + 'maint_filter_change': 'Change the filter', + 'clean_completed_to_start': 'Cleaning completed' } @@ -96,7 +118,7 @@ def setup(hass, config): return True -class NeatoHub(object): +class NeatoHub: """A My Neato hub wrapper class.""" def __init__(self, hass, domain_config, neato): @@ -122,7 +144,7 @@ def login(self): _LOGGER.error("Unable to connect to Neato API") return False - @Throttle(timedelta(seconds=1)) + @Throttle(timedelta(seconds=300)) def update_robots(self): """Update the robot states.""" _LOGGER.debug("Running HUB.update_robots %s", diff --git a/homeassistant/components/nest.py b/homeassistant/components/nest.py deleted file mode 100644 index e7d2ba9043896f..00000000000000 --- a/homeassistant/components/nest.py +++ /dev/null @@ -1,211 +0,0 @@ -""" -Support for Nest devices. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/nest/ -""" -import logging -import socket - -import voluptuous as vol - -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers import discovery -from homeassistant.const import ( - CONF_STRUCTURE, CONF_FILENAME, CONF_BINARY_SENSORS, CONF_SENSORS, - CONF_MONITORED_CONDITIONS) - -REQUIREMENTS = ['python-nest==3.7.0'] - -_CONFIGURING = {} -_LOGGER = logging.getLogger(__name__) - -DOMAIN = 'nest' - -DATA_NEST = 'nest' - -NEST_CONFIG_FILE = 'nest.conf' -CONF_CLIENT_ID = 'client_id' -CONF_CLIENT_SECRET = 'client_secret' - -ATTR_HOME_MODE = 'home_mode' -ATTR_STRUCTURE = 'structure' - -SENSOR_SCHEMA = vol.Schema({ - vol.Optional(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list) -}) - -AWAY_SCHEMA = vol.Schema({ - vol.Required(ATTR_HOME_MODE): cv.string, - vol.Optional(ATTR_STRUCTURE): vol.All(cv.ensure_list, cv.string) -}) - -CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_CLIENT_ID): cv.string, - vol.Required(CONF_CLIENT_SECRET): cv.string, - vol.Optional(CONF_STRUCTURE): vol.All(cv.ensure_list, cv.string), - vol.Optional(CONF_SENSORS): SENSOR_SCHEMA, - vol.Optional(CONF_BINARY_SENSORS): SENSOR_SCHEMA - }) -}, extra=vol.ALLOW_EXTRA) - - -def request_configuration(nest, hass, config): - """Request configuration steps from the user.""" - configurator = hass.components.configurator - if 'nest' in _CONFIGURING: - _LOGGER.debug("configurator failed") - configurator.notify_errors( - _CONFIGURING['nest'], "Failed to configure, please try again.") - return - - def nest_configuration_callback(data): - """Run when the configuration callback is called.""" - _LOGGER.debug("configurator callback") - pin = data.get('pin') - setup_nest(hass, nest, config, pin=pin) - - _CONFIGURING['nest'] = configurator.request_config( - "Nest", nest_configuration_callback, - description=('To configure Nest, click Request Authorization below, ' - 'log into your Nest account, ' - 'and then enter the resulting PIN'), - link_name='Request Authorization', - link_url=nest.authorize_url, - submit_caption="Confirm", - fields=[{'id': 'pin', 'name': 'Enter the PIN', 'type': ''}] - ) - - -def setup_nest(hass, nest, config, pin=None): - """Set up the Nest devices.""" - if pin is not None: - _LOGGER.debug("pin acquired, requesting access token") - nest.request_token(pin) - - if nest.access_token is None: - _LOGGER.debug("no access_token, requesting configuration") - request_configuration(nest, hass, config) - return - - if 'nest' in _CONFIGURING: - _LOGGER.debug("configuration done") - configurator = hass.components.configurator - configurator.request_done(_CONFIGURING.pop('nest')) - - _LOGGER.debug("proceeding with setup") - conf = config[DOMAIN] - hass.data[DATA_NEST] = NestDevice(hass, conf, nest) - - _LOGGER.debug("proceeding with discovery") - discovery.load_platform(hass, 'climate', DOMAIN, {}, config) - discovery.load_platform(hass, 'camera', DOMAIN, {}, config) - - sensor_config = conf.get(CONF_SENSORS, {}) - discovery.load_platform(hass, 'sensor', DOMAIN, sensor_config, config) - - binary_sensor_config = conf.get(CONF_BINARY_SENSORS, {}) - discovery.load_platform(hass, 'binary_sensor', DOMAIN, - binary_sensor_config, config) - - _LOGGER.debug("setup done") - - return True - - -def setup(hass, config): - """Set up the Nest thermostat component.""" - import nest - - if 'nest' in _CONFIGURING: - return - - conf = config[DOMAIN] - client_id = conf[CONF_CLIENT_ID] - client_secret = conf[CONF_CLIENT_SECRET] - filename = config.get(CONF_FILENAME, NEST_CONFIG_FILE) - - access_token_cache_file = hass.config.path(filename) - - nest = nest.Nest( - access_token_cache_file=access_token_cache_file, - client_id=client_id, client_secret=client_secret) - setup_nest(hass, nest, config) - - def set_mode(service): - """Set the home/away mode for a Nest structure.""" - if ATTR_STRUCTURE in service.data: - structures = service.data[ATTR_STRUCTURE] - else: - structures = hass.data[DATA_NEST].local_structure - - for structure in nest.structures: - if structure.name in structures: - _LOGGER.info("Setting mode for %s", structure.name) - structure.away = service.data[ATTR_HOME_MODE] - else: - _LOGGER.error("Invalid structure %s", - service.data[ATTR_STRUCTURE]) - - hass.services.register( - DOMAIN, 'set_mode', set_mode, schema=AWAY_SCHEMA) - - return True - - -class NestDevice(object): - """Structure Nest functions for hass.""" - - def __init__(self, hass, conf, nest): - """Init Nest Devices.""" - self.hass = hass - self.nest = nest - - if CONF_STRUCTURE not in conf: - self.local_structure = [s.name for s in nest.structures] - else: - self.local_structure = conf[CONF_STRUCTURE] - _LOGGER.debug("Structures to include: %s", self.local_structure) - - def thermostats(self): - """Generate a list of thermostats and their location.""" - try: - for structure in self.nest.structures: - if structure.name in self.local_structure: - for device in structure.thermostats: - yield (structure, device) - else: - _LOGGER.debug("Ignoring structure %s, not in %s", - structure.name, self.local_structure) - except socket.error: - _LOGGER.error( - "Connection error logging into the nest web service.") - - def smoke_co_alarms(self): - """Generate a list of smoke co alarms.""" - try: - for structure in self.nest.structures: - if structure.name in self.local_structure: - for device in structure.smoke_co_alarms: - yield(structure, device) - else: - _LOGGER.info("Ignoring structure %s, not in %s", - structure.name, self.local_structure) - except socket.error: - _LOGGER.error( - "Connection error logging into the nest web service.") - - def cameras(self): - """Generate a list of cameras.""" - try: - for structure in self.nest.structures: - if structure.name in self.local_structure: - for device in structure.cameras: - yield(structure, device) - else: - _LOGGER.info("Ignoring structure %s, not in %s", - structure.name, self.local_structure) - except socket.error: - _LOGGER.error( - "Connection error logging into the nest web service.") diff --git a/homeassistant/components/nest/.translations/ca.json b/homeassistant/components/nest/.translations/ca.json new file mode 100644 index 00000000000000..2fb17916aee81b --- /dev/null +++ b/homeassistant/components/nest/.translations/ca.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "Nom\u00e9s podeu configurar un \u00fanic compte Nest.", + "authorize_url_fail": "S'ha produ\u00eft un error desconegut al generar l'URL d'autoritzaci\u00f3.", + "authorize_url_timeout": "Temps d'espera generant l'URL d'autoritzaci\u00f3 esgotat.", + "no_flows": "Necessiteu configurar Nest abans de poder autenticar-vos-hi. [Llegiu les instruccions](https://www.home-assistant.io/components/nest/)." + }, + "error": { + "internal_error": "Error intern al validar el codi", + "invalid_code": "Codi inv\u00e0lid", + "timeout": "Temps d'espera de validaci\u00f3 del codi esgotat", + "unknown": "Error desconegut al validar el codi" + }, + "step": { + "init": { + "data": { + "flow_impl": "Prove\u00efdor" + }, + "description": "Trieu a trav\u00e9s de quin prove\u00efdor d'autenticaci\u00f3 us voleu autenticar amb Nest.", + "title": "Prove\u00efdor d'autenticaci\u00f3" + }, + "link": { + "data": { + "code": "Codi pin" + }, + "description": "Per enlla\u00e7ar el vostre compte de Nest, [autoritzeu el vostre compte] ({url}). \n\nDespr\u00e9s de l'autoritzaci\u00f3, copieu i enganxeu el codi pin que es mostra a sota.", + "title": "Enlla\u00e7ar compte de Nest" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/cs.json b/homeassistant/components/nest/.translations/cs.json new file mode 100644 index 00000000000000..c884226174b00b --- /dev/null +++ b/homeassistant/components/nest/.translations/cs.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "M\u016f\u017eete nastavit pouze jeden Nest \u00fa\u010det.", + "authorize_url_fail": "Nezn\u00e1m\u00e1 chyba p\u0159i generov\u00e1n\u00ed autoriza\u010dn\u00ed URL.", + "authorize_url_timeout": "\u010casov\u00fd limit autoriza\u010dn\u00ed URL vypr\u0161el", + "no_flows": "Pot\u0159ebujete nakonfigurovat Nest, abyste se s n\u00edm mohli autentizovat. [P\u0159e\u010dt\u011bte si pros\u00edm pokyny] (https://www.home-assistant.io/components/nest/)." + }, + "error": { + "internal_error": "Intern\u00ed chyba ov\u011b\u0159en\u00ed k\u00f3du", + "invalid_code": "Neplatn\u00fd k\u00f3d", + "timeout": "\u010casov\u00fd limit ov\u011b\u0159ov\u00e1n\u00ed k\u00f3du vypr\u0161el", + "unknown": "Nezn\u00e1m\u00e1 chyba ov\u011b\u0159en\u00ed k\u00f3du" + }, + "step": { + "init": { + "data": { + "flow_impl": "Poskytovatel" + }, + "description": "Zvolte pomoc\u00ed kter\u00e9ho poskytovatele ov\u011b\u0159en\u00ed chcete ov\u011b\u0159it slu\u017ebu Nest.", + "title": "Poskytovatel ov\u011b\u0159en\u00ed" + }, + "link": { + "data": { + "code": "K\u00f3d PIN" + }, + "description": "Chcete-li propojit \u00fa\u010det Nest, [autorizujte sv\u016fj \u00fa\u010det]({url}). \n\n Po autorizaci zkop\u00edrujte n\u00ed\u017ee uveden\u00fd k\u00f3d PIN.", + "title": "Propojit s Nest \u00fa\u010dtem" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/de.json b/homeassistant/components/nest/.translations/de.json new file mode 100644 index 00000000000000..86b50ab3c10323 --- /dev/null +++ b/homeassistant/components/nest/.translations/de.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "Sie k\u00f6nnen nur ein einziges Nest-Konto konfigurieren.", + "authorize_url_fail": "Unbekannter Fehler beim Erstellen der Authorisierungs-URL", + "authorize_url_timeout": "Zeit\u00fcberschreitung beim Erstellen der Authorisierungs-URL", + "no_flows": "Sie m\u00fcssen Nest konfigurieren, bevor Sie sich authentifizieren k\u00f6nnen. [Bitte lesen Sie die Anweisungen] (https://www.home-assistant.io/components/nest/)." + }, + "error": { + "internal_error": "Ein interner Fehler ist aufgetreten", + "invalid_code": "Ung\u00fcltiger Code", + "timeout": "Ein zeit\u00fcberschreitungs Fehler ist aufgetreten", + "unknown": "Ein unbekannter Fehler ist aufgetreten" + }, + "step": { + "init": { + "data": { + "flow_impl": "Anbieter" + }, + "description": "W\u00e4hlen Sie, \u00fcber welchen Authentifizierungsanbieter Sie sich bei Nest authentifizieren m\u00f6chten.", + "title": "Authentifizierungsanbieter" + }, + "link": { + "data": { + "code": "PIN Code" + }, + "description": "[Autorisieren Sie ihr Konto] ( {url} ), um ihren Nest-Account zu verkn\u00fcpfen.\n\n F\u00fcgen Sie anschlie\u00dfend den erhaltenen PIN Code hier ein.", + "title": "Nest-Konto verkn\u00fcpfen" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/en.json b/homeassistant/components/nest/.translations/en.json new file mode 100644 index 00000000000000..cf448bb35e7273 --- /dev/null +++ b/homeassistant/components/nest/.translations/en.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "You can only configure a single Nest account.", + "authorize_url_fail": "Unknown error generating an authorize url.", + "authorize_url_timeout": "Timeout generating authorize url.", + "no_flows": "You need to configure Nest before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/nest/)." + }, + "error": { + "internal_error": "Internal error validating code", + "invalid_code": "Invalid code", + "timeout": "Timeout validating code", + "unknown": "Unknown error validating code" + }, + "step": { + "init": { + "data": { + "flow_impl": "Provider" + }, + "description": "Pick via which authentication provider you want to authenticate with Nest.", + "title": "Authentication Provider" + }, + "link": { + "data": { + "code": "Pin code" + }, + "description": "To link your Nest account, [authorize your account]({url}).\n\nAfter authorization, copy-paste the provided pin code below.", + "title": "Link Nest Account" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/es-419.json b/homeassistant/components/nest/.translations/es-419.json new file mode 100644 index 00000000000000..0dfb5283d8f31f --- /dev/null +++ b/homeassistant/components/nest/.translations/es-419.json @@ -0,0 +1,27 @@ +{ + "config": { + "abort": { + "no_flows": "Debe configurar Nest antes de poder autenticarse con \u00e9l. [Lea las instrucciones] (https://www.home-assistant.io/components/nest/)." + }, + "error": { + "invalid_code": "Codigo invalido" + }, + "step": { + "init": { + "data": { + "flow_impl": "Proveedor" + }, + "description": "Seleccione a trav\u00e9s de qu\u00e9 proveedor de autenticaci\u00f3n desea autenticarse con Nest.", + "title": "Proveedor de autenticaci\u00f3n" + }, + "link": { + "data": { + "code": "C\u00f3digo PIN" + }, + "description": "Para vincular su cuenta Nest, [autorice su cuenta] ( {url} ). \n\n Despu\u00e9s de la autorizaci\u00f3n, copie y pegue el c\u00f3digo pin proporcionado a continuaci\u00f3n.", + "title": "Enlazar cuenta Nest" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/hu.json b/homeassistant/components/nest/.translations/hu.json new file mode 100644 index 00000000000000..abf8f79599f505 --- /dev/null +++ b/homeassistant/components/nest/.translations/hu.json @@ -0,0 +1,20 @@ +{ + "config": { + "error": { + "invalid_code": "\u00c9rv\u00e9nytelen k\u00f3d" + }, + "step": { + "init": { + "data": { + "flow_impl": "Szolg\u00e1ltat\u00f3" + } + }, + "link": { + "data": { + "code": "PIN-k\u00f3d" + } + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/it.json b/homeassistant/components/nest/.translations/it.json new file mode 100644 index 00000000000000..e4a19ebd521224 --- /dev/null +++ b/homeassistant/components/nest/.translations/it.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "\u00c8 possibile configurare un solo account Nest.", + "authorize_url_fail": "Errore sconoscioto nel generare l'url di autorizzazione", + "authorize_url_timeout": "Tempo scaduto nel generare l'url di autorizzazione", + "no_flows": "Devi configurare Nest prima di poter eseguire l'autenticazione. [Si prega di leggere le istruzioni] (https://www.home-assistant.io/components/nest/)." + }, + "error": { + "internal_error": "Errore interno nella convalida del codice", + "invalid_code": "Codice non valido", + "timeout": "Tempo scaduto per l'inserimento del codice di convalida", + "unknown": "Errore sconosciuto durante la convalida del codice" + }, + "step": { + "init": { + "data": { + "flow_impl": "Provider" + }, + "description": "Scegli tramite quale provider di autenticazione desideri autenticarti con Nest.", + "title": "Fornitore di autenticazione" + }, + "link": { + "data": { + "code": "Codice PIN" + }, + "description": "Per collegare l'account Nido, [autorizzare l'account]({url}).\n\nDopo l'autorizzazione, copia-incolla il codice PIN fornito di seguito.", + "title": "Collega un account Nest" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/ja.json b/homeassistant/components/nest/.translations/ja.json new file mode 100644 index 00000000000000..4335b7d16747d0 --- /dev/null +++ b/homeassistant/components/nest/.translations/ja.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/ko.json b/homeassistant/components/nest/.translations/ko.json new file mode 100644 index 00000000000000..0caa70aeff2853 --- /dev/null +++ b/homeassistant/components/nest/.translations/ko.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "\ud558\ub098\uc758 Nest \uacc4\uc815\ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4.", + "authorize_url_fail": "\uc778\uc99d url \uc0dd\uc131\uc5d0 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958\uac00 \ubc1c\uc0dd\ud588\uc2b5\ub2c8\ub2e4.", + "authorize_url_timeout": "\uc778\uc99d url \uc0dd\uc131 \uc2dc\uac04\uc774 \ucd08\uacfc\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "no_flows": "Nest \ub97c \uc778\uc99d\ud558\uae30 \uc804\uc5d0 Nest \ub97c \uad6c\uc131\ud574\uc57c \ud569\ub2c8\ub2e4. [\uc548\ub0b4](https://www.home-assistant.io/components/nest/)\ub97c \uc77d\uc5b4\ubcf4\uc138\uc694." + }, + "error": { + "internal_error": "\ucf54\ub4dc \uc720\ud6a8\uc131 \uac80\uc0ac\uc5d0 \ub0b4\ubd80 \uc624\ub958 \ubc1c\uc0dd", + "invalid_code": "\uc798\ubabb\ub41c \ucf54\ub4dc", + "timeout": "\ucf54\ub4dc \uc720\ud6a8\uc131 \uac80\uc0ac \uc2dc\uac04 \ucd08\uacfc", + "unknown": "\ucf54\ub4dc \uc720\ud6a8\uc131 \uac80\uc0ac\uc5d0 \uc54c \uc218 \uc5c6\ub294 \uc624\ub958 \ubc1c\uc0dd" + }, + "step": { + "init": { + "data": { + "flow_impl": "\uacf5\uae09\uc790" + }, + "description": "Nest\ub85c \uc778\uc99d\ud558\ub824\ub294 \uc778\uc99d \uacf5\uae09\uc790\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694.", + "title": "\uc778\uc99d \uacf5\uae09\uc790" + }, + "link": { + "data": { + "code": "\ud540 \ucf54\ub4dc" + }, + "description": "Nest \uacc4\uc815\uc744 \uc5f0\uacb0\ud558\ub824\uba74, [\uacc4\uc815 \uc5f0\uacb0 \uc2b9\uc778]({url})\uc744 \ud574\uc8fc\uc138\uc694.\n\n\uc2b9\uc778 \ud6c4, \uc544\ub798\uc758 \ud540 \ucf54\ub4dc\ub97c \ubcf5\uc0ac\ud558\uc5ec \ubd99\uc5ec\ub123\uc73c\uc138\uc694.", + "title": "Nest \uacc4\uc815 \uc5f0\uacb0" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/lb.json b/homeassistant/components/nest/.translations/lb.json new file mode 100644 index 00000000000000..197cc8206d0510 --- /dev/null +++ b/homeassistant/components/nest/.translations/lb.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "Dir k\u00ebnnt n\u00ebmmen een eenzegen\u00a0Nest Kont\u00a0konfigur\u00e9ieren.", + "authorize_url_fail": "Onbekannte Feeler beim gener\u00e9ieren vun der Autorisatiouns URL.", + "authorize_url_timeout": "Z\u00e4it Iwwerschreidung\u00a0beim gener\u00e9ieren\u00a0vun der Autorisatiouns\u00a0URL.", + "no_flows": "Dir musst Nest konfigur\u00e9ieren, ier Dir d\u00ebs Authentifiz\u00e9ierung\u00a0k\u00ebnnt benotzen.[Liest w.e.g. d'Instruktioune](https://www.home-assistant.io/components/nest/)." + }, + "error": { + "internal_error": "Interne Feeler beim valid\u00e9ieren vum Code", + "invalid_code": "Ong\u00ebltege Code", + "timeout": "Z\u00e4it Iwwerschreidung\u00a0beim valid\u00e9ieren vum Code", + "unknown": "Onbekannte Feeler beim valid\u00e9ieren vum Code" + }, + "step": { + "init": { + "data": { + "flow_impl": "Ubidder" + }, + "description": "Wielt den Authentifikatioun Ubidder deen sech mat Nest verbanne soll.", + "title": "Authentifikatioun Ubidder" + }, + "link": { + "data": { + "code": "Pin code" + }, + "description": "Fir den Nest Kont ze verbannen, [autoris\u00e9iert \u00e4ren Kont]({url}).\nKop\u00e9iert no der Autorisatioun den Pin hei \u00ebnnendr\u00ebnner", + "title": "Nest Kont verbannen" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/nl.json b/homeassistant/components/nest/.translations/nl.json new file mode 100644 index 00000000000000..756eb07189a2be --- /dev/null +++ b/homeassistant/components/nest/.translations/nl.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "Je kunt slechts \u00e9\u00e9n Nest-account configureren.", + "authorize_url_fail": "Onbekende fout bij het genereren van een autoriseer-URL.", + "authorize_url_timeout": "Toestemming voor het genereren van autoriseer-url.", + "no_flows": "U moet Nest configureren voordat u zich ermee kunt authenticeren. [Gelieve de instructies te lezen](https://www.home-assistant.io/components/nest/)." + }, + "error": { + "internal_error": "Interne foutvalidatiecode", + "invalid_code": "Ongeldige code", + "timeout": "Time-out validatie van code", + "unknown": "Onbekende foutvalidatiecode" + }, + "step": { + "init": { + "data": { + "flow_impl": "Leverancier" + }, + "description": "Kies met welke authenticatieleverancier u wilt verifi\u00ebren met Nest.", + "title": "Authenticatieleverancier" + }, + "link": { + "data": { + "code": "Pincode" + }, + "description": "Als je je Nest-account wilt koppelen, [autoriseer je account] ( {url} ). \n\nNa autorisatie, kopieer en plak de voorziene pincode hieronder.", + "title": "Koppel Nest-account" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/no.json b/homeassistant/components/nest/.translations/no.json new file mode 100644 index 00000000000000..03cf1a82b813bf --- /dev/null +++ b/homeassistant/components/nest/.translations/no.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "Du kan bare konfigurere en enkelt Nest konto.", + "authorize_url_fail": "Ukjent feil ved generering av autoriseringsadresse.", + "authorize_url_timeout": "Tidsavbrudd ved generering av autoriseringsadresse.", + "no_flows": "Du m\u00e5 konfigurere Nest f\u00f8r du kan autentisere med den. [Vennligst les instruksjonene](https://www.home-assistant.io/components/nest/)." + }, + "error": { + "internal_error": "Intern feil ved validering av kode", + "invalid_code": "Ugyldig kode", + "timeout": "Tidsavbrudd ved validering av kode", + "unknown": "Ukjent feil ved validering av kode" + }, + "step": { + "init": { + "data": { + "flow_impl": "Tilbyder" + }, + "description": "Velg via hvilken autentiseringstilbyder du vil godkjenne med Nest.", + "title": "Autentiseringstilbyder" + }, + "link": { + "data": { + "code": "PIN kode" + }, + "description": "For \u00e5 koble din Nest-konto, [autoriser kontoen din]({url}). \n\n Etter godkjenning, kopier og lim inn den oppgitte PIN koden nedenfor.", + "title": "Koble til Nest konto" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/pl.json b/homeassistant/components/nest/.translations/pl.json new file mode 100644 index 00000000000000..c03b2eff0fabd0 --- /dev/null +++ b/homeassistant/components/nest/.translations/pl.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "Mo\u017cesz skonfigurowa\u0107 tylko jedno konto Nest.", + "authorize_url_fail": "Nieznany b\u0142\u0105d podczas generowania url autoryzacji.", + "authorize_url_timeout": "Min\u0105\u0142 limit czasu generowania url autoryzacji.", + "no_flows": "Musisz skonfigurowa\u0107 Nest, zanim b\u0119dziesz m\u00f3g\u0142 wykona\u0107 uwierzytelnienie. [Przeczytaj instrukcje](https://www.home-assistant.io/components/nest/)." + }, + "error": { + "internal_error": "Wewn\u0119trzny b\u0142\u0105d sprawdzania poprawno\u015bci kodu", + "invalid_code": "Nieprawid\u0142owy kod", + "timeout": "Min\u0105\u0142 limit czasu sprawdzania poprawno\u015bci kodu", + "unknown": "Nieznany b\u0142\u0105d sprawdzania poprawno\u015bci kodu" + }, + "step": { + "init": { + "data": { + "flow_impl": "Dostawca" + }, + "description": "Wybierz, kt\u00f3rego dostawc\u0119 uwierzytelnienia chcesz u\u017cywa\u0107 z Nest.", + "title": "Dostawca uwierzytelnienia" + }, + "link": { + "data": { + "code": "Kod PIN" + }, + "description": "Aby po\u0142\u0105czy\u0107 z kontem Nest, [wykonaj autoryzacj\u0119]({url}). \n\n Po autoryzacji skopiuj i wklej podany kod PIN poni\u017cej.", + "title": "Po\u0142\u0105cz z kontem Nest" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/pt-BR.json b/homeassistant/components/nest/.translations/pt-BR.json new file mode 100644 index 00000000000000..22b4f56fc97f08 --- /dev/null +++ b/homeassistant/components/nest/.translations/pt-BR.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "Voc\u00ea pode configurar somente uma conta do Nest", + "authorize_url_fail": "Erro desconhecido ao gerar um URL de autoriza\u00e7\u00e3o.", + "authorize_url_timeout": "Excedido tempo limite de url de autoriza\u00e7\u00e3o", + "no_flows": "Voc\u00ea precisa configurar o Nest antes de poder autenticar com ele. [Por favor leio as instru\u00e7\u00f5es](https://www.home-assistant.io/components/nest/)." + }, + "error": { + "internal_error": "Erro interno ao validar o c\u00f3digo", + "invalid_code": "C\u00f3digo inv\u00e1lido", + "timeout": "Excedido tempo limite para validar c\u00f3digo", + "unknown": "Erro desconhecido ao validar o c\u00f3digo" + }, + "step": { + "init": { + "data": { + "flow_impl": "Provedor" + }, + "description": "Escolha atrav\u00e9s de qual provedor de autentica\u00e7\u00e3o voc\u00ea deseja autenticar com o Nest.", + "title": "Provedor de Autentica\u00e7\u00e3o" + }, + "link": { + "data": { + "code": "C\u00f3digo PIN" + }, + "description": "Para vincular sua conta do Nest, [autorize sua conta] ( {url} ). \n\n Ap\u00f3s a autoriza\u00e7\u00e3o, copie e cole o c\u00f3digo PIN fornecido abaixo.", + "title": "Link da conta Nest" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/pt.json b/homeassistant/components/nest/.translations/pt.json new file mode 100644 index 00000000000000..40743fe3ddbb6f --- /dev/null +++ b/homeassistant/components/nest/.translations/pt.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "S\u00f3 pode configurar uma \u00fanica conta Nest.", + "authorize_url_fail": "Erro desconhecido ao gerar um URL de autoriza\u00e7\u00e3o.", + "authorize_url_timeout": "Limite temporal ultrapassado ao gerar um URL de autoriza\u00e7\u00e3o.", + "no_flows": "\u00c9 necess\u00e1rio configurar o Nest antes de poder autenticar com ele. [Por favor, leia as instru\u00e7\u00f5es] (https://www.home-assistant.io/components/nest/)." + }, + "error": { + "internal_error": "Erro interno ao validar o c\u00f3digo", + "invalid_code": "C\u00f3digo inv\u00e1lido", + "timeout": "Limite temporal ultrapassado ao validar c\u00f3digo", + "unknown": "Erro desconhecido ao validar o c\u00f3digo" + }, + "step": { + "init": { + "data": { + "flow_impl": "Fornecedor" + }, + "description": "Escolha com qual fornecedor de autentica\u00e7\u00e3o deseja autenticar o Nest.", + "title": "Fornecedor de Autentica\u00e7\u00e3o" + }, + "link": { + "data": { + "code": "C\u00f3digo PIN" + }, + "description": "Para associar \u00e0 sua conta Nest, [autorizar sua conta]({url}).\n\nAp\u00f3s a autoriza\u00e7\u00e3o, copie e cole o c\u00f3digo pin fornecido abaixo.", + "title": "Associar conta Nest" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/ru.json b/homeassistant/components/nest/.translations/ru.json new file mode 100644 index 00000000000000..0f7b9b8dd719c2 --- /dev/null +++ b/homeassistant/components/nest/.translations/ru.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "\u0412\u044b \u043c\u043e\u0436\u0435\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0443 \u0443\u0447\u0435\u0442\u043d\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c Nest.", + "authorize_url_fail": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u0438 \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.", + "authorize_url_timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u0433\u0435\u043d\u0435\u0440\u0430\u0446\u0438\u0438 \u0441\u0441\u044b\u043b\u043a\u0438 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438.", + "no_flows": "\u0412\u0430\u043c \u043d\u0443\u0436\u043d\u043e \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Nest \u043f\u0435\u0440\u0435\u0434 \u0442\u0435\u043c, \u043a\u0430\u043a \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u044e. [\u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043e\u0437\u043d\u0430\u043a\u043e\u043c\u044c\u0442\u0435\u0441\u044c \u0441 \u0438\u043d\u0441\u0442\u0440\u0443\u043a\u0446\u0438\u044f\u043c\u0438](https://www.home-assistant.io/components/nest/)." + }, + "error": { + "internal_error": "\u0412\u043d\u0443\u0442\u0440\u0435\u043d\u043d\u044f\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u043a\u043e\u0434\u0430", + "invalid_code": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043e\u0434", + "timeout": "\u0418\u0441\u0442\u0435\u043a\u043b\u043e \u0432\u0440\u0435\u043c\u044f \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u043a\u043e\u0434\u0430.", + "unknown": "\u041d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u0430\u044f \u043e\u0448\u0438\u0431\u043a\u0430 \u043f\u0440\u043e\u0432\u0435\u0440\u043a\u0438 \u043a\u043e\u0434\u0430" + }, + "step": { + "init": { + "data": { + "flow_impl": "\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435, \u0447\u0435\u0440\u0435\u0437 \u043a\u0430\u043a\u043e\u0433\u043e \u043f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440\u0430 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438 \u0432\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0432\u0445\u043e\u0434 \u0432 Nest.", + "title": "\u041f\u0440\u043e\u0432\u0430\u0439\u0434\u0435\u0440 \u0430\u0443\u0442\u0435\u043d\u0442\u0438\u0444\u0438\u043a\u0430\u0446\u0438\u0438" + }, + "link": { + "data": { + "code": "\u041f\u0438\u043d-\u043a\u043e\u0434" + }, + "description": " [\u0410\u0432\u0442\u043e\u0440\u0438\u0437\u0443\u0439\u0442\u0435\u0441\u044c]({url}), \u0447\u0442\u043e\u0431\u044b \u043f\u0440\u0438\u0432\u044f\u0437\u0430\u0442\u044c \u0441\u0432\u043e\u044e \u0443\u0447\u0435\u0442\u043d\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c Nest. \n\n \u041f\u043e\u0441\u043b\u0435 \u0430\u0432\u0442\u043e\u0440\u0438\u0437\u0430\u0446\u0438\u0438 \u0441\u043a\u043e\u043f\u0438\u0440\u0443\u0439\u0442\u0435 \u043f\u0440\u0438\u043b\u0430\u0433\u0430\u0435\u043c\u044b\u0439 \u043f\u0438\u043d-\u043a\u043e\u0434.", + "title": "\u041f\u0440\u0438\u0432\u044f\u0437\u0430\u0442\u044c \u0443\u0447\u0435\u0442\u043d\u0443\u044e \u0437\u0430\u043f\u0438\u0441\u044c Nest" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/sl.json b/homeassistant/components/nest/.translations/sl.json new file mode 100644 index 00000000000000..d038ed4157fab0 --- /dev/null +++ b/homeassistant/components/nest/.translations/sl.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "Nastavite lahko samo en ra\u010dun Nest.", + "authorize_url_fail": "Neznana napaka pri generiranju potrditvenega URL-ja.", + "authorize_url_timeout": "\u010casovna omejitev za generiranje potrditvenega URL-ja je potekla.", + "no_flows": "Preden lahko preverite pristnost, morate konfigurirati Nest. [Preberite navodila](https://www.home-assistant.io/components/nest/)." + }, + "error": { + "internal_error": "Notranja napaka pri preverjanju kode", + "invalid_code": "Neveljavna koda", + "timeout": "\u010casovna omejitev je potekla pri preverjanju kode", + "unknown": "Neznana napaka pri preverjanju kode" + }, + "step": { + "init": { + "data": { + "flow_impl": "Ponudnik" + }, + "description": "Izberite prek katerega ponudnika overjanja \u017eelite overiti Nest.", + "title": "Ponudnik za preverjanje pristnosti" + }, + "link": { + "data": { + "code": "PIN koda" + }, + "description": "\u010ce \u017eelite povezati svoj ra\u010dun Nest, [pooblastite svoj ra\u010dun]({url}). \n\n Po odobritvi kopirajte in prilepite podano kodo PIN.", + "title": "Pove\u017eite Nest ra\u010dun" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/sv.json b/homeassistant/components/nest/.translations/sv.json new file mode 100644 index 00000000000000..721f891219daa5 --- /dev/null +++ b/homeassistant/components/nest/.translations/sv.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "Du kan endast konfigurera ett Nest-konto.", + "authorize_url_fail": "Ok\u00e4nt fel vid generering av autentisieringsadress.", + "authorize_url_timeout": "Timeout vid generering av en autentisieringsadress.", + "no_flows": "Du m\u00e5ste konfigurera Nest innan du kan autentisera med det. [V\u00e4nligen l\u00e4s instruktionerna] (https://www.home-assistant.io/components/nest/)." + }, + "error": { + "internal_error": "Internt fel vid validering av kod", + "invalid_code": "Ogiltig kod", + "timeout": "Timeout vid valididering av kod", + "unknown": "Ok\u00e4nt fel vid validering av kod" + }, + "step": { + "init": { + "data": { + "flow_impl": "Leverant\u00f6r" + }, + "description": "V\u00e4lj den autentiseringsleverant\u00f6r som du vill autentisera med mot Nest.", + "title": "Autentiseringsleverant\u00f6r" + }, + "link": { + "data": { + "code": "Pin-kod" + }, + "description": "F\u00f6r att l\u00e4nka ditt Nest-konto, [autentisiera ditt konto]({url}). \n\nEfter autentisiering, klipp och klistra in den angivna pin-koden nedan.", + "title": "L\u00e4nka Nest-konto" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/vi.json b/homeassistant/components/nest/.translations/vi.json new file mode 100644 index 00000000000000..996c6c68eae9e3 --- /dev/null +++ b/homeassistant/components/nest/.translations/vi.json @@ -0,0 +1,22 @@ +{ + "config": { + "error": { + "internal_error": "M\u00e3 x\u00e1c th\u1ef1c l\u1ed7i n\u1ed9i b\u1ed9", + "invalid_code": "M\u00e3 kh\u00f4ng h\u1ee3p l\u1ec7", + "timeout": "M\u00e3 x\u00e1c th\u1ef1c h\u1ebft th\u1eddi gian ch\u1edd", + "unknown": "M\u00e3 x\u00e1c th\u1ef1c l\u1ed7i kh\u00f4ng x\u00e1c \u0111\u1ecbnh" + }, + "step": { + "init": { + "data": { + "flow_impl": "Nh\u00e0 cung c\u1ea5p" + }, + "title": "Nh\u00e0 cung c\u1ea5p x\u00e1c th\u1ef1c" + }, + "link": { + "title": "Li\u00ean k\u1ebft t\u00e0i kho\u1ea3n Nest" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/zh-Hans.json b/homeassistant/components/nest/.translations/zh-Hans.json new file mode 100644 index 00000000000000..05ba5bdf15525a --- /dev/null +++ b/homeassistant/components/nest/.translations/zh-Hans.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "\u60a8\u53ea\u80fd\u914d\u7f6e\u4e00\u4e2a Nest \u5e10\u6237\u3002", + "authorize_url_fail": "\u751f\u6210\u6388\u6743\u7f51\u5740\u65f6\u53d1\u751f\u672a\u77e5\u9519\u8bef\u3002", + "authorize_url_timeout": "\u751f\u6210\u6388\u6743\u7f51\u5740\u8d85\u65f6\u3002", + "no_flows": "\u60a8\u9700\u8981\u5148\u914d\u7f6e Nest\uff0c\u7136\u540e\u624d\u80fd\u5bf9\u5176\u8fdb\u884c\u6388\u6743\u3002 [\u8bf7\u9605\u8bfb\u8bf4\u660e](https://www.home-assistant.io/components/nest/)\u3002" + }, + "error": { + "internal_error": "\u9a8c\u8bc1\u4ee3\u7801\u65f6\u53d1\u751f\u5185\u90e8\u9519\u8bef", + "invalid_code": "\u65e0\u6548\u4ee3\u7801", + "timeout": "\u4ee3\u7801\u9a8c\u8bc1\u8d85\u65f6", + "unknown": "\u9a8c\u8bc1\u4ee3\u7801\u65f6\u53d1\u751f\u672a\u77e5\u9519\u8bef" + }, + "step": { + "init": { + "data": { + "flow_impl": "\u63d0\u4f9b\u8005" + }, + "description": "\u9009\u62e9\u60a8\u60f3\u901a\u8fc7\u54ea\u4e2a\u6388\u6743\u63d0\u4f9b\u8005\u4e0e Nest \u8fdb\u884c\u6388\u6743\u3002", + "title": "\u6388\u6743\u63d0\u4f9b\u8005" + }, + "link": { + "data": { + "code": "PIN \u7801" + }, + "description": "\u8981\u5173\u8054 Nest \u5e10\u6237\uff0c\u8bf7[\u6388\u6743\u5e10\u6237]({url})\u3002\n\n\u5b8c\u6210\u6388\u6743\u540e\uff0c\u5728\u4e0b\u9762\u7c98\u8d34\u83b7\u5f97\u7684 PIN \u7801\u3002", + "title": "\u5173\u8054 Nest \u5e10\u6237" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/.translations/zh-Hant.json b/homeassistant/components/nest/.translations/zh-Hant.json new file mode 100644 index 00000000000000..6b9dbdb19b1148 --- /dev/null +++ b/homeassistant/components/nest/.translations/zh-Hant.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_setup": "\u50c5\u80fd\u8a2d\u5b9a\u4e00\u7d44 Nest \u5e33\u865f\u3002", + "authorize_url_fail": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u767c\u751f\u672a\u77e5\u932f\u8aa4", + "authorize_url_timeout": "\u7522\u751f\u8a8d\u8b49 URL \u6642\u903e\u6642", + "no_flows": "\u5fc5\u9808\u5148\u8a2d\u5b9a Nest \u65b9\u80fd\u9032\u884c\u8a8d\u8b49\u3002[\u8acb\u53c3\u95b1\u6559\u5b78\u6307\u5f15]\uff08https://www.home-assistant.io/components/nest/\uff09\u3002" + }, + "error": { + "internal_error": "\u8a8d\u8b49\u78bc\u5167\u90e8\u932f\u8aa4", + "invalid_code": "\u8a8d\u8b49\u78bc\u7121\u6548", + "timeout": "\u8a8d\u8b49\u78bc\u903e\u6642", + "unknown": "\u8a8d\u8b49\u78bc\u672a\u77e5\u932f\u8aa4" + }, + "step": { + "init": { + "data": { + "flow_impl": "\u8a8d\u8b49\u63d0\u4f9b\u8005" + }, + "description": "\u65bc\u8a8d\u8b49\u63d0\u4f9b\u8005\u4e2d\u6311\u9078\u6240\u8981\u9032\u884c Nest \u8a8d\u8b49\u63d0\u4f9b\u8005\u3002", + "title": "\u8a8d\u8b49\u63d0\u4f9b\u8005" + }, + "link": { + "data": { + "code": "PIN \u78bc" + }, + "description": "\u6b32\u9023\u7d50 Nest \u5e33\u865f\uff0c[\u8a8d\u8b49\u5e33\u865f]({url}).\n\n\u65bc\u8a8d\u8b49\u5f8c\uff0c\u8907\u88fd\u4e26\u8cbc\u4e0a\u4e0b\u65b9\u7684\u8a8d\u8b49\u78bc\u3002", + "title": "\u9023\u7d50 Nest \u5e33\u865f" + } + }, + "title": "Nest" + } +} \ No newline at end of file diff --git a/homeassistant/components/nest/__init__.py b/homeassistant/components/nest/__init__.py new file mode 100644 index 00000000000000..d25b94bbc17682 --- /dev/null +++ b/homeassistant/components/nest/__init__.py @@ -0,0 +1,323 @@ +""" +Support for Nest devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/nest/ +""" +import logging +import socket +from datetime import datetime, timedelta +import threading + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import ( + CONF_STRUCTURE, CONF_FILENAME, CONF_BINARY_SENSORS, CONF_SENSORS, + CONF_MONITORED_CONDITIONS, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) +from homeassistant.core import callback +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.dispatcher import dispatcher_send, \ + async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import DOMAIN +from . import local_auth + +REQUIREMENTS = ['python-nest==4.0.3'] + +_CONFIGURING = {} +_LOGGER = logging.getLogger(__name__) + + +DATA_NEST = 'nest' +DATA_NEST_CONFIG = 'nest_config' + +SIGNAL_NEST_UPDATE = 'nest_update' + +NEST_CONFIG_FILE = 'nest.conf' +CONF_CLIENT_ID = 'client_id' +CONF_CLIENT_SECRET = 'client_secret' + +ATTR_HOME_MODE = 'home_mode' +ATTR_STRUCTURE = 'structure' +ATTR_TRIP_ID = 'trip_id' +ATTR_ETA = 'eta' +ATTR_ETA_WINDOW = 'eta_window' + +HOME_MODE_AWAY = 'away' +HOME_MODE_HOME = 'home' + +SENSOR_SCHEMA = vol.Schema({ + vol.Optional(CONF_MONITORED_CONDITIONS): vol.All(cv.ensure_list) +}) + +AWAY_SCHEMA = vol.Schema({ + vol.Required(ATTR_HOME_MODE): vol.In([HOME_MODE_AWAY, HOME_MODE_HOME]), + vol.Optional(ATTR_STRUCTURE): vol.All(cv.ensure_list, cv.string), + vol.Optional(ATTR_TRIP_ID): cv.string, + vol.Optional(ATTR_ETA): cv.time_period, + vol.Optional(ATTR_ETA_WINDOW): cv.time_period +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_CLIENT_ID): cv.string, + vol.Required(CONF_CLIENT_SECRET): cv.string, + vol.Optional(CONF_STRUCTURE): vol.All(cv.ensure_list, cv.string), + vol.Optional(CONF_SENSORS): SENSOR_SCHEMA, + vol.Optional(CONF_BINARY_SENSORS): SENSOR_SCHEMA + }) +}, extra=vol.ALLOW_EXTRA) + + +def nest_update_event_broker(hass, nest): + """ + Dispatch SIGNAL_NEST_UPDATE to devices when nest stream API received data. + + Runs in its own thread. + """ + _LOGGER.debug("listening nest.update_event") + + while hass.is_running: + nest.update_event.wait() + + if not hass.is_running: + break + + nest.update_event.clear() + _LOGGER.debug("dispatching nest data update") + dispatcher_send(hass, SIGNAL_NEST_UPDATE) + + _LOGGER.debug("stop listening nest.update_event") + + +async def async_setup(hass, config): + """Set up Nest components.""" + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + local_auth.initialize(hass, conf[CONF_CLIENT_ID], conf[CONF_CLIENT_SECRET]) + + filename = config.get(CONF_FILENAME, NEST_CONFIG_FILE) + access_token_cache_file = hass.config.path(filename) + + hass.async_add_job(hass.config_entries.flow.async_init( + DOMAIN, context={'source': config_entries.SOURCE_IMPORT}, + data={ + 'nest_conf_path': access_token_cache_file, + } + )) + + # Store config to be used during entry setup + hass.data[DATA_NEST_CONFIG] = conf + + return True + + +async def async_setup_entry(hass, entry): + """Setup Nest from a config entry.""" + from nest import Nest + + nest = Nest(access_token=entry.data['tokens']['access_token']) + + _LOGGER.debug("proceeding with setup") + conf = hass.data.get(DATA_NEST_CONFIG, {}) + hass.data[DATA_NEST] = NestDevice(hass, conf, nest) + if not await hass.async_add_job(hass.data[DATA_NEST].initialize): + return False + + for component in 'climate', 'camera', 'sensor', 'binary_sensor': + hass.async_create_task(hass.config_entries.async_forward_entry_setup( + entry, component)) + + def set_mode(service): + """ + Set the home/away mode for a Nest structure. + + You can set optional eta information when set mode to away. + """ + if ATTR_STRUCTURE in service.data: + structures = service.data[ATTR_STRUCTURE] + else: + structures = hass.data[DATA_NEST].local_structure + + for structure in nest.structures: + if structure.name in structures: + _LOGGER.info("Setting mode for %s", structure.name) + structure.away = service.data[ATTR_HOME_MODE] + + if service.data[ATTR_HOME_MODE] == HOME_MODE_AWAY \ + and ATTR_ETA in service.data: + now = datetime.utcnow() + eta_begin = now + service.data[ATTR_ETA] + eta_window = service.data.get(ATTR_ETA_WINDOW, + timedelta(minutes=1)) + eta_end = eta_begin + eta_window + trip_id = service.data.get( + ATTR_TRIP_ID, "trip_{}".format(int(now.timestamp()))) + _LOGGER.info("Setting eta for %s, eta window starts at " + "%s ends at %s", trip_id, eta_begin, eta_end) + structure.set_eta(trip_id, eta_begin, eta_end) + else: + _LOGGER.error("Invalid structure %s", + service.data[ATTR_STRUCTURE]) + + hass.services.async_register( + DOMAIN, 'set_mode', set_mode, schema=AWAY_SCHEMA) + + @callback + def start_up(event): + """Start Nest update event listener.""" + threading.Thread( + name='Nest update listener', + target=nest_update_event_broker, + args=(hass, nest) + ).start() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_up) + + @callback + def shut_down(event): + """Stop Nest update event listener.""" + nest.update_event.set() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shut_down) + + _LOGGER.debug("async_setup_nest is done") + + return True + + +class NestDevice: + """Structure Nest functions for hass.""" + + def __init__(self, hass, conf, nest): + """Init Nest Devices.""" + self.hass = hass + self.nest = nest + self.local_structure = conf.get(CONF_STRUCTURE) + + def initialize(self): + """Initialize Nest.""" + from nest.nest import AuthorizationError, APIError + try: + # Do not optimize next statement, it is here for initialize + # persistence Nest API connection. + structure_names = [s.name for s in self.nest.structures] + if self.local_structure is None: + self.local_structure = structure_names + + except (AuthorizationError, APIError, socket.error) as err: + _LOGGER.error( + "Connection error while access Nest web service: %s", err) + return False + return True + + def structures(self): + """Generate a list of structures.""" + from nest.nest import AuthorizationError, APIError + try: + for structure in self.nest.structures: + if structure.name not in self.local_structure: + _LOGGER.debug("Ignoring structure %s, not in %s", + structure.name, self.local_structure) + continue + yield structure + + except (AuthorizationError, APIError, socket.error) as err: + _LOGGER.error( + "Connection error while access Nest web service: %s", err) + + def thermostats(self): + """Generate a list of thermostats.""" + return self._devices('thermostats') + + def smoke_co_alarms(self): + """Generate a list of smoke co alarms.""" + return self._devices('smoke_co_alarms') + + def cameras(self): + """Generate a list of cameras.""" + return self._devices('cameras') + + def _devices(self, device_type): + """Generate a list of Nest devices.""" + from nest.nest import AuthorizationError, APIError + try: + for structure in self.nest.structures: + if structure.name not in self.local_structure: + _LOGGER.debug("Ignoring structure %s, not in %s", + structure.name, self.local_structure) + continue + + for device in getattr(structure, device_type, []): + try: + # Do not optimize next statement, + # it is here for verify Nest API permission. + device.name_long + except KeyError: + _LOGGER.warning("Cannot retrieve device name for [%s]" + ", please check your Nest developer " + "account permission settings.", + device.serial) + continue + yield (structure, device) + + except (AuthorizationError, APIError, socket.error) as err: + _LOGGER.error( + "Connection error while access Nest web service: %s", err) + + +class NestSensorDevice(Entity): + """Representation of a Nest sensor.""" + + def __init__(self, structure, device, variable): + """Initialize the sensor.""" + self.structure = structure + self.variable = variable + + if device is not None: + # device specific + self.device = device + self._name = "{} {}".format(self.device.name_long, + self.variable.replace('_', ' ')) + else: + # structure only + self.device = structure + self._name = "{} {}".format(self.structure.name, + self.variable.replace('_', ' ')) + + self._state = None + self._unit = None + + @property + def name(self): + """Return the name of the nest, if any.""" + return self._name + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit + + @property + def should_poll(self): + """Do not need poll thanks using Nest streaming API.""" + return False + + def update(self): + """Do not use NestSensorDevice directly.""" + raise NotImplementedError + + async def async_added_to_hass(self): + """Register update signal handler.""" + async def async_update_state(): + """Update sensor state.""" + await self.async_update_ha_state(True) + + async_dispatcher_connect(self.hass, SIGNAL_NEST_UPDATE, + async_update_state) diff --git a/homeassistant/components/nest/config_flow.py b/homeassistant/components/nest/config_flow.py new file mode 100644 index 00000000000000..c9987693b1af75 --- /dev/null +++ b/homeassistant/components/nest/config_flow.py @@ -0,0 +1,164 @@ +"""Config flow to configure Nest.""" +import asyncio +from collections import OrderedDict +import logging +import os + +import async_timeout +import voluptuous as vol + +from homeassistant import config_entries, data_entry_flow +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.util.json import load_json + +from .const import DOMAIN + + +DATA_FLOW_IMPL = 'nest_flow_implementation' +_LOGGER = logging.getLogger(__name__) + + +@callback +def register_flow_implementation(hass, domain, name, gen_authorize_url, + convert_code): + """Register a flow implementation. + + domain: Domain of the component responsible for the implementation. + name: Name of the component. + gen_authorize_url: Coroutine function to generate the authorize url. + convert_code: Coroutine function to convert a code to an access token. + """ + if DATA_FLOW_IMPL not in hass.data: + hass.data[DATA_FLOW_IMPL] = OrderedDict() + + hass.data[DATA_FLOW_IMPL][domain] = { + 'domain': domain, + 'name': name, + 'gen_authorize_url': gen_authorize_url, + 'convert_code': convert_code, + } + + +class NestAuthError(HomeAssistantError): + """Base class for Nest auth errors.""" + + +class CodeInvalid(NestAuthError): + """Raised when invalid authorization code.""" + + +@config_entries.HANDLERS.register(DOMAIN) +class NestFlowHandler(data_entry_flow.FlowHandler): + """Handle a Nest config flow.""" + + VERSION = 1 + + def __init__(self): + """Initialize the Nest config flow.""" + self.flow_impl = None + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + return await self.async_step_init(user_input) + + async def async_step_init(self, user_input=None): + """Handle a flow start.""" + flows = self.hass.data.get(DATA_FLOW_IMPL, {}) + + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason='already_setup') + + if not flows: + return self.async_abort(reason='no_flows') + + if len(flows) == 1: + self.flow_impl = list(flows)[0] + return await self.async_step_link() + + if user_input is not None: + self.flow_impl = user_input['flow_impl'] + return await self.async_step_link() + + return self.async_show_form( + step_id='init', + data_schema=vol.Schema({ + vol.Required('flow_impl'): vol.In(list(flows)) + }) + ) + + async def async_step_link(self, user_input=None): + """Attempt to link with the Nest account. + + Route the user to a website to authenticate with Nest. Depending on + implementation type we expect a pin or an external component to + deliver the authentication code. + """ + flow = self.hass.data[DATA_FLOW_IMPL][self.flow_impl] + + errors = {} + + if user_input is not None: + try: + with async_timeout.timeout(10): + tokens = await flow['convert_code'](user_input['code']) + return self._entry_from_tokens( + 'Nest (via {})'.format(flow['name']), flow, tokens) + + except asyncio.TimeoutError: + errors['code'] = 'timeout' + except CodeInvalid: + errors['code'] = 'invalid_code' + except NestAuthError: + errors['code'] = 'unknown' + except Exception: # pylint: disable=broad-except + errors['code'] = 'internal_error' + _LOGGER.exception("Unexpected error resolving code") + + try: + with async_timeout.timeout(10): + url = await flow['gen_authorize_url'](self.flow_id) + except asyncio.TimeoutError: + return self.async_abort(reason='authorize_url_timeout') + except Exception: # pylint: disable=broad-except + _LOGGER.exception("Unexpected error generating auth url") + return self.async_abort(reason='authorize_url_fail') + + return self.async_show_form( + step_id='link', + description_placeholders={ + 'url': url + }, + data_schema=vol.Schema({ + vol.Required('code'): str, + }), + errors=errors, + ) + + async def async_step_import(self, info): + """Import existing auth from Nest.""" + if self.hass.config_entries.async_entries(DOMAIN): + return self.async_abort(reason='already_setup') + + config_path = info['nest_conf_path'] + + if not await self.hass.async_add_job(os.path.isfile, config_path): + self.flow_impl = DOMAIN + return await self.async_step_link() + + flow = self.hass.data[DATA_FLOW_IMPL][DOMAIN] + tokens = await self.hass.async_add_job(load_json, config_path) + + return self._entry_from_tokens( + 'Nest (import from configuration.yaml)', flow, tokens) + + @callback + def _entry_from_tokens(self, title, flow, tokens): + """Create an entry from tokens.""" + return self.async_create_entry( + title=title, + data={ + 'tokens': tokens, + 'impl_domain': flow['domain'], + }, + ) diff --git a/homeassistant/components/nest/const.py b/homeassistant/components/nest/const.py new file mode 100644 index 00000000000000..835918f6a048fd --- /dev/null +++ b/homeassistant/components/nest/const.py @@ -0,0 +1,2 @@ +"""Constants used by the Nest component.""" +DOMAIN = 'nest' diff --git a/homeassistant/components/nest/local_auth.py b/homeassistant/components/nest/local_auth.py new file mode 100644 index 00000000000000..5ab10cc2a5e25c --- /dev/null +++ b/homeassistant/components/nest/local_auth.py @@ -0,0 +1,45 @@ +"""Local Nest authentication.""" +import asyncio +from functools import partial + +from homeassistant.core import callback +from . import config_flow +from .const import DOMAIN + + +@callback +def initialize(hass, client_id, client_secret): + """Initialize a local auth provider.""" + config_flow.register_flow_implementation( + hass, DOMAIN, 'local', partial(generate_auth_url, client_id), + partial(resolve_auth_code, hass, client_id, client_secret) + ) + + +async def generate_auth_url(client_id, flow_id): + """Generate an authorize url.""" + from nest.nest import AUTHORIZE_URL + return AUTHORIZE_URL.format(client_id, flow_id) + + +async def resolve_auth_code(hass, client_id, client_secret, code): + """Resolve an authorization code.""" + from nest.nest import NestAuth, AuthorizationError + + result = asyncio.Future() + auth = NestAuth( + client_id=client_id, + client_secret=client_secret, + auth_callback=result.set_result, + ) + auth.pin = code + + try: + await hass.async_add_job(auth.login) + return await result + except AuthorizationError as err: + if err.response.status_code == 401: + raise config_flow.CodeInvalid() + else: + raise config_flow.NestAuthError('Unknown error: {} ({})'.format( + err, err.response.status_code)) diff --git a/homeassistant/components/nest/strings.json b/homeassistant/components/nest/strings.json new file mode 100644 index 00000000000000..5a70e3fd48d6c4 --- /dev/null +++ b/homeassistant/components/nest/strings.json @@ -0,0 +1,33 @@ +{ + "config": { + "title": "Nest", + "step": { + "init": { + "title": "Authentication Provider", + "description": "Pick via which authentication provider you want to authenticate with Nest.", + "data": { + "flow_impl": "Provider" + } + }, + "link": { + "title": "Link Nest Account", + "description": "To link your Nest account, [authorize your account]({url}).\n\nAfter authorization, copy-paste the provided pin code below.", + "data": { + "code": "Pin code" + } + } + }, + "error": { + "timeout": "Timeout validating code", + "invalid_code": "Invalid code", + "unknown": "Unknown error validating code", + "internal_error": "Internal error validating code" + }, + "abort": { + "already_setup": "You can only configure a single Nest account.", + "no_flows": "You need to configure Nest before being able to authenticate with it. [Please read the instructions](https://www.home-assistant.io/components/nest/).", + "authorize_url_timeout": "Timeout generating authorize url.", + "authorize_url_fail": "Unknown error generating an authorize url." + } + } +} \ No newline at end of file diff --git a/homeassistant/components/netatmo.py b/homeassistant/components/netatmo.py index 44a54c9551261f..c25b57fbd627ff 100644 --- a/homeassistant/components/netatmo.py +++ b/homeassistant/components/netatmo.py @@ -16,9 +16,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -REQUIREMENTS = [ - 'https://github.com/jabesq/netatmo-api-python/archive/' - 'v0.9.2.1.zip#lnetatmo==0.9.2.1'] +REQUIREMENTS = ['pyatmo==1.1.1'] _LOGGER = logging.getLogger(__name__) @@ -45,11 +43,11 @@ def setup(hass, config): """Set up the Netatmo devices.""" - import lnetatmo + import pyatmo global NETATMO_AUTH try: - NETATMO_AUTH = lnetatmo.ClientAuth( + NETATMO_AUTH = pyatmo.ClientAuth( config[DOMAIN][CONF_API_KEY], config[DOMAIN][CONF_SECRET_KEY], config[DOMAIN][CONF_USERNAME], config[DOMAIN][CONF_PASSWORD], 'read_station read_camera access_camera ' @@ -66,7 +64,7 @@ def setup(hass, config): return True -class CameraData(object): +class CameraData: """Get the latest data from Netatmo.""" def __init__(self, auth, home=None): @@ -111,8 +109,8 @@ def get_camera_type(self, camera=None, home=None, cid=None): @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Call the Netatmo API to update the data.""" - import lnetatmo - self.camera_data = lnetatmo.CameraData(self.auth, size=100) + import pyatmo + self.camera_data = pyatmo.CameraData(self.auth, size=100) @Throttle(MIN_TIME_BETWEEN_EVENT_UPDATES) def update_event(self): diff --git a/homeassistant/components/netgear_lte.py b/homeassistant/components/netgear_lte.py new file mode 100644 index 00000000000000..7f54e6fd6f91e1 --- /dev/null +++ b/homeassistant/components/netgear_lte.py @@ -0,0 +1,104 @@ +""" +Support for Netgear LTE modems. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/netgear_lte/ +""" +import asyncio +from datetime import timedelta + +import voluptuous as vol +import attr +import aiohttp + +from homeassistant.const import ( + CONF_HOST, CONF_PASSWORD, EVENT_HOMEASSISTANT_STOP) +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.aiohttp_client import async_create_clientsession +from homeassistant.util import Throttle + +REQUIREMENTS = ['eternalegypt==0.0.3'] + +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) + +DOMAIN = 'netgear_lte' +DATA_KEY = 'netgear_lte' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All(cv.ensure_list, [vol.Schema({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + })]) +}, extra=vol.ALLOW_EXTRA) + + +@attr.s +class ModemData: + """Class for modem state.""" + + modem = attr.ib() + serial_number = attr.ib(init=False) + unread_count = attr.ib(init=False) + usage = attr.ib(init=False) + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self): + """Call the API to update the data.""" + information = await self.modem.information() + self.serial_number = information.serial_number + self.unread_count = sum(1 for x in information.sms if x.unread) + self.usage = information.usage + + +@attr.s +class LTEData: + """Shared state.""" + + websession = attr.ib() + modem_data = attr.ib(init=False, factory=dict) + + def get_modem_data(self, config): + """Get the requested or the only modem_data value.""" + if CONF_HOST in config: + return self.modem_data.get(config[CONF_HOST]) + if len(self.modem_data) == 1: + return next(iter(self.modem_data.values())) + + return None + + +async def async_setup(hass, config): + """Set up Netgear LTE component.""" + if DATA_KEY not in hass.data: + websession = async_create_clientsession( + hass, cookie_jar=aiohttp.CookieJar(unsafe=True)) + hass.data[DATA_KEY] = LTEData(websession) + + tasks = [_setup_lte(hass, conf) for conf in config.get(DOMAIN, [])] + if tasks: + await asyncio.wait(tasks) + + return True + + +async def _setup_lte(hass, lte_config): + """Set up a Netgear LTE modem.""" + import eternalegypt + + host = lte_config[CONF_HOST] + password = lte_config[CONF_PASSWORD] + + websession = hass.data[DATA_KEY].websession + + modem = eternalegypt.Modem(hostname=host, websession=websession) + await modem.login(password=password) + + modem_data = ModemData(modem) + await modem_data.async_update() + hass.data[DATA_KEY].modem_data[host] = modem_data + + async def cleanup(event): + """Clean up resources.""" + await modem.logout() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, cleanup) diff --git a/homeassistant/components/notify/__init__.py b/homeassistant/components/notify/__init__.py index 41198d1f296425..4de35d3f850d66 100644 --- a/homeassistant/components/notify/__init__.py +++ b/homeassistant/components/notify/__init__.py @@ -156,6 +156,8 @@ def async_notify_message(service): DOMAIN, platform_name_slug, async_notify_message, schema=NOTIFY_SERVICE_SCHEMA) + hass.config.components.add('{}.{}'.format(DOMAIN, p_type)) + return True setup_tasks = [async_setup_platform(p_type, p_config) for p_type, p_config @@ -174,7 +176,7 @@ def async_platform_discovered(platform, info): return True -class BaseNotificationService(object): +class BaseNotificationService: """An abstract class for notification services.""" hass = None diff --git a/homeassistant/components/notify/apns.py b/homeassistant/components/notify/apns.py index dcbd1ce13174aa..8fabfc3aefb505 100644 --- a/homeassistant/components/notify/apns.py +++ b/homeassistant/components/notify/apns.py @@ -12,8 +12,8 @@ from homeassistant.helpers.event import track_state_change from homeassistant.config import load_yaml_config_file from homeassistant.components.notify import ( - ATTR_TARGET, ATTR_DATA, BaseNotificationService, DOMAIN) -from homeassistant.const import CONF_NAME, CONF_PLATFORM + ATTR_TARGET, ATTR_DATA, BaseNotificationService, DOMAIN, PLATFORM_SCHEMA) +from homeassistant.const import CONF_NAME, CONF_PLATFORM, ATTR_NAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers import template as template_helper @@ -27,9 +27,8 @@ SERVICE_REGISTER = 'apns_register' ATTR_PUSH_ID = 'push_id' -ATTR_NAME = 'name' -PLATFORM_SCHEMA = vol.Schema({ +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_PLATFORM): 'apns', vol.Required(CONF_NAME): cv.string, vol.Required(CONF_CERTFILE): cv.isfile, @@ -57,7 +56,7 @@ def get_service(hass, config, discovery_info=None): return service -class ApnsDevice(object): +class ApnsDevice: """ The APNS Device class. @@ -66,7 +65,7 @@ class ApnsDevice(object): """ def __init__(self, push_id, name, tracking_device_id=None, disabled=False): - """Initialize Apns Device.""" + """Initialize APNS Device.""" self.device_push_id = push_id self.device_name = name self.tracking_id = tracking_device_id @@ -104,7 +103,7 @@ def full_tracking_device_id(self): @property def disabled(self): - """Return the .""" + """Return the state of the service.""" return self.device_disabled def disable(self): diff --git a/homeassistant/components/notify/aws_lambda.py b/homeassistant/components/notify/aws_lambda.py index b0cc4a0121d5f9..46ac2f89d33cbf 100644 --- a/homeassistant/components/notify/aws_lambda.py +++ b/homeassistant/components/notify/aws_lambda.py @@ -44,7 +44,6 @@ def get_service(hass, config, discovery_info=None): context_b64 = base64.b64encode(context_str.encode('utf-8')) context = context_b64.decode('utf-8') - # pylint: disable=import-error import boto3 aws_config = config.copy() diff --git a/homeassistant/components/notify/aws_sns.py b/homeassistant/components/notify/aws_sns.py index c94e3abaa96fca..7ecf5a7cc7f88c 100644 --- a/homeassistant/components/notify/aws_sns.py +++ b/homeassistant/components/notify/aws_sns.py @@ -35,7 +35,6 @@ def get_service(hass, config, discovery_info=None): """Get the AWS SNS notification service.""" - # pylint: disable=import-error import boto3 aws_config = config.copy() diff --git a/homeassistant/components/notify/aws_sqs.py b/homeassistant/components/notify/aws_sqs.py index 43c04ed16d055b..30b673846e7f96 100644 --- a/homeassistant/components/notify/aws_sqs.py +++ b/homeassistant/components/notify/aws_sqs.py @@ -34,7 +34,6 @@ def get_service(hass, config, discovery_info=None): """Get the AWS SQS notification service.""" - # pylint: disable=import-error import boto3 aws_config = config.copy() diff --git a/homeassistant/components/notify/ciscospark.py b/homeassistant/components/notify/ciscospark.py index 0bf184023d7af6..e83e0e9024fff8 100644 --- a/homeassistant/components/notify/ciscospark.py +++ b/homeassistant/components/notify/ciscospark.py @@ -25,7 +25,6 @@ }) -# pylint: disable=unused-variable def get_service(hass, config, discovery_info=None): """Get the CiscoSpark notification service.""" return CiscoSparkNotificationService( diff --git a/homeassistant/components/notify/ecobee.py b/homeassistant/components/notify/ecobee.py index c718149b4b553b..31e4c4751c8000 100644 --- a/homeassistant/components/notify/ecobee.py +++ b/homeassistant/components/notify/ecobee.py @@ -11,7 +11,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components import ecobee from homeassistant.components.notify import ( - BaseNotificationService, PLATFORM_SCHEMA) # NOQA + BaseNotificationService, PLATFORM_SCHEMA) _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/notify/flock.py b/homeassistant/components/notify/flock.py new file mode 100644 index 00000000000000..d26f629809f8b3 --- /dev/null +++ b/homeassistant/components/notify/flock.py @@ -0,0 +1,61 @@ +""" +Flock platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.flock/ +""" +import asyncio +import logging + +import async_timeout +import voluptuous as vol + +from homeassistant.components.notify import ( + PLATFORM_SCHEMA, BaseNotificationService) +from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv + +_LOGGER = logging.getLogger(__name__) +_RESOURCE = 'https://api.flock.com/hooks/sendMessage/' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_ACCESS_TOKEN): cv.string, +}) + + +async def get_service(hass, config, discovery_info=None): + """Get the Flock notification service.""" + access_token = config.get(CONF_ACCESS_TOKEN) + url = '{}{}'.format(_RESOURCE, access_token) + session = async_get_clientsession(hass) + + return FlockNotificationService(url, session, hass.loop) + + +class FlockNotificationService(BaseNotificationService): + """Implement the notification service for Flock.""" + + def __init__(self, url, session, loop): + """Initialize the Flock notification service.""" + self._loop = loop + self._url = url + self._session = session + + async def async_send_message(self, message, **kwargs): + """Send the message to the user.""" + payload = {'text': message} + + _LOGGER.debug("Attempting to call Flock at %s", self._url) + + try: + with async_timeout.timeout(10, loop=self._loop): + response = await self._session.post(self._url, json=payload) + result = await response.json() + + if response.status != 200 or 'error' in result: + _LOGGER.error( + "Flock service returned HTTP status %d, response %s", + response.status, result) + except asyncio.TimeoutError: + _LOGGER.error("Timeout accessing Flock at %s", self._url) diff --git a/homeassistant/components/notify/group.py b/homeassistant/components/notify/group.py index a98bb6c23178b4..94856c730b1a17 100644 --- a/homeassistant/components/notify/group.py +++ b/homeassistant/components/notify/group.py @@ -5,7 +5,7 @@ https://home-assistant.io/components/notify.group/ """ import asyncio -import collections +from collections.abc import Mapping from copy import deepcopy import logging import voluptuous as vol @@ -33,7 +33,7 @@ def update(input_dict, update_source): Async friendly. """ for key, val in update_source.items(): - if isinstance(val, collections.Mapping): + if isinstance(val, Mapping): recurse = update(input_dict.get(key, {}), val) input_dict[key] = recurse else: diff --git a/homeassistant/components/notify/html5.py b/homeassistant/components/notify/html5.py index 7ccf4f8db9066f..1ed5047200419d 100644 --- a/homeassistant/components/notify/html5.py +++ b/homeassistant/components/notify/html5.py @@ -26,7 +26,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.util import ensure_unique_string -REQUIREMENTS = ['pywebpush==1.6.0', 'PyJWT==1.6.0'] +REQUIREMENTS = ['pywebpush==1.6.0'] DEPENDENCIES = ['frontend'] @@ -280,7 +280,7 @@ def check_authorization_header(self, request): return self.json_message('Authorization header must ' 'start with Bearer', status_code=HTTP_UNAUTHORIZED) - elif len(parts) != 2: + if len(parts) != 2: return self.json_message('Authorization header must ' 'be Bearer token', status_code=HTTP_UNAUTHORIZED) @@ -413,7 +413,6 @@ def send_message(self, message="", **kwargs): json.dumps(payload), gcm_key=gcm_key, ttl='86400' ) - # pylint: disable=no-member if response.status_code == 410: _LOGGER.info("Notification channel has expired") reg = self.registrations.pop(target) diff --git a/homeassistant/components/notify/joaoapps_join.py b/homeassistant/components/notify/joaoapps_join.py index e391d6559e5537..a75ff9cd165b7c 100644 --- a/homeassistant/components/notify/joaoapps_join.py +++ b/homeassistant/components/notify/joaoapps_join.py @@ -28,7 +28,6 @@ }) -# pylint: disable=unused-variable def get_service(hass, config, discovery_info=None): """Get the Join notification service.""" api_key = config.get(CONF_API_KEY) diff --git a/homeassistant/components/notify/lametric.py b/homeassistant/components/notify/lametric.py index 895ffd9db10f38..0cc3a0213b3f00 100644 --- a/homeassistant/components/notify/lametric.py +++ b/homeassistant/components/notify/lametric.py @@ -23,36 +23,41 @@ CONF_LIFETIME = "lifetime" CONF_CYCLES = "cycles" +CONF_PRIORITY = "priority" + +AVAILABLE_PRIORITIES = ["info", "warning", "critical"] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_ICON, default="i555"): cv.string, vol.Optional(CONF_LIFETIME, default=10): cv.positive_int, vol.Optional(CONF_CYCLES, default=1): cv.positive_int, + vol.Optional(CONF_PRIORITY, default="warning"): + vol.In(AVAILABLE_PRIORITIES) }) -# pylint: disable=unused-variable def get_service(hass, config, discovery_info=None): """Get the LaMetric notification service.""" hlmn = hass.data.get(LAMETRIC_DOMAIN) return LaMetricNotificationService(hlmn, config[CONF_ICON], config[CONF_LIFETIME] * 1000, - config[CONF_CYCLES]) + config[CONF_CYCLES], + config[CONF_PRIORITY]) class LaMetricNotificationService(BaseNotificationService): """Implement the notification service for LaMetric.""" - def __init__(self, hasslametricmanager, icon, lifetime, cycles): + def __init__(self, hasslametricmanager, icon, lifetime, cycles, priority): """Initialize the service.""" self.hasslametricmanager = hasslametricmanager self._icon = icon self._lifetime = lifetime self._cycles = cycles + self._priority = priority self._devices = [] - # pylint: disable=broad-except def send_message(self, message="", **kwargs): """Send a message to some LaMetric device.""" from lmnotify import SimpleFrame, Sound, Model @@ -64,6 +69,7 @@ def send_message(self, message="", **kwargs): icon = self._icon cycles = self._cycles sound = None + priority = self._priority # Additional data? if data is not None: @@ -78,6 +84,14 @@ def send_message(self, message="", **kwargs): except AssertionError: _LOGGER.error("Sound ID %s unknown, ignoring", data["sound"]) + if "cycles" in data: + cycles = data['cycles'] + if "priority" in data: + if data['priority'] in AVAILABLE_PRIORITIES: + priority = data['priority'] + else: + _LOGGER.warning("Priority %s invalid, using default %s", + data['priority'], priority) text_frame = SimpleFrame(icon, message) _LOGGER.debug("Icon/Message/Cycles/Lifetime: %s, %s, %d, %d", @@ -100,7 +114,8 @@ def send_message(self, message="", **kwargs): if targets is None or dev["name"] in targets: try: lmn.set_device(dev) - lmn.send_notification(model, lifetime=self._lifetime) + lmn.send_notification(model, lifetime=self._lifetime, + priority=priority) _LOGGER.debug("Sent notification to LaMetric %s", dev["name"]) except OSError: diff --git a/homeassistant/components/notify/mastodon.py b/homeassistant/components/notify/mastodon.py index 3ba95407fec15c..095e3f98ff91e3 100644 --- a/homeassistant/components/notify/mastodon.py +++ b/homeassistant/components/notify/mastodon.py @@ -13,7 +13,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['Mastodon.py==1.2.2'] +REQUIREMENTS = ['Mastodon.py==1.3.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/notify/message_bird.py b/homeassistant/components/notify/message_bird.py index b20abb52efc5c3..fa747ccba88dee 100644 --- a/homeassistant/components/notify/message_bird.py +++ b/homeassistant/components/notify/message_bird.py @@ -24,7 +24,6 @@ }) -# pylint: disable=unused-argument def get_service(hass, config, discovery_info=None): """Get the MessageBird notification service.""" import messagebird diff --git a/homeassistant/components/notify/mysensors.py b/homeassistant/components/notify/mysensors.py index 257b59954467f7..71ce7fb0b74eec 100644 --- a/homeassistant/components/notify/mysensors.py +++ b/homeassistant/components/notify/mysensors.py @@ -18,7 +18,7 @@ async def async_get_service(hass, config, discovery_info=None): return MySensorsNotificationService(hass) -class MySensorsNotificationDevice(mysensors.MySensorsDevice): +class MySensorsNotificationDevice(mysensors.device.MySensorsDevice): """Represent a MySensors Notification device.""" def send_msg(self, msg): @@ -36,13 +36,11 @@ def __repr__(self): class MySensorsNotificationService(BaseNotificationService): """Implement a MySensors notification service.""" - # pylint: disable=too-few-public-methods - def __init__(self, hass): """Initialize the service.""" self.devices = mysensors.get_mysensors_devices(hass, DOMAIN) - def send_message(self, message="", **kwargs): + async def async_send_message(self, message="", **kwargs): """Send a message to a user.""" target_devices = kwargs.get(ATTR_TARGET) devices = [device for device in self.devices.values() diff --git a/homeassistant/components/notify/netgear_lte.py b/homeassistant/components/notify/netgear_lte.py new file mode 100644 index 00000000000000..97dfe504a51308 --- /dev/null +++ b/homeassistant/components/notify/netgear_lte.py @@ -0,0 +1,45 @@ +"""Netgear LTE platform for notify component. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/notify.netgear_lte/ +""" + +import voluptuous as vol +import attr + +from homeassistant.components.notify import ( + BaseNotificationService, ATTR_TARGET, PLATFORM_SCHEMA) +from homeassistant.const import CONF_HOST +import homeassistant.helpers.config_validation as cv + +from ..netgear_lte import DATA_KEY + + +DEPENDENCIES = ['netgear_lte'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST): cv.string, + vol.Required(ATTR_TARGET): vol.All(cv.ensure_list, [cv.string]), +}) + + +async def async_get_service(hass, config, discovery_info=None): + """Get the notification service.""" + modem_data = hass.data[DATA_KEY].get_modem_data(config) + phone = config.get(ATTR_TARGET) + return NetgearNotifyService(modem_data, phone) + + +@attr.s +class NetgearNotifyService(BaseNotificationService): + """Implementation of a notification service.""" + + modem_data = attr.ib() + phone = attr.ib() + + async def async_send_message(self, message="", **kwargs): + """Send a message to a user.""" + targets = kwargs.get(ATTR_TARGET, self.phone) + if targets and message: + for target in targets: + await self.modem_data.modem.sms(target, message) diff --git a/homeassistant/components/notify/nfandroidtv.py b/homeassistant/components/notify/nfandroidtv.py index 1fa8f1dab78b63..044a037cc2978b 100644 --- a/homeassistant/components/notify/nfandroidtv.py +++ b/homeassistant/components/notify/nfandroidtv.py @@ -86,7 +86,6 @@ }) -# pylint: disable=unused-argument def get_service(hass, config, discovery_info=None): """Get the Notifications for Android TV notification service.""" remoteip = config.get(CONF_IP) diff --git a/homeassistant/components/notify/nma.py b/homeassistant/components/notify/nma.py deleted file mode 100644 index e81dc457a81333..00000000000000 --- a/homeassistant/components/notify/nma.py +++ /dev/null @@ -1,65 +0,0 @@ -""" -NMA (Notify My Android) notification service. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/notify.nma/ -""" -import logging -import xml.etree.ElementTree as ET - -import requests -import voluptuous as vol - -from homeassistant.components.notify import ( - ATTR_TITLE, ATTR_TITLE_DEFAULT, PLATFORM_SCHEMA, BaseNotificationService) -from homeassistant.const import CONF_API_KEY -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) -_RESOURCE = 'https://www.notifymyandroid.com/publicapi/' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_API_KEY): cv.string, -}) - - -def get_service(hass, config, discovery_info=None): - """Get the NMA notification service.""" - parameters = { - 'apikey': config[CONF_API_KEY], - } - response = requests.get( - '{}{}'.format(_RESOURCE, 'verify'), params=parameters, timeout=5) - tree = ET.fromstring(response.content) - - if tree[0].tag == 'error': - _LOGGER.error("Wrong API key supplied: %s", tree[0].text) - return None - - return NmaNotificationService(config[CONF_API_KEY]) - - -class NmaNotificationService(BaseNotificationService): - """Implement the notification service for NMA.""" - - def __init__(self, api_key): - """Initialize the service.""" - self._api_key = api_key - - def send_message(self, message="", **kwargs): - """Send a message to a user.""" - data = { - 'apikey': self._api_key, - 'application': 'home-assistant', - 'event': kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT), - 'description': message, - 'priority': 0, - } - - response = requests.get( - '{}{}'.format(_RESOURCE, 'notify'), params=data, timeout=5) - tree = ET.fromstring(response.content) - - if tree[0].tag == 'error': - _LOGGER.exception( - "Unable to perform request. Error: %s", tree[0].text) diff --git a/homeassistant/components/notify/pushbullet.py b/homeassistant/components/notify/pushbullet.py index 37edb6709a74d5..a94cf4f105528d 100644 --- a/homeassistant/components/notify/pushbullet.py +++ b/homeassistant/components/notify/pushbullet.py @@ -29,7 +29,6 @@ }) -# pylint: disable=unused-argument def get_service(hass, config, discovery_info=None): """Get the Pushbullet notification service.""" from pushbullet import PushBullet diff --git a/homeassistant/components/notify/pushover.py b/homeassistant/components/notify/pushover.py index cd73bbba4bfe8f..3ec0b27e7c4e09 100644 --- a/homeassistant/components/notify/pushover.py +++ b/homeassistant/components/notify/pushover.py @@ -26,7 +26,6 @@ }) -# pylint: disable=unused-variable def get_service(hass, config, discovery_info=None): """Get the Pushover notification service.""" from pushover import InitError diff --git a/homeassistant/components/notify/rest.py b/homeassistant/components/notify/rest.py index 40b09dc3c72f72..dd35f986f78a3b 100644 --- a/homeassistant/components/notify/rest.py +++ b/homeassistant/components/notify/rest.py @@ -95,7 +95,7 @@ def _data_template_creator(value): """Recursive template creator helper function.""" if isinstance(value, list): return [_data_template_creator(item) for item in value] - elif isinstance(value, dict): + if isinstance(value, dict): return {key: _data_template_creator(item) for key, item in value.items()} value.hass = self._hass diff --git a/homeassistant/components/notify/sendgrid.py b/homeassistant/components/notify/sendgrid.py index 89117397a5336a..92b709af8ad10c 100644 --- a/homeassistant/components/notify/sendgrid.py +++ b/homeassistant/components/notify/sendgrid.py @@ -14,7 +14,7 @@ CONF_API_KEY, CONF_SENDER, CONF_RECIPIENT, CONTENT_TYPE_TEXT_PLAIN) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['sendgrid==5.3.0'] +REQUIREMENTS = ['sendgrid==5.4.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/notify/slack.py b/homeassistant/components/notify/slack.py index b50260e4c613b4..d4c5a196a3fbbe 100644 --- a/homeassistant/components/notify/slack.py +++ b/homeassistant/components/notify/slack.py @@ -44,7 +44,6 @@ }) -# pylint: disable=unused-variable def get_service(hass, config, discovery_info=None): """Get the Slack notification service.""" import slacker diff --git a/homeassistant/components/notify/telegram.py b/homeassistant/components/notify/telegram.py index 899ccf9b09af97..b012506acd9db0 100644 --- a/homeassistant/components/notify/telegram.py +++ b/homeassistant/components/notify/telegram.py @@ -73,7 +73,7 @@ def send_message(self, message="", **kwargs): self.hass.services.call( DOMAIN, 'send_photo', service_data=service_data) return - elif data is not None and ATTR_VIDEO in data: + if data is not None and ATTR_VIDEO in data: videos = data.get(ATTR_VIDEO, None) videos = videos if isinstance(videos, list) else [videos] for video_data in videos: @@ -81,11 +81,11 @@ def send_message(self, message="", **kwargs): self.hass.services.call( DOMAIN, 'send_video', service_data=service_data) return - elif data is not None and ATTR_LOCATION in data: + if data is not None and ATTR_LOCATION in data: service_data.update(data.get(ATTR_LOCATION)) return self.hass.services.call( DOMAIN, 'send_location', service_data=service_data) - elif data is not None and ATTR_DOCUMENT in data: + if data is not None and ATTR_DOCUMENT in data: service_data.update(data.get(ATTR_DOCUMENT)) return self.hass.services.call( DOMAIN, 'send_document', service_data=service_data) diff --git a/homeassistant/components/notify/telstra.py b/homeassistant/components/notify/telstra.py deleted file mode 100644 index 82ac914a647cb2..00000000000000 --- a/homeassistant/components/notify/telstra.py +++ /dev/null @@ -1,104 +0,0 @@ -""" -Telstra API platform for notify component. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/notify.telstra/ -""" -import logging - -from aiohttp.hdrs import CONTENT_TYPE, AUTHORIZATION -import requests -import voluptuous as vol - -from homeassistant.components.notify import ( - ATTR_TITLE, PLATFORM_SCHEMA, BaseNotificationService) -from homeassistant.const import CONTENT_TYPE_JSON -import homeassistant.helpers.config_validation as cv - -_LOGGER = logging.getLogger(__name__) - -CONF_CONSUMER_KEY = 'consumer_key' -CONF_CONSUMER_SECRET = 'consumer_secret' -CONF_PHONE_NUMBER = 'phone_number' - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_CONSUMER_KEY): cv.string, - vol.Required(CONF_CONSUMER_SECRET): cv.string, - vol.Required(CONF_PHONE_NUMBER): cv.string, -}) - - -def get_service(hass, config, discovery_info=None): - """Get the Telstra SMS API notification service.""" - consumer_key = config.get(CONF_CONSUMER_KEY) - consumer_secret = config.get(CONF_CONSUMER_SECRET) - phone_number = config.get(CONF_PHONE_NUMBER) - - if _authenticate(consumer_key, consumer_secret) is False: - _LOGGER.exception("Error obtaining authorization from Telstra API") - return None - - return TelstraNotificationService( - consumer_key, consumer_secret, phone_number) - - -class TelstraNotificationService(BaseNotificationService): - """Implementation of a notification service for the Telstra SMS API.""" - - def __init__(self, consumer_key, consumer_secret, phone_number): - """Initialize the service.""" - self._consumer_key = consumer_key - self._consumer_secret = consumer_secret - self._phone_number = phone_number - - def send_message(self, message="", **kwargs): - """Send a message to a user.""" - title = kwargs.get(ATTR_TITLE) - - # Retrieve authorization first - token_response = _authenticate( - self._consumer_key, self._consumer_secret) - if token_response is False: - _LOGGER.exception("Error obtaining authorization from Telstra API") - return - - # Send the SMS - if title: - text = '{} {}'.format(title, message) - else: - text = message - - message_data = { - 'to': self._phone_number, - 'body': text, - } - message_resource = 'https://api.telstra.com/v1/sms/messages' - message_headers = { - CONTENT_TYPE: CONTENT_TYPE_JSON, - AUTHORIZATION: 'Bearer {}'.format(token_response['access_token']), - } - message_response = requests.post( - message_resource, headers=message_headers, json=message_data, - timeout=10) - - if message_response.status_code != 202: - _LOGGER.exception("Failed to send SMS. Status code: %d", - message_response.status_code) - - -def _authenticate(consumer_key, consumer_secret): - """Authenticate with the Telstra API.""" - token_data = { - 'client_id': consumer_key, - 'client_secret': consumer_secret, - 'grant_type': 'client_credentials', - 'scope': 'SMS' - } - token_resource = 'https://api.telstra.com/v1/oauth/token' - token_response = requests.get( - token_resource, params=token_data, timeout=10).json() - - if 'error' in token_response: - return False - - return token_response diff --git a/homeassistant/components/notify/twitter.py b/homeassistant/components/notify/twitter.py index 9489e05cfa5d24..6076cd5393a265 100644 --- a/homeassistant/components/notify/twitter.py +++ b/homeassistant/components/notify/twitter.py @@ -19,7 +19,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_USERNAME from homeassistant.helpers.event import async_track_point_in_time -REQUIREMENTS = ['TwitterAPI==2.5.0'] +REQUIREMENTS = ['TwitterAPI==2.5.4'] _LOGGER = logging.getLogger(__name__) @@ -194,9 +194,9 @@ def media_category_for_type(media_type): if media_type.startswith('image/gif'): return 'tweet_gif' - elif media_type.startswith('video/'): + if media_type.startswith('video/'): return 'tweet_video' - elif media_type.startswith('image/'): + if media_type.startswith('image/'): return 'tweet_image' return None diff --git a/homeassistant/components/notify/xmpp.py b/homeassistant/components/notify/xmpp.py index 12ddf49fca8bd2..c5678dff35136e 100644 --- a/homeassistant/components/notify/xmpp.py +++ b/homeassistant/components/notify/xmpp.py @@ -110,6 +110,5 @@ def check_credentials(self, event): def discard_ssl_invalid_cert(event): """Do nothing if ssl certificate is invalid.""" _LOGGER.info('Ignoring invalid ssl certificate as requested.') - return SendNotificationBot() diff --git a/homeassistant/components/nuimo_controller.py b/homeassistant/components/nuimo_controller.py index ffd7a799413b58..0f8fbb3907316c 100644 --- a/homeassistant/components/nuimo_controller.py +++ b/homeassistant/components/nuimo_controller.py @@ -15,9 +15,7 @@ REQUIREMENTS = [ '--only-binary=all ' # avoid compilation of gattlib - 'https://github.com/getSenic/nuimo-linux-python' - '/archive/29fc42987f74d8090d0e2382e8f248ff5990b8c9.zip' - '#nuimo==1.0.0'] + 'nuimo==0.1.0'] _LOGGER = logging.getLogger(__name__) @@ -54,7 +52,7 @@ def setup(hass, config): return True -class NuimoLogger(object): +class NuimoLogger: """Handle Nuimo Controller event callbacks.""" def __init__(self, hass, name): @@ -97,7 +95,6 @@ def run(self): self._nuimo.disconnect() self._nuimo = None - # pylint: disable=unused-argument def stop(self, event): """Terminate Thread by unsetting flag.""" _LOGGER.debug('Stopping thread for Nuimo %s', self._mac) @@ -170,7 +167,7 @@ def handle_write_matrix(call): ".........") -class DiscoveryLogger(object): +class DiscoveryLogger: """Handle Nuimo Discovery callbacks.""" # pylint: disable=no-self-use diff --git a/homeassistant/components/octoprint.py b/homeassistant/components/octoprint.py index 5caaa1b372d701..ff52ad94d8b8fc 100644 --- a/homeassistant/components/octoprint.py +++ b/homeassistant/components/octoprint.py @@ -50,7 +50,7 @@ def setup(hass, config): return True -class OctoPrintAPI(object): +class OctoPrintAPI: """Simple JSON wrapper for OctoPrint's API.""" def __init__(self, api_url, key, bed, number_of_tools): @@ -144,7 +144,6 @@ def update(self, sensor_type, end_point, group, tool=None): return response -# pylint: disable=unused-variable def get_value_from_json(json_dict, sensor_type, group, tool): """Return the value for sensor_type from the JSON.""" if group not in json_dict: @@ -155,7 +154,7 @@ def get_value_from_json(json_dict, sensor_type, group, tool): return 0 return json_dict[group][sensor_type] - elif tool is not None: + if tool is not None: if sensor_type in json_dict[group][tool]: return json_dict[group][tool][sensor_type] diff --git a/homeassistant/components/onboarding/__init__.py b/homeassistant/components/onboarding/__init__.py new file mode 100644 index 00000000000000..52d18b9a87063a --- /dev/null +++ b/homeassistant/components/onboarding/__init__.py @@ -0,0 +1,57 @@ +"""Component to help onboard new users.""" +from homeassistant.core import callback +from homeassistant.loader import bind_hass + +from .const import DOMAIN, STEP_USER, STEPS + +DEPENDENCIES = ['http'] + +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 + + +@bind_hass +@callback +def async_is_onboarded(hass): + """Return if Home Assistant has been onboarded.""" + # Temporarily: if auth not active, always set onboarded=True + if not hass.auth.active: + return True + + return hass.data.get(DOMAIN, True) + + +async def async_setup(hass, config): + """Set up the onboarding component.""" + store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + data = await store.async_load() + + if data is None: + data = { + 'done': [] + } + + if STEP_USER not in data['done']: + # Users can already have created an owner account via the command line + # If so, mark the user step as done. + has_owner = False + + for user in await hass.auth.async_get_users(): + if user.is_owner: + has_owner = True + break + + if has_owner: + data['done'].append(STEP_USER) + await store.async_save(data) + + if set(data['done']) == set(STEPS): + return True + + hass.data[DOMAIN] = False + + from . import views + + await views.async_setup(hass, data, store) + + return True diff --git a/homeassistant/components/onboarding/const.py b/homeassistant/components/onboarding/const.py new file mode 100644 index 00000000000000..3aa106ac18c4d9 --- /dev/null +++ b/homeassistant/components/onboarding/const.py @@ -0,0 +1,7 @@ +"""Constants for the onboarding component.""" +DOMAIN = 'onboarding' +STEP_USER = 'user' + +STEPS = [ + STEP_USER +] diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py new file mode 100644 index 00000000000000..497fa827f083df --- /dev/null +++ b/homeassistant/components/onboarding/views.py @@ -0,0 +1,107 @@ +"""Onboarding views.""" +import asyncio + +import voluptuous as vol + +from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.components.http.view import HomeAssistantView +from homeassistant.core import callback + +from .const import DOMAIN, STEP_USER, STEPS + + +async def async_setup(hass, data, store): + """Set up the onboarding view.""" + hass.http.register_view(OnboardingView(data, store)) + hass.http.register_view(UserOnboardingView(data, store)) + + +class OnboardingView(HomeAssistantView): + """Return the onboarding status.""" + + requires_auth = False + url = '/api/onboarding' + name = 'api:onboarding' + + def __init__(self, data, store): + """Initialize the onboarding view.""" + self._store = store + self._data = data + + async def get(self, request): + """Return the onboarding status.""" + return self.json([ + { + 'step': key, + 'done': key in self._data['done'], + } for key in STEPS + ]) + + +class _BaseOnboardingView(HomeAssistantView): + """Base class for onboarding.""" + + requires_auth = False + step = None + + def __init__(self, data, store): + """Initialize the onboarding view.""" + self._store = store + self._data = data + self._lock = asyncio.Lock() + + @callback + def _async_is_done(self): + """Return if this step is done.""" + return self.step in self._data['done'] + + async def _async_mark_done(self, hass): + """Mark step as done.""" + self._data['done'].append(self.step) + await self._store.async_save(self._data) + + hass.data[DOMAIN] = len(self._data) == len(STEPS) + + +class UserOnboardingView(_BaseOnboardingView): + """View to handle onboarding.""" + + url = '/api/onboarding/users' + name = 'api:onboarding:users' + step = STEP_USER + + @RequestDataValidator(vol.Schema({ + vol.Required('name'): str, + vol.Required('username'): str, + vol.Required('password'): str, + })) + async def post(self, request, data): + """Return the manifest.json.""" + hass = request.app['hass'] + + async with self._lock: + if self._async_is_done(): + return self.json_message('User step already done', 403) + + provider = _async_get_hass_provider(hass) + await provider.async_initialize() + + user = await hass.auth.async_create_user(data['name']) + await hass.async_add_executor_job( + provider.data.add_auth, data['username'], data['password']) + credentials = await provider.async_get_or_create_credentials({ + 'username': data['username'] + }) + await provider.data.async_save() + await hass.auth.async_link_user(user, credentials) + await self._async_mark_done(hass) + + +@callback +def _async_get_hass_provider(hass): + """Get the Home Assistant auth provider.""" + for prv in hass.auth.auth_providers: + if prv.type == 'homeassistant': + return prv + + raise RuntimeError('No Home Assistant provider found') diff --git a/homeassistant/components/openuv.py b/homeassistant/components/openuv.py new file mode 100644 index 00000000000000..dd038611ae96dc --- /dev/null +++ b/homeassistant/components/openuv.py @@ -0,0 +1,182 @@ +""" +Support for data from openuv.io. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/openuv/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_API_KEY, CONF_BINARY_SENSORS, CONF_ELEVATION, + CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, + CONF_SCAN_INTERVAL, CONF_SENSORS) +from homeassistant.helpers import ( + aiohttp_client, config_validation as cv, discovery) +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_time_interval + +REQUIREMENTS = ['pyopenuv==1.0.1'] +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'openuv' + +DATA_PROTECTION_WINDOW = 'protection_window' +DATA_UV = 'uv' + +DEFAULT_ATTRIBUTION = 'Data provided by OpenUV' +DEFAULT_SCAN_INTERVAL = timedelta(minutes=30) + +NOTIFICATION_ID = 'openuv_notification' +NOTIFICATION_TITLE = 'OpenUV Component Setup' + +TOPIC_UPDATE = '{0}_data_update'.format(DOMAIN) + +TYPE_CURRENT_OZONE_LEVEL = 'current_ozone_level' +TYPE_CURRENT_UV_INDEX = 'current_uv_index' +TYPE_MAX_UV_INDEX = 'max_uv_index' +TYPE_PROTECTION_WINDOW = 'uv_protection_window' +TYPE_SAFE_EXPOSURE_TIME_1 = 'safe_exposure_time_type_1' +TYPE_SAFE_EXPOSURE_TIME_2 = 'safe_exposure_time_type_2' +TYPE_SAFE_EXPOSURE_TIME_3 = 'safe_exposure_time_type_3' +TYPE_SAFE_EXPOSURE_TIME_4 = 'safe_exposure_time_type_4' +TYPE_SAFE_EXPOSURE_TIME_5 = 'safe_exposure_time_type_5' +TYPE_SAFE_EXPOSURE_TIME_6 = 'safe_exposure_time_type_6' + +BINARY_SENSORS = { + TYPE_PROTECTION_WINDOW: ('Protection Window', 'mdi:sunglasses') +} + +BINARY_SENSOR_SCHEMA = vol.Schema({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(BINARY_SENSORS)): + vol.All(cv.ensure_list, [vol.In(BINARY_SENSORS)]) +}) + +SENSORS = { + TYPE_CURRENT_OZONE_LEVEL: ( + 'Current Ozone Level', 'mdi:vector-triangle', 'du'), + TYPE_CURRENT_UV_INDEX: ('Current UV Index', 'mdi:weather-sunny', 'index'), + TYPE_MAX_UV_INDEX: ('Max UV Index', 'mdi:weather-sunny', 'index'), + TYPE_SAFE_EXPOSURE_TIME_1: ( + 'Skin Type 1 Safe Exposure Time', 'mdi:timer', 'minutes'), + TYPE_SAFE_EXPOSURE_TIME_2: ( + 'Skin Type 2 Safe Exposure Time', 'mdi:timer', 'minutes'), + TYPE_SAFE_EXPOSURE_TIME_3: ( + 'Skin Type 3 Safe Exposure Time', 'mdi:timer', 'minutes'), + TYPE_SAFE_EXPOSURE_TIME_4: ( + 'Skin Type 4 Safe Exposure Time', 'mdi:timer', 'minutes'), + TYPE_SAFE_EXPOSURE_TIME_5: ( + 'Skin Type 5 Safe Exposure Time', 'mdi:timer', 'minutes'), + TYPE_SAFE_EXPOSURE_TIME_6: ( + 'Skin Type 6 Safe Exposure Time', 'mdi:timer', 'minutes'), +} + +SENSOR_SCHEMA = vol.Schema({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): + vol.All(cv.ensure_list, [vol.In(SENSORS)]) +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_ELEVATION): float, + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_BINARY_SENSORS, default={}): BINARY_SENSOR_SCHEMA, + vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): + cv.time_period, + }) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the OpenUV component.""" + from pyopenuv import Client + from pyopenuv.errors import OpenUvError + + conf = config[DOMAIN] + api_key = conf[CONF_API_KEY] + elevation = conf.get(CONF_ELEVATION, hass.config.elevation) + latitude = conf.get(CONF_LATITUDE, hass.config.latitude) + longitude = conf.get(CONF_LONGITUDE, hass.config.longitude) + + try: + websession = aiohttp_client.async_get_clientsession(hass) + openuv = OpenUV( + Client( + api_key, latitude, longitude, websession, altitude=elevation), + conf[CONF_BINARY_SENSORS][CONF_MONITORED_CONDITIONS] + + conf[CONF_SENSORS][CONF_MONITORED_CONDITIONS]) + await openuv.async_update() + hass.data[DOMAIN] = openuv + except OpenUvError as err: + _LOGGER.error('An error occurred: %s', str(err)) + hass.components.persistent_notification.create( + 'Error: {0}
' + 'You will need to restart hass after fixing.' + ''.format(err), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + + for component, schema in [ + ('binary_sensor', conf[CONF_BINARY_SENSORS]), + ('sensor', conf[CONF_SENSORS]), + ]: + hass.async_create_task( + discovery.async_load_platform( + hass, component, DOMAIN, schema, config)) + + async def refresh_sensors(event_time): + """Refresh OpenUV data.""" + _LOGGER.debug('Refreshing OpenUV data') + await openuv.async_update() + async_dispatcher_send(hass, TOPIC_UPDATE) + + async_track_time_interval(hass, refresh_sensors, conf[CONF_SCAN_INTERVAL]) + + return True + + +class OpenUV: + """Define a generic OpenUV object.""" + + def __init__(self, client, monitored_conditions): + """Initialize.""" + self._monitored_conditions = monitored_conditions + self.client = client + self.data = {} + + async def async_update(self): + """Update sensor/binary sensor data.""" + if TYPE_PROTECTION_WINDOW in self._monitored_conditions: + data = await self.client.uv_protection_window() + self.data[DATA_PROTECTION_WINDOW] = data + + if any(c in self._monitored_conditions for c in SENSORS): + data = await self.client.uv_index() + self.data[DATA_UV] = data + + +class OpenUvEntity(Entity): + """Define a generic OpenUV entity.""" + + def __init__(self, openuv): + """Initialize.""" + self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._name = None + self.openuv = openuv + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attrs + + @property + def name(self): + """Return the name of the entity.""" + return self._name diff --git a/homeassistant/components/panel_custom.py b/homeassistant/components/panel_custom.py index 473d44f3b55156..0444e7a5b5305c 100644 --- a/homeassistant/components/panel_custom.py +++ b/homeassistant/components/panel_custom.py @@ -4,12 +4,12 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/panel_custom/ """ -import asyncio import logging import os import voluptuous as vol +from homeassistant.loader import bind_hass import homeassistant.helpers.config_validation as cv DOMAIN = 'panel_custom' @@ -21,49 +21,132 @@ CONF_URL_PATH = 'url_path' CONF_CONFIG = 'config' CONF_WEBCOMPONENT_PATH = 'webcomponent_path' +CONF_JS_URL = 'js_url' +CONF_EMBED_IFRAME = 'embed_iframe' +CONF_TRUST_EXTERNAL_SCRIPT = 'trust_external_script' + +DEFAULT_EMBED_IFRAME = False +DEFAULT_TRUST_EXTERNAL = False DEFAULT_ICON = 'mdi:bookmark' +LEGACY_URL = '/api/panel_custom/{}' PANEL_DIR = 'panels' CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.All(cv.ensure_list, [{ - vol.Required(CONF_COMPONENT_NAME): cv.slug, + DOMAIN: vol.All(cv.ensure_list, [vol.Schema({ + vol.Required(CONF_COMPONENT_NAME): cv.string, vol.Optional(CONF_SIDEBAR_TITLE): cv.string, vol.Optional(CONF_SIDEBAR_ICON, default=DEFAULT_ICON): cv.icon, vol.Optional(CONF_URL_PATH): cv.string, - vol.Optional(CONF_CONFIG): cv.match_all, + vol.Optional(CONF_CONFIG): dict, vol.Optional(CONF_WEBCOMPONENT_PATH): cv.isfile, - }]) + vol.Optional(CONF_JS_URL): cv.string, + vol.Optional(CONF_EMBED_IFRAME, + default=DEFAULT_EMBED_IFRAME): cv.boolean, + vol.Optional(CONF_TRUST_EXTERNAL_SCRIPT, + default=DEFAULT_TRUST_EXTERNAL): cv.boolean, + })]) }, extra=vol.ALLOW_EXTRA) _LOGGER = logging.getLogger(__name__) -@asyncio.coroutine -def async_setup(hass, config): +@bind_hass +async def async_register_panel( + hass, + # The url to serve the panel + frontend_url_path, + # The webcomponent name that loads your panel + webcomponent_name, + # Title/icon for sidebar + sidebar_title=None, + sidebar_icon=None, + # HTML source of your panel + html_url=None, + # JS source of your panel + js_url=None, + # If your panel should be run inside an iframe + embed_iframe=DEFAULT_EMBED_IFRAME, + # Should user be asked for confirmation when loading external source + trust_external=DEFAULT_TRUST_EXTERNAL, + # Configuration to be passed to the panel + config=None): + """Register a new custom panel.""" + if js_url is None and html_url is None: + raise ValueError('Either js_url or html_url is required.') + elif js_url and html_url: + raise ValueError('Pass in either JS url or HTML url, not both.') + + if config is not None and not isinstance(config, dict): + raise ValueError('Config needs to be a dictionary.') + + custom_panel_config = { + 'name': webcomponent_name, + 'embed_iframe': embed_iframe, + 'trust_external': trust_external, + } + + if js_url is not None: + custom_panel_config['js_url'] = js_url + + if html_url is not None: + custom_panel_config['html_url'] = html_url + + if config is not None: + # Make copy because we're mutating it + config = dict(config) + else: + config = {} + + config['_panel_custom'] = custom_panel_config + + await hass.components.frontend.async_register_built_in_panel( + component_name='custom', + sidebar_title=sidebar_title, + sidebar_icon=sidebar_icon, + frontend_url_path=frontend_url_path, + config=config + ) + + +async def async_setup(hass, config): """Initialize custom panel.""" success = False for panel in config.get(DOMAIN): - name = panel.get(CONF_COMPONENT_NAME) + name = panel[CONF_COMPONENT_NAME] + + kwargs = { + 'webcomponent_name': panel[CONF_COMPONENT_NAME], + 'frontend_url_path': panel.get(CONF_URL_PATH, name), + 'sidebar_title': panel.get(CONF_SIDEBAR_TITLE), + 'sidebar_icon': panel.get(CONF_SIDEBAR_ICON), + 'config': panel.get(CONF_CONFIG), + 'trust_external': panel[CONF_TRUST_EXTERNAL_SCRIPT], + 'embed_iframe': panel[CONF_EMBED_IFRAME], + } + panel_path = panel.get(CONF_WEBCOMPONENT_PATH) if panel_path is None: - panel_path = hass.config.path(PANEL_DIR, '{}.html'.format(name)) + panel_path = hass.config.path( + PANEL_DIR, '{}.html'.format(name)) - if not os.path.isfile(panel_path): + if CONF_JS_URL in panel: + kwargs['js_url'] = panel[CONF_JS_URL] + + elif not await hass.async_add_job(os.path.isfile, panel_path): _LOGGER.error('Unable to find webcomponent for %s: %s', name, panel_path) continue - yield from hass.components.frontend.async_register_panel( - name, panel_path, - sidebar_title=panel.get(CONF_SIDEBAR_TITLE), - sidebar_icon=panel.get(CONF_SIDEBAR_ICON), - frontend_url_path=panel.get(CONF_URL_PATH), - config=panel.get(CONF_CONFIG), - ) + else: + url = LEGACY_URL.format(name) + hass.http.register_static_path(url, panel_path) + kwargs['html_url'] = url + + await async_register_panel(hass, **kwargs) success = True diff --git a/homeassistant/components/panel_iframe.py b/homeassistant/components/panel_iframe.py index 4574437bac94ea..86594b74995b8c 100644 --- a/homeassistant/components/panel_iframe.py +++ b/homeassistant/components/panel_iframe.py @@ -4,8 +4,6 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/panel_iframe/ """ -import asyncio - import voluptuous as vol from homeassistant.const import (CONF_ICON, CONF_URL) @@ -34,11 +32,10 @@ }})}, extra=vol.ALLOW_EXTRA) -@asyncio.coroutine -def setup(hass, config): +async def async_setup(hass, config): """Set up the iFrame frontend panels.""" for url_path, info in config[DOMAIN].items(): - yield from hass.components.frontend.async_register_built_in_panel( + await hass.components.frontend.async_register_built_in_panel( 'iframe', info.get(CONF_TITLE), info.get(CONF_ICON), url_path, {'url': info[CONF_URL]}) diff --git a/homeassistant/components/persistent_notification/__init__.py b/homeassistant/components/persistent_notification/__init__.py index cce3550d35c8f0..2850a5f96cd90b 100644 --- a/homeassistant/components/persistent_notification/__init__.py +++ b/homeassistant/components/persistent_notification/__init__.py @@ -6,10 +6,11 @@ """ import asyncio import logging +from typing import Awaitable import voluptuous as vol -from homeassistant.core import callback +from homeassistant.core import callback, HomeAssistant from homeassistant.exceptions import TemplateError from homeassistant.loader import bind_hass from homeassistant.helpers import config_validation as cv @@ -58,7 +59,8 @@ def dismiss(hass, notification_id): @callback @bind_hass -def async_create(hass, message, title=None, notification_id=None): +def async_create(hass: HomeAssistant, message: str, title: str = None, + notification_id: str = None) -> None: """Generate a notification.""" data = { key: value for key, value in [ @@ -68,7 +70,8 @@ def async_create(hass, message, title=None, notification_id=None): ] if value is not None } - hass.async_add_job(hass.services.async_call(DOMAIN, SERVICE_CREATE, data)) + hass.async_create_task( + hass.services.async_call(DOMAIN, SERVICE_CREATE, data)) @callback @@ -81,7 +84,7 @@ def async_dismiss(hass, notification_id): @asyncio.coroutine -def async_setup(hass, config): +def async_setup(hass: HomeAssistant, config: dict) -> Awaitable[bool]: """Set up the persistent notification component.""" @callback def create_service(call): diff --git a/homeassistant/components/pilight.py b/homeassistant/components/pilight.py index 344c750c0ec741..d307a428e0e27d 100644 --- a/homeassistant/components/pilight.py +++ b/homeassistant/components/pilight.py @@ -118,7 +118,7 @@ def handle_received_code(data): return True -class CallRateDelayThrottle(object): +class CallRateDelayThrottle: """Helper class to provide service call rate throttling. This class provides a decorator to decorate service methods that need diff --git a/homeassistant/components/plant.py b/homeassistant/components/plant.py index 048851e97f5420..84dc8402742059 100644 --- a/homeassistant/components/plant.py +++ b/homeassistant/components/plant.py @@ -324,7 +324,7 @@ def state_attributes(self): return attrib -class DailyHistory(object): +class DailyHistory: """Stores one measurement per day for a maximum number of days. At the moment only the maximum value per day is kept. diff --git a/homeassistant/components/prometheus.py b/homeassistant/components/prometheus.py index 96ed098567d1d0..da986f024a4440 100644 --- a/homeassistant/components/prometheus.py +++ b/homeassistant/components/prometheus.py @@ -10,17 +10,17 @@ import voluptuous as vol from aiohttp import web +from homeassistant.components.climate import ATTR_CURRENT_TEMPERATURE from homeassistant.components.http import HomeAssistantView -from homeassistant.components import recorder from homeassistant.const import ( - CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_INCLUDE, EVENT_STATE_CHANGED, TEMP_FAHRENHEIT, CONTENT_TYPE_TEXT_PLAIN, ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT) from homeassistant import core as hacore -from homeassistant.helpers import state as state_helper +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import entityfilter, state as state_helper from homeassistant.util.temperature import fahrenheit_to_celsius -REQUIREMENTS = ['prometheus_client==0.1.0'] +REQUIREMENTS = ['prometheus_client==0.2.0'] _LOGGER = logging.getLogger(__name__) @@ -29,8 +29,14 @@ DOMAIN = 'prometheus' DEPENDENCIES = ['http'] +CONF_FILTER = 'filter' +CONF_PROM_NAMESPACE = 'namespace' + CONFIG_SCHEMA = vol.Schema({ - DOMAIN: recorder.FILTER_SCHEMA, + DOMAIN: vol.All({ + vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA, + vol.Optional(CONF_PROM_NAMESPACE): cv.string, + }) }, extra=vol.ALLOW_EXTRA) @@ -40,25 +46,26 @@ def setup(hass, config): hass.http.register_view(PrometheusView(prometheus_client)) - conf = config.get(DOMAIN, {}) - exclude = conf.get(CONF_EXCLUDE, {}) - include = conf.get(CONF_INCLUDE, {}) - metrics = Metrics(prometheus_client, exclude, include) + conf = config[DOMAIN] + entity_filter = conf[CONF_FILTER] + namespace = conf.get(CONF_PROM_NAMESPACE) + metrics = PrometheusMetrics(prometheus_client, entity_filter, namespace) hass.bus.listen(EVENT_STATE_CHANGED, metrics.handle_event) return True -class Metrics(object): +class PrometheusMetrics: """Model all of the metrics which should be exposed to Prometheus.""" - def __init__(self, prometheus_client, exclude, include): + def __init__(self, prometheus_client, entity_filter, namespace): """Initialize Prometheus Metrics.""" self.prometheus_client = prometheus_client - self.exclude = exclude.get(CONF_ENTITIES, []) + \ - exclude.get(CONF_DOMAINS, []) - self.include_domains = include.get(CONF_DOMAINS, []) - self.include_entities = include.get(CONF_ENTITIES, []) + self._filter = entity_filter + if namespace: + self.metrics_prefix = "{}_".format(namespace) + else: + self.metrics_prefix = "" self._metrics = {} def handle_event(self, event): @@ -71,14 +78,7 @@ def handle_event(self, event): _LOGGER.debug("Handling state update for %s", entity_id) domain, _ = hacore.split_entity_id(entity_id) - if entity_id in self.exclude: - return - if domain in self.exclude and entity_id not in self.include_entities: - return - if self.include_domains and domain not in self.include_domains: - return - if not self.exclude and (self.include_entities and - entity_id not in self.include_entities): + if not self._filter(state.entity_id): return handler = '_handle_{}'.format(domain) @@ -100,7 +100,9 @@ def _metric(self, metric, factory, documentation, labels=None): try: return self._metrics[metric] except KeyError: - self._metrics[metric] = factory(metric, documentation, labels) + full_metric_name = "{}{}".format(self.metrics_prefix, metric) + self._metrics[metric] = factory( + full_metric_name, documentation, labels) return self._metrics[metric] @staticmethod @@ -179,6 +181,15 @@ def _handle_climate(self, state): 'Temperature in degrees Celsius') metric.labels(**self._labels(state)).set(temp) + current_temp = state.attributes.get(ATTR_CURRENT_TEMPERATURE) + if current_temp: + if unit == TEMP_FAHRENHEIT: + current_temp = fahrenheit_to_celsius(current_temp) + metric = self._metric( + 'current_temperature_c', self.prometheus_client.Gauge, + 'Current Temperature in degrees Celsius') + metric.labels(**self._labels(state)).set(current_temp) + metric = self._metric( 'climate_state', self.prometheus_client.Gauge, 'State of the thermostat (0/1)') diff --git a/homeassistant/components/python_script.py b/homeassistant/components/python_script.py index 1d33740d4a485f..bbc6e07f2b0c90 100644 --- a/homeassistant/components/python_script.py +++ b/homeassistant/components/python_script.py @@ -18,7 +18,7 @@ from homeassistant.util import sanitize_filename import homeassistant.util.dt as dt_util -REQUIREMENTS = ['restrictedpython==4.0b3'] +REQUIREMENTS = ['restrictedpython==4.0b4'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/qwikswitch.py b/homeassistant/components/qwikswitch.py index f26318fa7a926c..63e30a9491edec 100644 --- a/homeassistant/components/qwikswitch.py +++ b/homeassistant/components/qwikswitch.py @@ -150,8 +150,10 @@ def callback_value_changed(_qsd, qsid, _val): comps = {'switch': [], 'light': [], 'sensor': [], 'binary_sensor': []} try: + sensor_ids = [] for sens in sensors: _, _type = SENSORS[sens['type']] + sensor_ids.append(sens['id']) if _type is bool: comps['binary_sensor'].append(sens) continue @@ -192,9 +194,7 @@ def callback_qs_listen(qspacket): 'qwikswitch.button.{}'.format(qspacket[QS_ID]), qspacket) return - if qspacket[QS_ID] not in qsusb.devices: - # Not a standard device in, component can handle packet - # i.e. sensors + if qspacket[QS_ID] in sensor_ids: _LOGGER.debug("Dispatch %s ((%s))", qspacket[QS_ID], qspacket) hass.helpers.dispatcher.async_dispatcher_send( qspacket[QS_ID], qspacket) diff --git a/homeassistant/components/rachio.py b/homeassistant/components/rachio.py new file mode 100644 index 00000000000000..0e67e15d5c099a --- /dev/null +++ b/homeassistant/components/rachio.py @@ -0,0 +1,288 @@ +""" +Integration with the Rachio Iro sprinkler system controller. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/rachio/ +""" +import asyncio +import logging + +from aiohttp import web +import voluptuous as vol +from typing import Optional +from homeassistant.auth.util import generate_secret +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import CONF_API_KEY, EVENT_HOMEASSISTANT_STOP, URL_API +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send + +REQUIREMENTS = ['rachiopy==0.1.3'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'rachio' + +CONF_CUSTOM_URL = 'hass_url_override' +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_CUSTOM_URL): cv.string, + }) +}, extra=vol.ALLOW_EXTRA) + +# Keys used in the API JSON +KEY_DEVICE_ID = 'deviceId' +KEY_DEVICES = 'devices' +KEY_ENABLED = 'enabled' +KEY_EXTERNAL_ID = 'externalId' +KEY_ID = 'id' +KEY_NAME = 'name' +KEY_ON = 'on' +KEY_STATUS = 'status' +KEY_SUBTYPE = 'subType' +KEY_SUMMARY = 'summary' +KEY_TYPE = 'type' +KEY_URL = 'url' +KEY_USERNAME = 'username' +KEY_ZONE_ID = 'zoneId' +KEY_ZONE_NUMBER = 'zoneNumber' +KEY_ZONES = 'zones' + +STATUS_ONLINE = 'ONLINE' +STATUS_OFFLINE = 'OFFLINE' + +# Device webhook values +TYPE_CONTROLLER_STATUS = 'DEVICE_STATUS' +SUBTYPE_OFFLINE = 'OFFLINE' +SUBTYPE_ONLINE = 'ONLINE' +SUBTYPE_OFFLINE_NOTIFICATION = 'OFFLINE_NOTIFICATION' +SUBTYPE_COLD_REBOOT = 'COLD_REBOOT' +SUBTYPE_SLEEP_MODE_ON = 'SLEEP_MODE_ON' +SUBTYPE_SLEEP_MODE_OFF = 'SLEEP_MODE_OFF' +SUBTYPE_BROWNOUT_VALVE = 'BROWNOUT_VALVE' +SUBTYPE_RAIN_SENSOR_DETECTION_ON = 'RAIN_SENSOR_DETECTION_ON' +SUBTYPE_RAIN_SENSOR_DETECTION_OFF = 'RAIN_SENSOR_DETECTION_OFF' +SUBTYPE_RAIN_DELAY_ON = 'RAIN_DELAY_ON' +SUBTYPE_RAIN_DELAY_OFF = 'RAIN_DELAY_OFF' + +# Schedule webhook values +TYPE_SCHEDULE_STATUS = 'SCHEDULE_STATUS' +SUBTYPE_SCHEDULE_STARTED = 'SCHEDULE_STARTED' +SUBTYPE_SCHEDULE_STOPPED = 'SCHEDULE_STOPPED' +SUBTYPE_SCHEDULE_COMPLETED = 'SCHEDULE_COMPLETED' +SUBTYPE_WEATHER_NO_SKIP = 'WEATHER_INTELLIGENCE_NO_SKIP' +SUBTYPE_WEATHER_SKIP = 'WEATHER_INTELLIGENCE_SKIP' +SUBTYPE_WEATHER_CLIMATE_SKIP = 'WEATHER_INTELLIGENCE_CLIMATE_SKIP' +SUBTYPE_WEATHER_FREEZE = 'WEATHER_INTELLIGENCE_FREEZE' + +# Zone webhook values +TYPE_ZONE_STATUS = 'ZONE_STATUS' +SUBTYPE_ZONE_STARTED = 'ZONE_STARTED' +SUBTYPE_ZONE_STOPPED = 'ZONE_STOPPED' +SUBTYPE_ZONE_COMPLETED = 'ZONE_COMPLETED' +SUBTYPE_ZONE_CYCLING = 'ZONE_CYCLING' +SUBTYPE_ZONE_CYCLING_COMPLETED = 'ZONE_CYCLING_COMPLETED' + +# Webhook callbacks +LISTEN_EVENT_TYPES = ['DEVICE_STATUS_EVENT', 'ZONE_STATUS_EVENT'] +WEBHOOK_CONST_ID = 'homeassistant.rachio:' +WEBHOOK_PATH = URL_API + DOMAIN +SIGNAL_RACHIO_UPDATE = DOMAIN + '_update' +SIGNAL_RACHIO_CONTROLLER_UPDATE = SIGNAL_RACHIO_UPDATE + '_controller' +SIGNAL_RACHIO_ZONE_UPDATE = SIGNAL_RACHIO_UPDATE + '_zone' +SIGNAL_RACHIO_SCHEDULE_UPDATE = SIGNAL_RACHIO_UPDATE + '_schedule' + + +def setup(hass, config) -> bool: + """Set up the Rachio component.""" + from rachiopy import Rachio + + # Listen for incoming webhook connections + hass.http.register_view(RachioWebhookView()) + + # Configure API + api_key = config[DOMAIN].get(CONF_API_KEY) + rachio = Rachio(api_key) + + # Get the URL of this server + custom_url = config[DOMAIN].get(CONF_CUSTOM_URL) + hass_url = hass.config.api.base_url if custom_url is None else custom_url + rachio.webhook_auth = generate_secret() + rachio.webhook_url = hass_url + WEBHOOK_PATH + + # Get the API user + try: + person = RachioPerson(hass, rachio) + except AssertionError as error: + _LOGGER.error("Could not reach the Rachio API: %s", error) + return False + + # Check for Rachio controller devices + if not person.controllers: + _LOGGER.error("No Rachio devices found in account %s", + person.username) + return False + _LOGGER.info("%d Rachio device(s) found", len(person.controllers)) + + # Enable component + hass.data[DOMAIN] = person + return True + + +class RachioPerson: + """Represent a Rachio user.""" + + def __init__(self, hass, rachio): + """Create an object from the provided API instance.""" + # Use API token to get user ID + self._hass = hass + self.rachio = rachio + + response = rachio.person.getInfo() + assert int(response[0][KEY_STATUS]) == 200, "API key error" + self._id = response[1][KEY_ID] + + # Use user ID to get user data + data = rachio.person.get(self._id) + assert int(data[0][KEY_STATUS]) == 200, "User ID error" + self.username = data[1][KEY_USERNAME] + self._controllers = [RachioIro(self._hass, self.rachio, controller) + for controller in data[1][KEY_DEVICES]] + _LOGGER.info('Using Rachio API as user "%s"', self.username) + + @property + def user_id(self) -> str: + """Get the user ID as defined by the Rachio API.""" + return self._id + + @property + def controllers(self) -> list: + """Get a list of controllers managed by this account.""" + return self._controllers + + +class RachioIro: + """Represent a Rachio Iro.""" + + def __init__(self, hass, rachio, data): + """Initialize a Rachio device.""" + self.hass = hass + self.rachio = rachio + self._id = data[KEY_ID] + self._name = data[KEY_NAME] + self._zones = data[KEY_ZONES] + self._init_data = data + _LOGGER.debug('%s has ID "%s"', str(self), self.controller_id) + + # Listen for all updates + self._init_webhooks() + + def _init_webhooks(self) -> None: + """Start getting updates from the Rachio API.""" + current_webhook_id = None + + # First delete any old webhooks that may have stuck around + def _deinit_webhooks(event) -> None: + """Stop getting updates from the Rachio API.""" + webhooks = self.rachio.notification.getDeviceWebhook( + self.controller_id)[1] + for webhook in webhooks: + if webhook[KEY_EXTERNAL_ID].startswith(WEBHOOK_CONST_ID) or\ + webhook[KEY_ID] == current_webhook_id: + self.rachio.notification.deleteWebhook(webhook[KEY_ID]) + _deinit_webhooks(None) + + # Choose which events to listen for and get their IDs + event_types = [] + for event_type in self.rachio.notification.getWebhookEventType()[1]: + if event_type[KEY_NAME] in LISTEN_EVENT_TYPES: + event_types.append({"id": event_type[KEY_ID]}) + + # Register to listen to these events from the device + url = self.rachio.webhook_url + auth = WEBHOOK_CONST_ID + self.rachio.webhook_auth + new_webhook = self.rachio.notification.postWebhook(self.controller_id, + auth, url, + event_types) + # Save ID for deletion at shutdown + current_webhook_id = new_webhook[1][KEY_ID] + self.hass.bus.listen(EVENT_HOMEASSISTANT_STOP, _deinit_webhooks) + + def __str__(self) -> str: + """Display the controller as a string.""" + return 'Rachio controller "{}"'.format(self.name) + + @property + def controller_id(self) -> str: + """Return the Rachio API controller ID.""" + return self._id + + @property + def name(self) -> str: + """Return the user-defined name of the controller.""" + return self._name + + @property + def current_schedule(self) -> str: + """Return the schedule that the device is running right now.""" + return self.rachio.device.getCurrentSchedule(self.controller_id)[1] + + @property + def init_data(self) -> dict: + """Return the information used to set up the controller.""" + return self._init_data + + def list_zones(self, include_disabled=False) -> list: + """Return a list of the zone dicts connected to the device.""" + # All zones + if include_disabled: + return self._zones + + # Only enabled zones + return [z for z in self._zones if z[KEY_ENABLED]] + + def get_zone(self, zone_id) -> Optional[dict]: + """Return the zone with the given ID.""" + for zone in self.list_zones(include_disabled=True): + if zone[KEY_ID] == zone_id: + return zone + + return None + + def stop_watering(self) -> None: + """Stop watering all zones connected to this controller.""" + self.rachio.device.stopWater(self.controller_id) + _LOGGER.info("Stopped watering of all zones on %s", str(self)) + + +class RachioWebhookView(HomeAssistantView): + """Provide a page for the server to call.""" + + SIGNALS = { + TYPE_CONTROLLER_STATUS: SIGNAL_RACHIO_CONTROLLER_UPDATE, + TYPE_SCHEDULE_STATUS: SIGNAL_RACHIO_SCHEDULE_UPDATE, + TYPE_ZONE_STATUS: SIGNAL_RACHIO_ZONE_UPDATE, + } + + requires_auth = False # Handled separately + url = WEBHOOK_PATH + name = url[1:].replace('/', ':') + + # pylint: disable=no-self-use + @asyncio.coroutine + async def post(self, request) -> web.Response: + """Handle webhook calls from the server.""" + hass = request.app['hass'] + data = await request.json() + + try: + auth = data.get(KEY_EXTERNAL_ID, str()).split(':')[1] + assert auth == hass.data[DOMAIN].rachio.webhook_auth + except (AssertionError, IndexError): + return web.Response(status=web.HTTPForbidden.status_code) + + update_type = data[KEY_TYPE] + if update_type in self.SIGNALS: + async_dispatcher_send(hass, self.SIGNALS[update_type], data) + + return web.Response(status=web.HTTPNoContent.status_code) diff --git a/homeassistant/components/rainbird.py b/homeassistant/components/rainbird.py index 76dda6fd366ff9..bbce7f752af970 100644 --- a/homeassistant/components/rainbird.py +++ b/homeassistant/components/rainbird.py @@ -11,7 +11,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.const import (CONF_HOST, CONF_PASSWORD) -REQUIREMENTS = ['pyrainbird==0.1.3'] +REQUIREMENTS = ['pyrainbird==0.1.6'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/raincloud.py b/homeassistant/components/raincloud.py index 505c3a7b2b00b8..53cd8e79d7e646 100644 --- a/homeassistant/components/raincloud.py +++ b/homeassistant/components/raincloud.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_time_interval -REQUIREMENTS = ['raincloudy==0.0.4'] +REQUIREMENTS = ['raincloudy==0.0.5'] _LOGGER = logging.getLogger(__name__) @@ -124,7 +124,7 @@ def hub_refresh(event_time): return True -class RainCloudHub(object): +class RainCloudHub: """Representation of a base RainCloud device.""" def __init__(self, data): @@ -168,7 +168,6 @@ def device_state_attributes(self): """Return the state attributes.""" return { ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - 'current_time': self.data.current_time, 'identifier': self.data.serial, } diff --git a/homeassistant/components/rainmachine.py b/homeassistant/components/rainmachine.py deleted file mode 100644 index 99cec53c2ed1d4..00000000000000 --- a/homeassistant/components/rainmachine.py +++ /dev/null @@ -1,89 +0,0 @@ -""" -This component provides support for RainMachine sprinkler controllers. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/rainmachine/ -""" -import logging -from datetime import timedelta - -import voluptuous as vol - -from homeassistant.helpers import config_validation as cv, discovery -from homeassistant.const import ( - CONF_IP_ADDRESS, CONF_PASSWORD, CONF_PORT, CONF_SSL, CONF_SWITCHES) - -REQUIREMENTS = ['regenmaschine==0.4.1'] - -_LOGGER = logging.getLogger(__name__) - -DATA_RAINMACHINE = 'data_rainmachine' -DOMAIN = 'rainmachine' - -NOTIFICATION_ID = 'rainmachine_notification' -NOTIFICATION_TITLE = 'RainMachine Component Setup' - -CONF_ZONE_RUN_TIME = 'zone_run_time' - -DEFAULT_ATTRIBUTION = 'Data provided by Green Electronics LLC' -DEFAULT_PORT = 8080 -DEFAULT_SSL = True - -MIN_SCAN_TIME = timedelta(seconds=1) -MIN_SCAN_TIME_FORCED = timedelta(milliseconds=100) - -SWITCH_SCHEMA = vol.Schema({ - vol.Optional(CONF_ZONE_RUN_TIME): - cv.positive_int -}) - -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.Schema({ - vol.Required(CONF_IP_ADDRESS): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, - vol.Optional(CONF_SWITCHES): SWITCH_SCHEMA, - }) - }, - extra=vol.ALLOW_EXTRA) - - -def setup(hass, config): - """Set up the RainMachine component.""" - from regenmaschine import Authenticator, Client - from regenmaschine.exceptions import HTTPError - from requests.exceptions import ConnectTimeout - - conf = config[DOMAIN] - ip_address = conf[CONF_IP_ADDRESS] - password = conf[CONF_PASSWORD] - port = conf[CONF_PORT] - ssl = conf[CONF_SSL] - - _LOGGER.debug('Setting up RainMachine client') - - try: - auth = Authenticator.create_local( - ip_address, password, port=port, https=ssl) - client = Client(auth) - mac = client.provision.wifi()['macAddress'] - hass.data[DATA_RAINMACHINE] = (client, mac) - except (HTTPError, ConnectTimeout, UnboundLocalError) as exc_info: - _LOGGER.error('An error occurred: %s', str(exc_info)) - hass.components.persistent_notification.create( - 'Error: {0}
' - 'You will need to restart hass after fixing.' - ''.format(exc_info), - title=NOTIFICATION_TITLE, - notification_id=NOTIFICATION_ID) - return False - - _LOGGER.debug('Setting up switch platform') - switch_config = conf.get(CONF_SWITCHES, {}) - discovery.load_platform(hass, 'switch', DOMAIN, switch_config, config) - - _LOGGER.debug('Setup complete') - - return True diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py new file mode 100644 index 00000000000000..9f15c8b373fa6f --- /dev/null +++ b/homeassistant/components/rainmachine/__init__.py @@ -0,0 +1,238 @@ +""" +Support for RainMachine devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/rainmachine/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_BINARY_SENSORS, CONF_IP_ADDRESS, CONF_PASSWORD, + CONF_PORT, CONF_SCAN_INTERVAL, CONF_SENSORS, CONF_SSL, + CONF_MONITORED_CONDITIONS, CONF_SWITCHES) +from homeassistant.helpers import ( + aiohttp_client, config_validation as cv, discovery) +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_time_interval + +REQUIREMENTS = ['regenmaschine==1.0.2'] + +_LOGGER = logging.getLogger(__name__) + +DATA_RAINMACHINE = 'data_rainmachine' +DOMAIN = 'rainmachine' + +NOTIFICATION_ID = 'rainmachine_notification' +NOTIFICATION_TITLE = 'RainMachine Component Setup' + +PROGRAM_UPDATE_TOPIC = '{0}_program_update'.format(DOMAIN) +SENSOR_UPDATE_TOPIC = '{0}_data_update'.format(DOMAIN) +ZONE_UPDATE_TOPIC = '{0}_zone_update'.format(DOMAIN) + +CONF_PROGRAM_ID = 'program_id' +CONF_ZONE_ID = 'zone_id' +CONF_ZONE_RUN_TIME = 'zone_run_time' + +DEFAULT_ATTRIBUTION = 'Data provided by Green Electronics LLC' +DEFAULT_ICON = 'mdi:water' +DEFAULT_PORT = 8080 +DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) +DEFAULT_SSL = True +DEFAULT_ZONE_RUN = 60 * 10 + +TYPE_FREEZE = 'freeze' +TYPE_FREEZE_PROTECTION = 'freeze_protection' +TYPE_FREEZE_TEMP = 'freeze_protect_temp' +TYPE_HOT_DAYS = 'extra_water_on_hot_days' +TYPE_HOURLY = 'hourly' +TYPE_MONTH = 'month' +TYPE_RAINDELAY = 'raindelay' +TYPE_RAINSENSOR = 'rainsensor' +TYPE_WEEKDAY = 'weekday' + +BINARY_SENSORS = { + TYPE_FREEZE: ('Freeze Restrictions', 'mdi:cancel'), + TYPE_FREEZE_PROTECTION: ('Freeze Protection', 'mdi:weather-snowy'), + TYPE_HOT_DAYS: ('Extra Water on Hot Days', 'mdi:thermometer-lines'), + TYPE_HOURLY: ('Hourly Restrictions', 'mdi:cancel'), + TYPE_MONTH: ('Month Restrictions', 'mdi:cancel'), + TYPE_RAINDELAY: ('Rain Delay Restrictions', 'mdi:cancel'), + TYPE_RAINSENSOR: ('Rain Sensor Restrictions', 'mdi:cancel'), + TYPE_WEEKDAY: ('Weekday Restrictions', 'mdi:cancel'), +} + +SENSORS = { + TYPE_FREEZE_TEMP: ('Freeze Protect Temperature', 'mdi:thermometer', '°C'), +} + +BINARY_SENSOR_SCHEMA = vol.Schema({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(BINARY_SENSORS)): + vol.All(cv.ensure_list, [vol.In(BINARY_SENSORS)]) +}) + +SENSOR_SCHEMA = vol.Schema({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): + vol.All(cv.ensure_list, [vol.In(SENSORS)]) +}) + +SERVICE_START_PROGRAM_SCHEMA = vol.Schema({ + vol.Required(CONF_PROGRAM_ID): cv.positive_int, +}) + +SERVICE_START_ZONE_SCHEMA = vol.Schema({ + vol.Required(CONF_ZONE_ID): cv.positive_int, + vol.Optional(CONF_ZONE_RUN_TIME, default=DEFAULT_ZONE_RUN): + cv.positive_int, +}) + +SERVICE_STOP_PROGRAM_SCHEMA = vol.Schema({ + vol.Required(CONF_PROGRAM_ID): cv.positive_int, +}) + +SERVICE_STOP_ZONE_SCHEMA = vol.Schema({ + vol.Required(CONF_ZONE_ID): cv.positive_int, +}) + +SWITCH_SCHEMA = vol.Schema({vol.Optional(CONF_ZONE_RUN_TIME): cv.positive_int}) + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: + vol.Schema({ + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): + cv.time_period, + vol.Optional(CONF_BINARY_SENSORS, default={}): + BINARY_SENSOR_SCHEMA, + vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA, + vol.Optional(CONF_SWITCHES, default={}): SWITCH_SCHEMA, + }) + }, + extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the RainMachine component.""" + from regenmaschine import Client + from regenmaschine.errors import RequestError + + conf = config[DOMAIN] + ip_address = conf[CONF_IP_ADDRESS] + password = conf[CONF_PASSWORD] + port = conf[CONF_PORT] + ssl = conf[CONF_SSL] + + try: + websession = aiohttp_client.async_get_clientsession(hass) + client = Client(ip_address, websession, port=port, ssl=ssl) + await client.authenticate(password) + rainmachine = RainMachine(client) + await rainmachine.async_update() + hass.data[DATA_RAINMACHINE] = rainmachine + except RequestError as err: + _LOGGER.error('An error occurred: %s', str(err)) + hass.components.persistent_notification.create( + 'Error: {0}
' + 'You will need to restart hass after fixing.' + ''.format(err), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + + for component, schema in [ + ('binary_sensor', conf[CONF_BINARY_SENSORS]), + ('sensor', conf[CONF_SENSORS]), + ('switch', conf[CONF_SWITCHES]), + ]: + hass.async_create_task( + discovery.async_load_platform(hass, component, DOMAIN, schema, + config)) + + async def refresh_sensors(event_time): + """Refresh RainMachine sensor data.""" + _LOGGER.debug('Updating RainMachine sensor data') + await rainmachine.async_update() + async_dispatcher_send(hass, SENSOR_UPDATE_TOPIC) + + async_track_time_interval(hass, refresh_sensors, conf[CONF_SCAN_INTERVAL]) + + async def start_program(service): + """Start a particular program.""" + await rainmachine.client.programs.start(service.data[CONF_PROGRAM_ID]) + async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) + + async def start_zone(service): + """Start a particular zone for a certain amount of time.""" + await rainmachine.client.zones.start(service.data[CONF_ZONE_ID], + service.data[CONF_ZONE_RUN_TIME]) + async_dispatcher_send(hass, ZONE_UPDATE_TOPIC) + + async def stop_all(service): + """Stop all watering.""" + await rainmachine.client.watering.stop_all() + async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) + + async def stop_program(service): + """Stop a program.""" + await rainmachine.client.programs.stop(service.data[CONF_PROGRAM_ID]) + async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) + + async def stop_zone(service): + """Stop a zone.""" + await rainmachine.client.zones.stop(service.data[CONF_ZONE_ID]) + async_dispatcher_send(hass, ZONE_UPDATE_TOPIC) + + for service, method, schema in [ + ('start_program', start_program, SERVICE_START_PROGRAM_SCHEMA), + ('start_zone', start_zone, SERVICE_START_ZONE_SCHEMA), + ('stop_all', stop_all, {}), + ('stop_program', stop_program, SERVICE_STOP_PROGRAM_SCHEMA), + ('stop_zone', stop_zone, SERVICE_STOP_ZONE_SCHEMA) + ]: + hass.services.async_register(DOMAIN, service, method, schema=schema) + + return True + + +class RainMachine: + """Define a generic RainMachine object.""" + + def __init__(self, client): + """Initialize.""" + self.client = client + self.device_mac = self.client.mac + self.restrictions = {} + + async def async_update(self): + """Update sensor/binary sensor data.""" + self.restrictions.update({ + 'current': await self.client.restrictions.current(), + 'global': await self.client.restrictions.universal() + }) + + +class RainMachineEntity(Entity): + """Define a generic RainMachine entity.""" + + def __init__(self, rainmachine): + """Initialize.""" + self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._name = None + self.rainmachine = rainmachine + + @property + def device_state_attributes(self) -> dict: + """Return the state attributes.""" + return self._attrs + + @property + def name(self) -> str: + """Return the name of the entity.""" + return self._name diff --git a/homeassistant/components/rainmachine/services.yaml b/homeassistant/components/rainmachine/services.yaml new file mode 100644 index 00000000000000..a8c77628c8f23a --- /dev/null +++ b/homeassistant/components/rainmachine/services.yaml @@ -0,0 +1,32 @@ +# Describes the format for available RainMachine services + +--- +start_program: + description: Start a program. + fields: + program_id: + description: The program to start. + example: 3 +start_zone: + description: Start a zone for a set number of seconds. + fields: + zone_id: + description: The zone to start. + example: 3 + zone_run_time: + description: The number of seconds to run the zone. + example: 120 +stop_all: + description: Stop all watering activities. +stop_program: + description: Stop a program. + fields: + program_id: + description: The program to stop. + example: 3 +stop_zone: + description: Stop a zone. + fields: + zone_id: + description: The zone to stop. + example: 3 diff --git a/homeassistant/components/raspihats.py b/homeassistant/components/raspihats.py index 3bc45eab34ece4..f43263bf4bf428 100644 --- a/homeassistant/components/raspihats.py +++ b/homeassistant/components/raspihats.py @@ -34,7 +34,6 @@ I2C_HATS_MANAGER = 'I2CH_MNG' -# pylint: disable=unused-argument def setup(hass, config): """Set up the raspihats component.""" hass.data[I2C_HATS_MANAGER] = I2CHatsManager() @@ -64,7 +63,7 @@ class I2CHatsException(Exception): """I2C-HATs exception.""" -class I2CHatsDIScanner(object): +class I2CHatsDIScanner: """Scan Digital Inputs and fire callbacks.""" _DIGITAL_INPUTS = "di" diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index 9b5bea043f4ecc..f3d8e269a42a0a 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -35,7 +35,7 @@ from .const import DATA_INSTANCE from .util import session_scope -REQUIREMENTS = ['sqlalchemy==1.2.7'] +REQUIREMENTS = ['sqlalchemy==1.2.10'] _LOGGER = logging.getLogger(__name__) @@ -284,7 +284,7 @@ def async_purge(now): self._close_connection() self.queue.task_done() return - elif isinstance(event, PurgeTask): + if isinstance(event, PurgeTask): purge.purge_old_data(self, event.keep_days, event.repack) self.queue.task_done() continue diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index af70c9d998c57f..207f2f53a7fd13 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -1,39 +1,53 @@ """Schema migration helpers.""" import logging +import os from .util import session_scope _LOGGER = logging.getLogger(__name__) +PROGRESS_FILE = '.migration_progress' def migrate_schema(instance): """Check if the schema needs to be upgraded.""" from .models import SchemaChanges, SCHEMA_VERSION + progress_path = instance.hass.config.path(PROGRESS_FILE) + with session_scope(session=instance.get_session()) as session: res = session.query(SchemaChanges).order_by( SchemaChanges.change_id.desc()).first() current_version = getattr(res, 'schema_version', None) if current_version == SCHEMA_VERSION: + # Clean up if old migration left file + if os.path.isfile(progress_path): + _LOGGER.warning("Found existing migration file, cleaning up") + os.remove(instance.hass.config.path(PROGRESS_FILE)) return - _LOGGER.debug("Database requires upgrade. Schema version: %s", - current_version) + with open(progress_path, 'w'): + pass + + _LOGGER.warning("Database requires upgrade. Schema version: %s", + current_version) if current_version is None: current_version = _inspect_schema_version(instance.engine, session) _LOGGER.debug("No schema version found. Inspected version: %s", current_version) - for version in range(current_version, SCHEMA_VERSION): - new_version = version + 1 - _LOGGER.info("Upgrading recorder db schema to version %s", - new_version) - _apply_update(instance.engine, new_version, current_version) - session.add(SchemaChanges(schema_version=new_version)) + try: + for version in range(current_version, SCHEMA_VERSION): + new_version = version + 1 + _LOGGER.info("Upgrading recorder db schema to version %s", + new_version) + _apply_update(instance.engine, new_version, current_version) + session.add(SchemaChanges(schema_version=new_version)) - _LOGGER.info("Upgrade to version %s done", new_version) + _LOGGER.info("Upgrade to version %s done", new_version) + finally: + os.remove(instance.hass.config.path(PROGRESS_FILE)) def _create_index(engine, table_name, index_name): @@ -43,6 +57,7 @@ def _create_index(engine, table_name, index_name): within the table definition described in the models """ from sqlalchemy import Table + from sqlalchemy.exc import OperationalError from . import models table = Table(table_name, models.Base.metadata) @@ -53,7 +68,15 @@ def _create_index(engine, table_name, index_name): _LOGGER.info("Adding index `%s` to database. Note: this can take several " "minutes on large databases and slow computers. Please " "be patient!", index_name) - index.create(engine) + try: + index.create(engine) + except OperationalError as err: + if 'already exists' not in str(err).lower(): + raise + + _LOGGER.warning('Index %s already exists on %s, continueing', + index_name, table_name) + _LOGGER.debug("Finished creating %s", index_name) @@ -114,6 +137,42 @@ def _drop_index(engine, table_name, index_name): "critical operation.", index_name, table_name) +def _add_columns(engine, table_name, columns_def): + """Add columns to a table.""" + from sqlalchemy import text + from sqlalchemy.exc import OperationalError + + _LOGGER.info("Adding columns %s to table %s. Note: this can take several " + "minutes on large databases and slow computers. Please " + "be patient!", + ', '.join(column.split(' ')[0] for column in columns_def), + table_name) + + columns_def = ['ADD {}'.format(col_def) for col_def in columns_def] + + try: + engine.execute(text("ALTER TABLE {table} {columns_def}".format( + table=table_name, + columns_def=', '.join(columns_def)))) + return + except OperationalError: + # Some engines support adding all columns at once, + # this error is when they dont' + _LOGGER.info('Unable to use quick column add. Adding 1 by 1.') + + for column_def in columns_def: + try: + engine.execute(text("ALTER TABLE {table} {column_def}".format( + table=table_name, + column_def=column_def))) + except OperationalError as err: + if 'duplicate' not in str(err).lower(): + raise + + _LOGGER.warning('Column %s already exists on %s, continueing', + column_def.split(' ')[1], table_name) + + def _apply_update(engine, new_version, old_version): """Perform operations to bring schema up to date.""" if new_version == 1: @@ -146,6 +205,19 @@ def _apply_update(engine, new_version, old_version): elif new_version == 5: # Create supporting index for States.event_id foreign key _create_index(engine, "states", "ix_states_event_id") + elif new_version == 6: + _add_columns(engine, "events", [ + 'context_id CHARACTER(36)', + 'context_user_id CHARACTER(36)', + ]) + _create_index(engine, "events", "ix_events_context_id") + _create_index(engine, "events", "ix_events_context_user_id") + _add_columns(engine, "states", [ + 'context_id CHARACTER(36)', + 'context_user_id CHARACTER(36)', + ]) + _create_index(engine, "states", "ix_states_context_id") + _create_index(engine, "states", "ix_states_context_user_id") else: raise ValueError("No schema migration defined for version {}" .format(new_version)) diff --git a/homeassistant/components/recorder/models.py b/homeassistant/components/recorder/models.py index 32d6291b90ca86..b8b777990f76b6 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -9,14 +9,15 @@ from sqlalchemy.ext.declarative import declarative_base import homeassistant.util.dt as dt_util -from homeassistant.core import Event, EventOrigin, State, split_entity_id +from homeassistant.core import ( + Context, Event, EventOrigin, State, split_entity_id) from homeassistant.remote import JSONEncoder # SQLAlchemy Schema # pylint: disable=invalid-name Base = declarative_base() -SCHEMA_VERSION = 5 +SCHEMA_VERSION = 6 _LOGGER = logging.getLogger(__name__) @@ -31,6 +32,8 @@ class Events(Base): # type: ignore origin = Column(String(32)) time_fired = Column(DateTime(timezone=True), index=True) created = Column(DateTime(timezone=True), default=datetime.utcnow) + context_id = Column(String(36), index=True) + context_user_id = Column(String(36), index=True) @staticmethod def from_event(event): @@ -38,16 +41,23 @@ def from_event(event): return Events(event_type=event.event_type, event_data=json.dumps(event.data, cls=JSONEncoder), origin=str(event.origin), - time_fired=event.time_fired) + time_fired=event.time_fired, + context_id=event.context.id, + context_user_id=event.context.user_id) def to_native(self): """Convert to a natve HA Event.""" + context = Context( + id=self.context_id, + user_id=self.context_user_id + ) try: return Event( self.event_type, json.loads(self.event_data), EventOrigin(self.origin), - _process_timestamp(self.time_fired) + _process_timestamp(self.time_fired), + context=context, ) except ValueError: # When json.loads fails @@ -69,6 +79,8 @@ class States(Base): # type: ignore last_updated = Column(DateTime(timezone=True), default=datetime.utcnow, index=True) created = Column(DateTime(timezone=True), default=datetime.utcnow) + context_id = Column(String(36), index=True) + context_user_id = Column(String(36), index=True) __table_args__ = ( # Used for fetching the state of entities at a specific time @@ -82,7 +94,11 @@ def from_event(event): entity_id = event.data['entity_id'] state = event.data.get('new_state') - dbstate = States(entity_id=entity_id) + dbstate = States( + entity_id=entity_id, + context_id=event.context.id, + context_user_id=event.context.user_id, + ) # State got deleted if state is None: @@ -103,12 +119,17 @@ def from_event(event): def to_native(self): """Convert to an HA state object.""" + context = Context( + id=self.context_id, + user_id=self.context_user_id + ) try: return State( self.entity_id, self.state, json.loads(self.attributes), _process_timestamp(self.last_changed), - _process_timestamp(self.last_updated) + _process_timestamp(self.last_updated), + context=context, ) except ValueError: # When json.loads fails @@ -168,7 +189,7 @@ def _process_timestamp(ts): """Process a timestamp into datetime object.""" if ts is None: return None - elif ts.tzinfo is None: + if ts.tzinfo is None: return dt_util.UTC.localize(ts) return dt_util.as_utc(ts) diff --git a/homeassistant/components/remember_the_milk/__init__.py b/homeassistant/components/remember_the_milk/__init__.py index 98cd937de3cca7..a94e8e95c6f6d2 100644 --- a/homeassistant/components/remember_the_milk/__init__.py +++ b/homeassistant/components/remember_the_milk/__init__.py @@ -138,7 +138,7 @@ def register_account_callback(_): ) -class RememberTheMilkConfiguration(object): +class RememberTheMilkConfiguration: """Internal configuration data for RememberTheMilk class. This class stores the authentication token it get from the backend. diff --git a/homeassistant/components/remote/demo.py b/homeassistant/components/remote/demo.py index bc67c1646b27b8..d959d74574f3b9 100644 --- a/homeassistant/components/remote/demo.py +++ b/homeassistant/components/remote/demo.py @@ -8,7 +8,6 @@ from homeassistant.const import DEVICE_DEFAULT_NAME -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up the demo remotes.""" add_devices_callback([ diff --git a/homeassistant/components/remote/harmony.py b/homeassistant/components/remote/harmony.py index 842dce087e8092..a63b73250357f5 100644 --- a/homeassistant/components/remote/harmony.py +++ b/homeassistant/components/remote/harmony.py @@ -10,7 +10,7 @@ import voluptuous as vol -import homeassistant.components.remote as remote +from homeassistant.components import remote from homeassistant.components.remote import ( ATTR_ACTIVITY, ATTR_DELAY_SECS, ATTR_DEVICE, ATTR_NUM_REPEATS, DEFAULT_DELAY_SECS, DOMAIN, PLATFORM_SCHEMA) diff --git a/homeassistant/components/remote/itach.py b/homeassistant/components/remote/itach.py index 8b91e5356b416d..829a038953cd81 100644 --- a/homeassistant/components/remote/itach.py +++ b/homeassistant/components/remote/itach.py @@ -10,7 +10,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv -import homeassistant.components.remote as remote +from homeassistant.components import remote from homeassistant.const import ( DEVICE_DEFAULT_NAME, CONF_NAME, CONF_MAC, CONF_HOST, CONF_PORT, CONF_DEVICES) @@ -44,7 +44,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the ITach connection and devices.""" import pyitachip2ir diff --git a/homeassistant/components/remote/kira.py b/homeassistant/components/remote/kira.py index 42d4ce77054636..dc37eb760f7d7d 100644 --- a/homeassistant/components/remote/kira.py +++ b/homeassistant/components/remote/kira.py @@ -7,7 +7,7 @@ import functools as ft import logging -import homeassistant.components.remote as remote +from homeassistant.components import remote from homeassistant.const import CONF_DEVICE, CONF_NAME from homeassistant.helpers.entity import Entity diff --git a/homeassistant/components/remote/xiaomi_miio.py b/homeassistant/components/remote/xiaomi_miio.py index e731d421e69f8c..eda09e3af64494 100644 --- a/homeassistant/components/remote/xiaomi_miio.py +++ b/homeassistant/components/remote/xiaomi_miio.py @@ -22,7 +22,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util.dt import utcnow -REQUIREMENTS = ['python-miio==0.3.9', 'construct==2.9.41'] +REQUIREMENTS = ['python-miio==0.4.0', 'construct==2.9.41'] _LOGGER = logging.getLogger(__name__) @@ -229,17 +229,16 @@ def device_state_attributes(self): return {'hidden': 'true'} return - # pylint: disable=R0201 @asyncio.coroutine def async_turn_on(self, **kwargs): """Turn the device on.""" - _LOGGER.error("Device does not support turn_on, " + + _LOGGER.error("Device does not support turn_on, " "please use 'remote.send_command' to send commands.") @asyncio.coroutine def async_turn_off(self, **kwargs): """Turn the device off.""" - _LOGGER.error("Device does not support turn_off, " + + _LOGGER.error("Device does not support turn_off, " "please use 'remote.send_command' to send commands.") def _send_command(self, payload): diff --git a/homeassistant/components/rflink.py b/homeassistant/components/rflink.py index 87e2a7a2331eb0..b8af971b3ffff3 100644 --- a/homeassistant/components/rflink.py +++ b/homeassistant/components/rflink.py @@ -20,6 +20,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.deprecation import get_deprecated from homeassistant.helpers.entity import Entity +from homeassistant.helpers.dispatcher import ( + async_dispatcher_send, async_dispatcher_connect) REQUIREMENTS = ['rflink==0.0.37'] @@ -65,6 +67,8 @@ SERVICE_SEND_COMMAND = 'send_command' +SIGNAL_AVAILABILITY = 'rflink_device_available' + DEVICE_DEFAULTS_SCHEMA = vol.Schema({ vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, vol.Optional(CONF_SIGNAL_REPETITIONS, @@ -96,7 +100,7 @@ def identify_event_type(event): """ if EVENT_KEY_COMMAND in event: return EVENT_KEY_COMMAND - elif EVENT_KEY_SENSOR in event: + if EVENT_KEY_SENSOR in event: return EVENT_KEY_SENSOR return 'unknown' @@ -185,6 +189,8 @@ def reconnect(exc=None): # Reset protocol binding before starting reconnect RflinkCommand.set_rflink_protocol(None) + async_dispatcher_send(hass, SIGNAL_AVAILABILITY, False) + # If HA is not stopping, initiate new connection if hass.state != CoreState.stopping: _LOGGER.warning('disconnected from Rflink, reconnecting') @@ -219,9 +225,16 @@ def connect(): _LOGGER.exception( "Error connecting to Rflink, reconnecting in %s", reconnect_interval) + # Connection to Rflink device is lost, make entities unavailable + async_dispatcher_send(hass, SIGNAL_AVAILABILITY, False) + hass.loop.call_later(reconnect_interval, reconnect, exc) return + # There is a valid connection to a Rflink device now so + # mark entities as available + async_dispatcher_send(hass, SIGNAL_AVAILABILITY, True) + # Bind protocol to command class to allow entities to send commands RflinkCommand.set_rflink_protocol( protocol, config[DOMAIN][CONF_WAIT_FOR_ACK]) @@ -244,6 +257,7 @@ class RflinkDevice(Entity): platform = None _state = STATE_UNKNOWN + _available = True def __init__(self, device_id, hass, name=None, aliases=None, group=True, group_aliases=None, nogroup_aliases=None, fire_event=False, @@ -305,6 +319,23 @@ def assumed_state(self): """Assume device state until first device event sets state.""" return self._state is STATE_UNKNOWN + @property + def available(self): + """Return True if entity is available.""" + return self._available + + @callback + def set_availability(self, availability): + """Update availability state.""" + self._available = availability + self.async_schedule_update_ha_state() + + @asyncio.coroutine + def async_added_to_hass(self): + """Register update callback.""" + async_dispatcher_connect(self.hass, SIGNAL_AVAILABILITY, + self.set_availability) + class RflinkCommand(RflinkDevice): """Singleton class to make Rflink command interface available to entities. diff --git a/homeassistant/components/rfxtrx.py b/homeassistant/components/rfxtrx.py index 2e96ec64d975ad..60dbb209039f8a 100644 --- a/homeassistant/components/rfxtrx.py +++ b/homeassistant/components/rfxtrx.py @@ -4,21 +4,18 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/rfxtrx/ """ - import asyncio -import logging from collections import OrderedDict +import logging + import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.util import slugify from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, - EVENT_HOMEASSISTANT_STOP, - ATTR_ENTITY_ID, TEMP_CELSIUS, - CONF_DEVICES -) + ATTR_ENTITY_ID, ATTR_NAME, ATTR_STATE, CONF_DEVICE, CONF_DEVICES, + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity +from homeassistant.util import slugify REQUIREMENTS = ['pyRFXtrx==0.22.1'] @@ -29,8 +26,6 @@ ATTR_AUTOMATIC_ADD = 'automatic_add' ATTR_DEVICE = 'device' ATTR_DEBUG = 'debug' -ATTR_STATE = 'state' -ATTR_NAME = 'name' ATTR_FIRE_EVENT = 'fire_event' ATTR_DATA_TYPE = 'data_type' ATTR_DUMMY = 'dummy' @@ -40,7 +35,6 @@ CONF_SIGNAL_REPETITIONS = 'signal_repetitions' CONF_FIRE_EVENT = 'fire_event' CONF_DUMMY = 'dummy' -CONF_DEVICE = 'device' CONF_DEBUG = 'debug' CONF_OFF_DELAY = 'off_delay' EVENT_BUTTON_PRESSED = 'button_pressed' @@ -168,11 +162,11 @@ def get_pt2262_cmd(device_id, data_bits): return hex(data[-1] & mask) -# pylint: disable=unused-variable def get_pt2262_device(device_id): """Look for the device which id matches the given device_id parameter.""" for device in RFX_DEVICES.values(): if (hasattr(device, 'is_lighting4') and + device.masked_id is not None and device.masked_id == get_pt2262_deviceid(device_id, device.data_bits)): _LOGGER.debug("rfxtrx: found matching device %s for %s", @@ -182,7 +176,6 @@ def get_pt2262_device(device_id): return None -# pylint: disable=unused-variable def find_possible_pt2262_device(device_id): """Look for the device which id matches the given device_id parameter.""" for dev_id, device in RFX_DEVICES.items(): diff --git a/homeassistant/components/ring.py b/homeassistant/components/ring.py index 1a15e22fca08c1..3bfa1372fabae5 100644 --- a/homeassistant/components/ring.py +++ b/homeassistant/components/ring.py @@ -12,7 +12,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.const import CONF_USERNAME, CONF_PASSWORD -REQUIREMENTS = ['ring_doorbell==0.1.8'] +REQUIREMENTS = ['ring_doorbell==0.2.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/rpi_gpio.py b/homeassistant/components/rpi_gpio.py index dfc60b5e45ee7a..824ec46d636d3c 100644 --- a/homeassistant/components/rpi_gpio.py +++ b/homeassistant/components/rpi_gpio.py @@ -17,10 +17,9 @@ DOMAIN = 'rpi_gpio' -# pylint: disable=no-member def setup(hass, config): """Set up the Raspberry PI GPIO component.""" - import RPi.GPIO as GPIO + from RPi import GPIO def cleanup_gpio(event): """Stuff to do before stopping.""" @@ -37,32 +36,32 @@ def prepare_gpio(event): def setup_output(port): """Set up a GPIO as output.""" - import RPi.GPIO as GPIO + from RPi import GPIO GPIO.setup(port, GPIO.OUT) def setup_input(port, pull_mode): """Set up a GPIO as input.""" - import RPi.GPIO as GPIO + from RPi import GPIO GPIO.setup(port, GPIO.IN, GPIO.PUD_DOWN if pull_mode == 'DOWN' else GPIO.PUD_UP) def write_output(port, value): """Write a value to a GPIO.""" - import RPi.GPIO as GPIO + from RPi import GPIO GPIO.output(port, value) def read_input(port): """Read a value from a GPIO.""" - import RPi.GPIO as GPIO + from RPi import GPIO return GPIO.input(port) def edge_detect(port, event_callback, bounce): """Add detection for RISING and FALLING events.""" - import RPi.GPIO as GPIO + from RPi import GPIO GPIO.add_event_detect( port, GPIO.BOTH, diff --git a/homeassistant/components/sabnzbd.py b/homeassistant/components/sabnzbd.py new file mode 100644 index 00000000000000..b9c75c87c1d1ed --- /dev/null +++ b/homeassistant/components/sabnzbd.py @@ -0,0 +1,254 @@ +""" +Support for monitoring an SABnzbd NZB client. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sabnzbd/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.discovery import SERVICE_SABNZBD +from homeassistant.const import ( + CONF_HOST, CONF_API_KEY, CONF_NAME, CONF_PORT, CONF_SENSORS, CONF_SSL) +from homeassistant.core import callback +from homeassistant.helpers import discovery +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util.json import load_json, save_json + +REQUIREMENTS = ['pysabnzbd==1.0.1'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'sabnzbd' +DATA_SABNZBD = 'sabznbd' + +_CONFIGURING = {} + +ATTR_SPEED = 'speed' +BASE_URL_FORMAT = '{}://{}:{}/' +CONFIG_FILE = 'sabnzbd.conf' +DEFAULT_HOST = 'localhost' +DEFAULT_NAME = 'SABnzbd' +DEFAULT_PORT = 8080 +DEFAULT_SPEED_LIMIT = '100' +DEFAULT_SSL = False + +UPDATE_INTERVAL = timedelta(seconds=30) + +SERVICE_PAUSE = 'pause' +SERVICE_RESUME = 'resume' +SERVICE_SET_SPEED = 'set_speed' + +SIGNAL_SABNZBD_UPDATED = 'sabnzbd_updated' + +SENSOR_TYPES = { + 'current_status': ['Status', None, 'status'], + 'speed': ['Speed', 'MB/s', 'kbpersec'], + 'queue_size': ['Queue', 'MB', 'mb'], + 'queue_remaining': ['Left', 'MB', 'mbleft'], + 'disk_size': ['Disk', 'GB', 'diskspacetotal1'], + 'disk_free': ['Disk Free', 'GB', 'diskspace1'], + 'queue_count': ['Queue Count', None, 'noofslots_total'], + 'day_size': ['Daily Total', 'GB', 'day_size'], + 'week_size': ['Weekly Total', 'GB', 'week_size'], + 'month_size': ['Monthly Total', 'GB', 'month_size'], + 'total_size': ['Total', 'GB', 'total_size'], +} + +SPEED_LIMIT_SCHEMA = vol.Schema({ + vol.Optional(ATTR_SPEED, default=DEFAULT_SPEED_LIMIT): cv.string, +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SENSORS): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + }), +}, extra=vol.ALLOW_EXTRA) + + +async def async_check_sabnzbd(sab_api): + """Check if we can reach SABnzbd.""" + from pysabnzbd import SabnzbdApiException + + try: + await sab_api.check_available() + return True + except SabnzbdApiException: + _LOGGER.error("Connection to SABnzbd API failed") + return False + + +async def async_configure_sabnzbd(hass, config, use_ssl, name=DEFAULT_NAME, + api_key=None): + """Try to configure Sabnzbd and request api key if configuration fails.""" + from pysabnzbd import SabnzbdApi + + host = config[CONF_HOST] + port = config[CONF_PORT] + uri_scheme = 'https' if use_ssl else 'http' + base_url = BASE_URL_FORMAT.format(uri_scheme, host, port) + if api_key is None: + conf = await hass.async_add_job(load_json, + hass.config.path(CONFIG_FILE)) + api_key = conf.get(base_url, {}).get(CONF_API_KEY, '') + + sab_api = SabnzbdApi(base_url, api_key) + if await async_check_sabnzbd(sab_api): + async_setup_sabnzbd(hass, sab_api, config, name) + else: + async_request_configuration(hass, config, base_url) + + +async def async_setup(hass, config): + """Setup the SABnzbd component.""" + async def sabnzbd_discovered(service, info): + """Handle service discovery.""" + ssl = info.get('properties', {}).get('https', '0') == '1' + await async_configure_sabnzbd(hass, info, ssl) + + discovery.async_listen(hass, SERVICE_SABNZBD, sabnzbd_discovered) + + conf = config.get(DOMAIN) + if conf is not None: + use_ssl = conf.get(CONF_SSL) + name = conf.get(CONF_NAME) + api_key = conf.get(CONF_API_KEY) + await async_configure_sabnzbd(hass, conf, use_ssl, name, api_key) + return True + + +@callback +def async_setup_sabnzbd(hass, sab_api, config, name): + """Setup SABnzbd sensors and services.""" + sab_api_data = SabnzbdApiData(sab_api, name, config.get(CONF_SENSORS, {})) + + if config.get(CONF_SENSORS): + hass.data[DATA_SABNZBD] = sab_api_data + hass.async_create_task( + discovery.async_load_platform(hass, 'sensor', DOMAIN, {}, config)) + + async def async_service_handler(service): + """Handle service calls.""" + if service.service == SERVICE_PAUSE: + await sab_api_data.async_pause_queue() + elif service.service == SERVICE_RESUME: + await sab_api_data.async_resume_queue() + elif service.service == SERVICE_SET_SPEED: + speed = service.data.get(ATTR_SPEED) + await sab_api_data.async_set_queue_speed(speed) + + hass.services.async_register(DOMAIN, SERVICE_PAUSE, + async_service_handler, + schema=vol.Schema({})) + + hass.services.async_register(DOMAIN, SERVICE_RESUME, + async_service_handler, + schema=vol.Schema({})) + + hass.services.async_register(DOMAIN, SERVICE_SET_SPEED, + async_service_handler, + schema=SPEED_LIMIT_SCHEMA) + + async def async_update_sabnzbd(now): + """Refresh SABnzbd queue data.""" + from pysabnzbd import SabnzbdApiException + try: + await sab_api.refresh_data() + async_dispatcher_send(hass, SIGNAL_SABNZBD_UPDATED, None) + except SabnzbdApiException as err: + _LOGGER.error(err) + + async_track_time_interval(hass, async_update_sabnzbd, UPDATE_INTERVAL) + + +@callback +def async_request_configuration(hass, config, host): + """Request configuration steps from the user.""" + from pysabnzbd import SabnzbdApi + + configurator = hass.components.configurator + # We got an error if this method is called while we are configuring + if host in _CONFIGURING: + configurator.async_notify_errors( + _CONFIGURING[host], + 'Failed to register, please try again.') + + return + + async def async_configuration_callback(data): + """Handle configuration changes.""" + api_key = data.get(CONF_API_KEY) + sab_api = SabnzbdApi(host, api_key) + if not await async_check_sabnzbd(sab_api): + return + + def success(): + """Setup was successful.""" + conf = load_json(hass.config.path(CONFIG_FILE)) + conf[host] = {CONF_API_KEY: api_key} + save_json(hass.config.path(CONFIG_FILE), conf) + req_config = _CONFIGURING.pop(host) + configurator.request_done(req_config) + + hass.async_add_job(success) + async_setup_sabnzbd(hass, sab_api, config, + config.get(CONF_NAME, DEFAULT_NAME)) + + _CONFIGURING[host] = configurator.async_request_config( + DEFAULT_NAME, + async_configuration_callback, + description='Enter the API Key', + submit_caption='Confirm', + fields=[{'id': CONF_API_KEY, 'name': 'API Key', 'type': ''}] + ) + + +class SabnzbdApiData: + """Class for storing/refreshing sabnzbd api queue data.""" + + def __init__(self, sab_api, name, sensors): + """Initialize component.""" + self.sab_api = sab_api + self.name = name + self.sensors = sensors + + async def async_pause_queue(self): + """Pause Sabnzbd queue.""" + from pysabnzbd import SabnzbdApiException + try: + return await self.sab_api.pause_queue() + except SabnzbdApiException as err: + _LOGGER.error(err) + return False + + async def async_resume_queue(self): + """Resume Sabnzbd queue.""" + from pysabnzbd import SabnzbdApiException + try: + return await self.sab_api.resume_queue() + except SabnzbdApiException as err: + _LOGGER.error(err) + return False + + async def async_set_queue_speed(self, limit): + """Set speed limit for the Sabnzbd queue.""" + from pysabnzbd import SabnzbdApiException + try: + return await self.sab_api.set_speed_limit(limit) + except SabnzbdApiException as err: + _LOGGER.error(err) + return False + + def get_queue_field(self, field): + """Return the value for the given field from the Sabnzbd queue.""" + return self.sab_api.queue.get(field) diff --git a/homeassistant/components/satel_integra.py b/homeassistant/components/satel_integra.py index 4b61ff15c08579..128377d19f7610 100644 --- a/homeassistant/components/satel_integra.py +++ b/homeassistant/components/satel_integra.py @@ -4,7 +4,6 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/satel_integra/ """ -# pylint: disable=invalid-name import asyncio import logging @@ -99,10 +98,10 @@ def _close(): conf, conf.get(CONF_ARM_HOME_MODE)) - task_control_panel = hass.async_add_job( + task_control_panel = hass.async_create_task( async_load_platform(hass, 'alarm_control_panel', DOMAIN, conf, config)) - task_zones = hass.async_add_job( + task_zones = hass.async_create_task( async_load_platform(hass, 'binary_sensor', DOMAIN, {CONF_ZONES: zones}, config)) diff --git a/homeassistant/components/scene/lifx_cloud.py b/homeassistant/components/scene/lifx_cloud.py index ffbb10cba4eca1..a9ec1ef679cb6f 100644 --- a/homeassistant/components/scene/lifx_cloud.py +++ b/homeassistant/components/scene/lifx_cloud.py @@ -29,7 +29,6 @@ }) -# pylint: disable=unused-argument @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the scenes stored in the LIFX Cloud.""" @@ -59,7 +58,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): devices.append(LifxCloudScene(hass, headers, timeout, scene)) async_add_devices(devices) return True - elif status == 401: + if status == 401: _LOGGER.error("Unauthorized (bad token?) on %s", url) return False diff --git a/homeassistant/components/scene/litejet.py b/homeassistant/components/scene/litejet.py index 37fb58d8dc7aec..87539e2dded96d 100644 --- a/homeassistant/components/scene/litejet.py +++ b/homeassistant/components/scene/litejet.py @@ -6,7 +6,7 @@ """ import logging -import homeassistant.components.litejet as litejet +from homeassistant.components import litejet from homeassistant.components.scene import Scene DEPENDENCIES = ['litejet'] diff --git a/homeassistant/components/scene/tuya.py b/homeassistant/components/scene/tuya.py new file mode 100644 index 00000000000000..3990a7da206993 --- /dev/null +++ b/homeassistant/components/scene/tuya.py @@ -0,0 +1,40 @@ +""" +Support for the Tuya scene. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/scene.tuya/ +""" +from homeassistant.components.scene import Scene, DOMAIN +from homeassistant.components.tuya import DATA_TUYA, TuyaDevice + +DEPENDENCIES = ['tuya'] + +ENTITY_ID_FORMAT = DOMAIN + '.{}' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Tuya scenes.""" + if discovery_info is None: + return + tuya = hass.data[DATA_TUYA] + dev_ids = discovery_info.get('dev_ids') + devices = [] + for dev_id in dev_ids: + device = tuya.get_device_by_id(dev_id) + if device is None: + continue + devices.append(TuyaScene(device)) + add_devices(devices) + + +class TuyaScene(TuyaDevice, Scene): + """Tuya Scene.""" + + def __init__(self, tuya): + """Init Tuya scene.""" + super().__init__(tuya) + self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) + + def activate(self): + """Activate the scene.""" + self.tuya.activate() diff --git a/homeassistant/components/scsgate.py b/homeassistant/components/scsgate.py index a7193b4094903d..dcea69cbb48dba 100644 --- a/homeassistant/components/scsgate.py +++ b/homeassistant/components/scsgate.py @@ -60,7 +60,7 @@ def stop_monitor(event): return True -class SCSGate(object): +class SCSGate: """The class for dealing with the SCSGate device via scsgate.Reactor.""" def __init__(self, device, logger): diff --git a/homeassistant/components/sensor/.translations/moon.ar.json b/homeassistant/components/sensor/.translations/moon.ar.json new file mode 100644 index 00000000000000..94af741f5f4de7 --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.ar.json @@ -0,0 +1,6 @@ +{ + "state": { + "first_quarter": "\u0627\u0644\u0631\u0628\u0639 \u0627\u0644\u0623\u0648\u0644", + "full_moon": "\u0627\u0644\u0642\u0645\u0631 \u0627\u0644\u0643\u0627\u0645\u0644" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.ca.json b/homeassistant/components/sensor/.translations/moon.ca.json new file mode 100644 index 00000000000000..56eaf8d3b4c8fe --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.ca.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "Quart creixent", + "full_moon": "Lluna plena", + "last_quarter": "Quart minvant", + "new_moon": "Lluna nova", + "waning_crescent": "Lluna vella minvant", + "waning_gibbous": "Gibosa minvant", + "waxing_crescent": "Lluna nova visible", + "waxing_gibbous": "Gibosa creixent" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.de.json b/homeassistant/components/sensor/.translations/moon.de.json new file mode 100644 index 00000000000000..aebca53ec4dcf7 --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.de.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "Erstes Viertel", + "full_moon": "Vollmond", + "last_quarter": "Letztes Viertel", + "new_moon": "Neumond", + "waning_crescent": "Abnehmende Sichel", + "waning_gibbous": "Drittes Viertel", + "waxing_crescent": " Zunehmende Sichel", + "waxing_gibbous": "Zweites Viertel" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.en.json b/homeassistant/components/sensor/.translations/moon.en.json new file mode 100644 index 00000000000000..587b9496114118 --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.en.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "First quarter", + "full_moon": "Full moon", + "last_quarter": "Last quarter", + "new_moon": "New moon", + "waning_crescent": "Waning crescent", + "waning_gibbous": "Waning gibbous", + "waxing_crescent": "Waxing crescent", + "waxing_gibbous": "Waxing gibbous" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.es-419.json b/homeassistant/components/sensor/.translations/moon.es-419.json new file mode 100644 index 00000000000000..71cfab736cb6be --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.es-419.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "Cuarto creciente", + "full_moon": "Luna llena", + "last_quarter": "Cuarto menguante", + "new_moon": "Luna nueva", + "waning_crescent": "Luna menguante", + "waning_gibbous": "Luna menguante gibosa", + "waxing_crescent": "Luna creciente", + "waxing_gibbous": "Luna creciente gibosa" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.fr.json b/homeassistant/components/sensor/.translations/moon.fr.json new file mode 100644 index 00000000000000..fac2b654a4664d --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.fr.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "Premier quartier", + "full_moon": "Pleine lune", + "last_quarter": "Dernier quartier", + "new_moon": "Nouvelle lune", + "waning_crescent": "Dernier croissant", + "waning_gibbous": "Gibbeuse d\u00e9croissante", + "waxing_crescent": "Premier croissant", + "waxing_gibbous": "Gibbeuse croissante" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.ko.json b/homeassistant/components/sensor/.translations/moon.ko.json new file mode 100644 index 00000000000000..7e62250b89224a --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.ko.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "\ubc18\ub2ec(\ucc28\uc624\ub974\ub294)", + "full_moon": "\ubcf4\ub984\ub2ec", + "last_quarter": "\ubc18\ub2ec(\uc904\uc5b4\ub4dc\ub294)", + "new_moon": "\uc0ad\uc6d4", + "waning_crescent": "\uadf8\ubbd0\ub2ec", + "waning_gibbous": "\ud558\ud604\ub2ec", + "waxing_crescent": "\ucd08\uc2b9\ub2ec", + "waxing_gibbous": "\uc0c1\ud604\ub2ec" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.nl.json b/homeassistant/components/sensor/.translations/moon.nl.json new file mode 100644 index 00000000000000..5e78d429b9f079 --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.nl.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "Eerste kwartier", + "full_moon": "Volle maan", + "last_quarter": "Laatste kwartier", + "new_moon": "Nieuwe maan", + "waning_crescent": "Krimpende, sikkelvormige maan", + "waning_gibbous": "Krimpende, vooruitspringende maan", + "waxing_crescent": "Wassende, sikkelvormige maan", + "waxing_gibbous": "Wassende, vooruitspringende maan" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.no.json b/homeassistant/components/sensor/.translations/moon.no.json new file mode 100644 index 00000000000000..104412c90babfd --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.no.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "F\u00f8rste kvartdel", + "full_moon": "Fullm\u00e5ne", + "last_quarter": "Siste kvartdel", + "new_moon": "Nym\u00e5ne", + "waning_crescent": "Minkende halvm\u00e5ne", + "waning_gibbous": "Minkende trekvartm\u00e5ne", + "waxing_crescent": "Voksende halvm\u00e5ne", + "waxing_gibbous": "Voksende trekvartm\u00e5ne" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.ru.json b/homeassistant/components/sensor/.translations/moon.ru.json new file mode 100644 index 00000000000000..6db932a1aed0ae --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.ru.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "\u041f\u0435\u0440\u0432\u0430\u044f \u0447\u0435\u0442\u0432\u0435\u0440\u0442\u044c", + "full_moon": "\u041f\u043e\u043b\u043d\u043e\u043b\u0443\u043d\u0438\u0435", + "last_quarter": "\u041f\u043e\u0441\u043b\u0435\u0434\u043d\u044f\u044f \u0447\u0435\u0442\u0432\u0435\u0440\u0442\u044c", + "new_moon": "\u041d\u043e\u0432\u043e\u043b\u0443\u043d\u0438\u0435", + "waning_crescent": "\u0421\u0442\u0430\u0440\u0430\u044f \u043b\u0443\u043d\u0430", + "waning_gibbous": "\u0423\u0431\u044b\u0432\u0430\u044e\u0449\u0430\u044f \u043b\u0443\u043d\u0430", + "waxing_crescent": "\u041c\u043e\u043b\u043e\u0434\u0430\u044f \u043b\u0443\u043d\u0430", + "waxing_gibbous": "\u041f\u0440\u0438\u0431\u044b\u0432\u0430\u044e\u0449\u0430\u044f \u043b\u0443\u043d\u0430" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.sl.json b/homeassistant/components/sensor/.translations/moon.sl.json new file mode 100644 index 00000000000000..41e873e4defd80 --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.sl.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "Prvi krajec", + "full_moon": "Polna luna", + "last_quarter": "Zadnji krajec", + "new_moon": "Mlaj", + "waning_crescent": "Zadnji izbo\u010dec", + "waning_gibbous": "Zadnji srpec", + "waxing_crescent": " Prvi izbo\u010dec", + "waxing_gibbous": "Prvi srpec" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.zh-Hans.json b/homeassistant/components/sensor/.translations/moon.zh-Hans.json new file mode 100644 index 00000000000000..22ab0d49f62d06 --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.zh-Hans.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "\u4e0a\u5f26\u6708", + "full_moon": "\u6ee1\u6708", + "last_quarter": "\u4e0b\u5f26\u6708", + "new_moon": "\u65b0\u6708", + "waning_crescent": "\u6b8b\u6708", + "waning_gibbous": "\u4e8f\u51f8\u6708", + "waxing_crescent": "\u5ce8\u7709\u6708", + "waxing_gibbous": "\u76c8\u51f8\u6708" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/moon.zh-Hant.json b/homeassistant/components/sensor/.translations/moon.zh-Hant.json new file mode 100644 index 00000000000000..9cf4aad011e04e --- /dev/null +++ b/homeassistant/components/sensor/.translations/moon.zh-Hant.json @@ -0,0 +1,12 @@ +{ + "state": { + "first_quarter": "\u4e0a\u5f26\u6708", + "full_moon": "\u6eff\u6708", + "last_quarter": "\u4e0b\u5f26\u6708", + "new_moon": "\u65b0\u6708", + "waning_crescent": "\u6b98\u6708", + "waning_gibbous": "\u8667\u51f8\u6708", + "waxing_crescent": "\u86fe\u7709\u6708", + "waxing_gibbous": "\u76c8\u51f8\u6708" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.ca.json b/homeassistant/components/sensor/.translations/season.ca.json new file mode 100644 index 00000000000000..9bce187ec65d91 --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.ca.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "Tardor", + "spring": "Primavera", + "summer": "Estiu", + "winter": "Hivern" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.es-419.json b/homeassistant/components/sensor/.translations/season.es-419.json new file mode 100644 index 00000000000000..65df6a58b10799 --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.es-419.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "Oto\u00f1o", + "spring": "Primavera", + "summer": "Verano", + "winter": "Invierno" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.fr.json b/homeassistant/components/sensor/.translations/season.fr.json new file mode 100644 index 00000000000000..ec9f9657428917 --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.fr.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "Automne", + "spring": "Printemps", + "summer": "\u00c9t\u00e9", + "winter": "Hiver" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.lv.json b/homeassistant/components/sensor/.translations/season.lv.json new file mode 100644 index 00000000000000..a96e1112f71f31 --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.lv.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "Rudens", + "spring": "Pavasaris", + "summer": "Vasara", + "winter": "Ziema" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.pt-BR.json b/homeassistant/components/sensor/.translations/season.pt-BR.json new file mode 100644 index 00000000000000..fde45ad6c8efa0 --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.pt-BR.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "Outono", + "spring": "Primavera", + "summer": "Ver\u00e3o", + "winter": "Inverno" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/abode.py b/homeassistant/components/sensor/abode.py index b51ab288c1aa30..26247c7745474f 100644 --- a/homeassistant/components/sensor/abode.py +++ b/homeassistant/components/sensor/abode.py @@ -67,9 +67,9 @@ def state(self): """Return the state of the sensor.""" if self._sensor_type == 'temp': return self._device.temp - elif self._sensor_type == 'humidity': + if self._sensor_type == 'humidity': return self._device.humidity - elif self._sensor_type == 'lux': + if self._sensor_type == 'lux': return self._device.lux @property @@ -77,7 +77,7 @@ def unit_of_measurement(self): """Return the units of measurement.""" if self._sensor_type == 'temp': return self._device.temp_unit - elif self._sensor_type == 'humidity': + if self._sensor_type == 'humidity': return self._device.humidity_unit - elif self._sensor_type == 'lux': + if self._sensor_type == 'lux': return self._device.lux_unit diff --git a/homeassistant/components/sensor/airvisual.py b/homeassistant/components/sensor/airvisual.py index b4007c8d7440fb..403722c7b6ad26 100644 --- a/homeassistant/components/sensor/airvisual.py +++ b/homeassistant/components/sensor/airvisual.py @@ -9,16 +9,16 @@ import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, CONF_API_KEY, - CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, CONF_STATE, - CONF_SHOW_ON_MAP, CONF_RADIUS) + CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, + CONF_SCAN_INTERVAL, CONF_STATE, CONF_SHOW_ON_MAP) +from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['pyairvisual==1.0.0'] +REQUIREMENTS = ['pyairvisual==2.0.1'] _LOGGER = getLogger(__name__) ATTR_CITY = 'city' @@ -29,135 +29,173 @@ CONF_CITY = 'city' CONF_COUNTRY = 'country' -CONF_ATTRIBUTION = "Data provided by AirVisual" + +DEFAULT_ATTRIBUTION = "Data provided by AirVisual" +DEFAULT_SCAN_INTERVAL = timedelta(minutes=10) MASS_PARTS_PER_MILLION = 'ppm' MASS_PARTS_PER_BILLION = 'ppb' VOLUME_MICROGRAMS_PER_CUBIC_METER = 'µg/m3' -MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) - -SENSOR_TYPES = [ - ('AirPollutionLevelSensor', 'Air Pollution Level', 'mdi:scale'), - ('AirQualityIndexSensor', 'Air Quality Index', 'mdi:format-list-numbers'), - ('MainPollutantSensor', 'Main Pollutant', 'mdi:chemical-weapon'), +SENSOR_TYPE_LEVEL = 'air_pollution_level' +SENSOR_TYPE_AQI = 'air_quality_index' +SENSOR_TYPE_POLLUTANT = 'main_pollutant' +SENSORS = [ + (SENSOR_TYPE_LEVEL, 'Air Pollution Level', 'mdi:scale', None), + (SENSOR_TYPE_AQI, 'Air Quality Index', 'mdi:format-list-numbers', 'AQI'), + (SENSOR_TYPE_POLLUTANT, 'Main Pollutant', 'mdi:chemical-weapon', None), ] -POLLUTANT_LEVEL_MAPPING = [ - {'label': 'Good', 'minimum': 0, 'maximum': 50}, - {'label': 'Moderate', 'minimum': 51, 'maximum': 100}, - {'label': 'Unhealthy for sensitive group', 'minimum': 101, 'maximum': 150}, - {'label': 'Unhealthy', 'minimum': 151, 'maximum': 200}, - {'label': 'Very Unhealthy', 'minimum': 201, 'maximum': 300}, - {'label': 'Hazardous', 'minimum': 301, 'maximum': 10000} -] +POLLUTANT_LEVEL_MAPPING = [{ + 'label': 'Good', + 'minimum': 0, + 'maximum': 50 +}, { + 'label': 'Moderate', + 'minimum': 51, + 'maximum': 100 +}, { + 'label': 'Unhealthy for sensitive group', + 'minimum': 101, + 'maximum': 150 +}, { + 'label': 'Unhealthy', + 'minimum': 151, + 'maximum': 200 +}, { + 'label': 'Very Unhealthy', + 'minimum': 201, + 'maximum': 300 +}, { + 'label': 'Hazardous', + 'minimum': 301, + 'maximum': 10000 +}] POLLUTANT_MAPPING = { - 'co': {'label': 'Carbon Monoxide', 'unit': MASS_PARTS_PER_MILLION}, - 'n2': {'label': 'Nitrogen Dioxide', 'unit': MASS_PARTS_PER_BILLION}, - 'o3': {'label': 'Ozone', 'unit': MASS_PARTS_PER_BILLION}, - 'p1': {'label': 'PM10', 'unit': VOLUME_MICROGRAMS_PER_CUBIC_METER}, - 'p2': {'label': 'PM2.5', 'unit': VOLUME_MICROGRAMS_PER_CUBIC_METER}, - 's2': {'label': 'Sulfur Dioxide', 'unit': MASS_PARTS_PER_BILLION}, + 'co': { + 'label': 'Carbon Monoxide', + 'unit': MASS_PARTS_PER_MILLION + }, + 'n2': { + 'label': 'Nitrogen Dioxide', + 'unit': MASS_PARTS_PER_BILLION + }, + 'o3': { + 'label': 'Ozone', + 'unit': MASS_PARTS_PER_BILLION + }, + 'p1': { + 'label': 'PM10', + 'unit': VOLUME_MICROGRAMS_PER_CUBIC_METER + }, + 'p2': { + 'label': 'PM2.5', + 'unit': VOLUME_MICROGRAMS_PER_CUBIC_METER + }, + 's2': { + 'label': 'Sulfur Dioxide', + 'unit': MASS_PARTS_PER_BILLION + }, } SENSOR_LOCALES = {'cn': 'Chinese', 'us': 'U.S.'} PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_MONITORED_CONDITIONS): + vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSOR_LOCALES)): vol.All(cv.ensure_list, [vol.In(SENSOR_LOCALES)]), - vol.Optional(CONF_CITY): cv.string, - vol.Optional(CONF_COUNTRY): cv.string, - vol.Optional(CONF_LATITUDE): cv.latitude, - vol.Optional(CONF_LONGITUDE): cv.longitude, - vol.Optional(CONF_RADIUS, default=1000): cv.positive_int, + vol.Inclusive(CONF_CITY, 'city'): cv.string, + vol.Inclusive(CONF_COUNTRY, 'city'): cv.string, + vol.Inclusive(CONF_LATITUDE, 'coords'): cv.latitude, + vol.Inclusive(CONF_LONGITUDE, 'coords'): cv.longitude, vol.Optional(CONF_SHOW_ON_MAP, default=True): cv.boolean, - vol.Optional(CONF_STATE): cv.string, + vol.Inclusive(CONF_STATE, 'city'): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): + cv.time_period }) -def setup_platform(hass, config, add_devices, discovery_info=None): +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): """Configure the platform and add the sensors.""" from pyairvisual import Client - classes = { - 'AirPollutionLevelSensor': AirPollutionLevelSensor, - 'AirQualityIndexSensor': AirQualityIndexSensor, - 'MainPollutantSensor': MainPollutantSensor - } - - api_key = config.get(CONF_API_KEY) - monitored_locales = config.get(CONF_MONITORED_CONDITIONS) - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - radius = config.get(CONF_RADIUS) city = config.get(CONF_CITY) state = config.get(CONF_STATE) country = config.get(CONF_COUNTRY) - show_on_map = config.get(CONF_SHOW_ON_MAP) + + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + + websession = aiohttp_client.async_get_clientsession(hass) if city and state and country: _LOGGER.debug( "Using city, state, and country: %s, %s, %s", city, state, country) location_id = ','.join((city, state, country)) data = AirVisualData( - Client(api_key), city=city, state=state, country=country, - show_on_map=show_on_map) + Client(config[CONF_API_KEY], websession), + city=city, + state=state, + country=country, + show_on_map=config[CONF_SHOW_ON_MAP], + scan_interval=config[CONF_SCAN_INTERVAL]) else: _LOGGER.debug( "Using latitude and longitude: %s, %s", latitude, longitude) location_id = ','.join((str(latitude), str(longitude))) data = AirVisualData( - Client(api_key), latitude=latitude, longitude=longitude, - radius=radius, show_on_map=show_on_map) + Client(config[CONF_API_KEY], websession), + latitude=latitude, + longitude=longitude, + show_on_map=config[CONF_SHOW_ON_MAP], + scan_interval=config[CONF_SCAN_INTERVAL]) - data.update() + await data.async_update() sensors = [] - for locale in monitored_locales: - for sensor_class, name, icon in SENSOR_TYPES: - sensors.append(classes[sensor_class]( - data, - name, - icon, - locale, - location_id - )) - - add_devices(sensors, True) - - -class AirVisualBaseSensor(Entity): - """Define a base class for all of our sensors.""" - - def __init__(self, data, name, icon, locale, entity_id): - """Initialize the sensor.""" - self.data = data - self._attrs = {} + for locale in config[CONF_MONITORED_CONDITIONS]: + for kind, name, icon, unit in SENSORS: + sensors.append( + AirVisualSensor( + data, kind, name, icon, unit, locale, location_id)) + + async_add_devices(sensors, True) + + +class AirVisualSensor(Entity): + """Define an AirVisual sensor.""" + + def __init__(self, airvisual, kind, name, icon, unit, locale, location_id): + """Initialize.""" + self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} self._icon = icon self._locale = locale + self._location_id = location_id self._name = name self._state = None - self._entity_id = entity_id - self._unit = None + self._type = kind + self._unit = unit + self.airvisual = airvisual @property def device_state_attributes(self): """Return the device state attributes.""" - self._attrs.update({ - ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - }) - - if self.data.show_on_map: - self._attrs[ATTR_LATITUDE] = self.data.latitude - self._attrs[ATTR_LONGITUDE] = self.data.longitude + if self.airvisual.show_on_map: + self._attrs[ATTR_LATITUDE] = self.airvisual.latitude + self._attrs[ATTR_LONGITUDE] = self.airvisual.longitude else: - self._attrs['lati'] = self.data.latitude - self._attrs['long'] = self.data.longitude + self._attrs['lati'] = self.airvisual.latitude + self._attrs['long'] = self.airvisual.longitude return self._attrs + @property + def available(self): + """Return True if entity is available.""" + return bool(self.airvisual.pollution_info) + @property def icon(self): """Return the icon.""" @@ -173,127 +211,83 @@ def state(self): """Return the state.""" return self._state - -class AirPollutionLevelSensor(AirVisualBaseSensor): - """Define a sensor to measure air pollution level.""" - @property def unique_id(self): """Return a unique, HASS-friendly identifier for this entity.""" - return '{0}_pollution_level'.format(self._entity_id) - - def update(self): - """Update the status of the sensor.""" - self.data.update() - - aqi = self.data.pollution_info.get('aqi{0}'.format(self._locale)) - try: - [level] = [ - i for i in POLLUTANT_LEVEL_MAPPING - if i['minimum'] <= aqi <= i['maximum'] - ] - self._state = level.get('label') - except TypeError: - self._state = None - except ValueError: - self._state = None - - -class AirQualityIndexSensor(AirVisualBaseSensor): - """Define a sensor to measure AQI.""" - - @property - def unique_id(self): - """Return a unique, HASS-friendly identifier for this entity.""" - return '{0}_aqi'.format(self._entity_id) + return '{0}_{1}_{2}'.format( + self._location_id, self._locale, self._type) @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" - return 'AQI' - - def update(self): - """Update the status of the sensor.""" - self.data.update() - - self._state = self.data.pollution_info.get( - 'aqi{0}'.format(self._locale)) - - -class MainPollutantSensor(AirVisualBaseSensor): - """Define a sensor to the main pollutant of an area.""" - - def __init__(self, data, name, icon, locale, entity_id): - """Initialize the sensor.""" - super().__init__(data, name, icon, locale, entity_id) - self._symbol = None - self._unit = None - - @property - def unique_id(self): - """Return a unique, HASS-friendly identifier for this entity.""" - return '{0}_main_pollutant'.format(self._entity_id) - - def update(self): - """Update the status of the sensor.""" - self.data.update() - - symbol = self.data.pollution_info.get('main{0}'.format(self._locale)) - pollution_info = POLLUTANT_MAPPING.get(symbol, {}) - self._state = pollution_info.get('label') - self._unit = pollution_info.get('unit') - self._symbol = symbol + return self._unit - self._attrs.update({ - ATTR_POLLUTANT_SYMBOL: self._symbol, - ATTR_POLLUTANT_UNIT: self._unit - }) + async def async_update(self): + """Update the sensor.""" + await self.airvisual.async_update() + data = self.airvisual.pollution_info + if not data: + return -class AirVisualData(object): + if self._type == SENSOR_TYPE_LEVEL: + aqi = data['aqi{0}'.format(self._locale)] + [level] = [ + i for i in POLLUTANT_LEVEL_MAPPING + if i['minimum'] <= aqi <= i['maximum'] + ] + self._state = level['label'] + elif self._type == SENSOR_TYPE_AQI: + self._state = data['aqi{0}'.format(self._locale)] + elif self._type == SENSOR_TYPE_POLLUTANT: + symbol = data['main{0}'.format(self._locale)] + self._state = POLLUTANT_MAPPING[symbol]['label'] + self._attrs.update({ + ATTR_POLLUTANT_SYMBOL: symbol, + ATTR_POLLUTANT_UNIT: POLLUTANT_MAPPING[symbol]['unit'] + }) + + +class AirVisualData: """Define an object to hold sensor data.""" def __init__(self, client, **kwargs): - """Initialize the AirVisual data element.""" + """Initialize.""" self._client = client - self.attrs = {} - self.pollution_info = None - self.city = kwargs.get(CONF_CITY) - self.state = kwargs.get(CONF_STATE) self.country = kwargs.get(CONF_COUNTRY) - self.latitude = kwargs.get(CONF_LATITUDE) self.longitude = kwargs.get(CONF_LONGITUDE) - self._radius = kwargs.get(CONF_RADIUS) - + self.pollution_info = {} self.show_on_map = kwargs.get(CONF_SHOW_ON_MAP) + self.state = kwargs.get(CONF_STATE) - @Throttle(MIN_TIME_BETWEEN_UPDATES) - def update(self): - """Update with new AirVisual data.""" - from pyairvisual.exceptions import HTTPError + self.async_update = Throttle( + kwargs[CONF_SCAN_INTERVAL])(self._async_update) + + async def _async_update(self): + """Update AirVisual data.""" + from pyairvisual.errors import AirVisualError try: if self.city and self.state and self.country: - resp = self._client.city( - self.city, self.state, self.country).get('data') - self.longitude, self.latitude = resp.get('location').get( - 'coordinates') + resp = await self._client.data.city( + self.city, self.state, self.country) + self.longitude, self.latitude = resp['location']['coordinates'] else: - resp = self._client.nearest_city( - self.latitude, self.longitude, self._radius).get('data') + resp = await self._client.data.nearest_city( + self.latitude, self.longitude) + _LOGGER.debug("New data retrieved: %s", resp) - self.pollution_info = resp.get('current', {}).get('pollution', {}) - - self.attrs = { - ATTR_CITY: resp.get('city'), - ATTR_REGION: resp.get('state'), - ATTR_COUNTRY: resp.get('country') - } - except HTTPError as exc_info: - _LOGGER.error("Unable to retrieve data on this location: %s", - self.__dict__) - _LOGGER.debug(exc_info) + self.pollution_info = resp['current']['pollution'] + except AirVisualError as err: + if self.city and self.state and self.country: + location = (self.city, self.state, self.country) + else: + location = (self.latitude, self.longitude) + + _LOGGER.error( + "Can't retrieve data for location: %s (%s)", location, + err) self.pollution_info = {} diff --git a/homeassistant/components/sensor/alpha_vantage.py b/homeassistant/components/sensor/alpha_vantage.py index 77d8ba9322f826..a7e6f6d26221bb 100644 --- a/homeassistant/components/sensor/alpha_vantage.py +++ b/homeassistant/components/sensor/alpha_vantage.py @@ -22,7 +22,6 @@ ATTR_CLOSE = 'close' ATTR_HIGH = 'high' ATTR_LOW = 'low' -ATTR_VOLUME = 'volume' CONF_ATTRIBUTION = "Stock market information provided by Alpha Vantage" CONF_FOREIGN_EXCHANGE = 'foreign_exchange' @@ -148,7 +147,6 @@ def device_state_attributes(self): ATTR_CLOSE: self.values['4. close'], ATTR_HIGH: self.values['2. high'], ATTR_LOW: self.values['3. low'], - ATTR_VOLUME: self.values['5. volume'], } @property diff --git a/homeassistant/components/sensor/api_streams.py b/homeassistant/components/sensor/api_streams.py index a8ef179280bbbc..0d193dee79bb88 100644 --- a/homeassistant/components/sensor/api_streams.py +++ b/homeassistant/components/sensor/api_streams.py @@ -38,9 +38,9 @@ def handle(self, record): else: if not record.msg.startswith('WS'): return - elif len(record.args) < 2: + if len(record.args) < 2: return - elif record.args[1] == 'Connected': + if record.args[1] == 'Connected': self.entity.count += 1 elif record.args[1] == 'Closed connection': self.entity.count -= 1 diff --git a/homeassistant/components/sensor/arduino.py b/homeassistant/components/sensor/arduino.py index f49d8e76f6c521..d4d8ea09d294ca 100644 --- a/homeassistant/components/sensor/arduino.py +++ b/homeassistant/components/sensor/arduino.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -import homeassistant.components.arduino as arduino +from homeassistant.components import arduino from homeassistant.const import CONF_NAME from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv diff --git a/homeassistant/components/sensor/arest.py b/homeassistant/components/sensor/arest.py index 19860ba84fd126..751f0f11171cdb 100644 --- a/homeassistant/components/sensor/arest.py +++ b/homeassistant/components/sensor/arest.py @@ -158,7 +158,7 @@ def available(self): return self.arest.available -class ArestData(object): +class ArestData: """The Class for handling the data retrieval for variables.""" def __init__(self, resource, pin=None): diff --git a/homeassistant/components/sensor/arlo.py b/homeassistant/components/sensor/arlo.py index 97b7ac2290940d..6d764b1c9164b2 100644 --- a/homeassistant/components/sensor/arlo.py +++ b/homeassistant/components/sensor/arlo.py @@ -4,17 +4,20 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.arlo/ """ -import asyncio import logging -from datetime import timedelta import voluptuous as vol +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.components.arlo import ( - CONF_ATTRIBUTION, DEFAULT_BRAND, DATA_ARLO) + CONF_ATTRIBUTION, DEFAULT_BRAND, DATA_ARLO, SIGNAL_UPDATE_ARLO) from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS) +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, + DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_HUMIDITY) + +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity from homeassistant.helpers.icon import icon_for_battery_level @@ -22,15 +25,16 @@ DEPENDENCIES = ['arlo'] -SCAN_INTERVAL = timedelta(seconds=90) - # sensor_type [ description, unit, icon ] SENSOR_TYPES = { 'last_capture': ['Last', None, 'run-fast'], 'total_cameras': ['Arlo Cameras', None, 'video'], 'captured_today': ['Captured Today', None, 'file-video'], 'battery_level': ['Battery Level', '%', 'battery-50'], - 'signal_strength': ['Signal Strength', None, 'signal'] + 'signal_strength': ['Signal Strength', None, 'signal'], + 'temperature': ['Temperature', TEMP_CELSIUS, 'thermometer'], + 'humidity': ['Humidity', '%', 'water-percent'], + 'air_quality': ['Air Quality', 'ppm', 'biohazard'] } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -39,35 +43,43 @@ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +def setup_platform(hass, config, add_devices, discovery_info=None): """Set up an Arlo IP sensor.""" arlo = hass.data.get(DATA_ARLO) if not arlo: - return False + return sensors = [] for sensor_type in config.get(CONF_MONITORED_CONDITIONS): if sensor_type == 'total_cameras': sensors.append(ArloSensor( - hass, SENSOR_TYPES[sensor_type][0], arlo, sensor_type)) + SENSOR_TYPES[sensor_type][0], arlo, sensor_type)) else: for camera in arlo.cameras: + if sensor_type in ('temperature', 'humidity', 'air_quality'): + continue + name = '{0} {1}'.format( SENSOR_TYPES[sensor_type][0], camera.name) - sensors.append(ArloSensor(hass, name, camera, sensor_type)) + sensors.append(ArloSensor(name, camera, sensor_type)) - async_add_devices(sensors, True) + for base_station in arlo.base_stations: + if sensor_type in ('temperature', 'humidity', 'air_quality') \ + and base_station.model_id == 'ABC1000': + name = '{0} {1}'.format( + SENSOR_TYPES[sensor_type][0], base_station.name) + sensors.append(ArloSensor(name, base_station, sensor_type)) + + add_devices(sensors, True) class ArloSensor(Entity): """An implementation of a Netgear Arlo IP sensor.""" - def __init__(self, hass, name, device, sensor_type): + def __init__(self, name, device, sensor_type): """Initialize an Arlo sensor.""" - super().__init__() + _LOGGER.debug('ArloSensor created for %s', name) self._name = name - self._hass = hass self._data = device self._sensor_type = sensor_type self._state = None @@ -78,6 +90,16 @@ def name(self): """Return the name of this camera.""" return self._name + async def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ARLO, self._update_callback) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) + @property def state(self): """Return the state of the sensor.""" @@ -96,20 +118,18 @@ def unit_of_measurement(self): """Return the units of measurement.""" return SENSOR_TYPES.get(self._sensor_type)[1] + @property + def device_class(self): + """Return the device class of the sensor.""" + if self._sensor_type == 'temperature': + return DEVICE_CLASS_TEMPERATURE + if self._sensor_type == 'humidity': + return DEVICE_CLASS_HUMIDITY + return None + def update(self): """Get the latest data and updates the state.""" - try: - base_station = self._data.base_station - except (AttributeError, IndexError): - return - - if not base_station: - return - - base_station.refresh_rate = SCAN_INTERVAL.total_seconds() - - self._data.update() - + _LOGGER.debug("Updating Arlo sensor %s", self.name) if self._sensor_type == 'total_cameras': self._state = len(self._data.cameras) @@ -118,9 +138,13 @@ def update(self): elif self._sensor_type == 'last_capture': try: - video = self._data.videos()[0] + video = self._data.last_video self._state = video.created_at_pretty("%m-%d-%Y %H:%M:%S") except (AttributeError, IndexError): + error_msg = \ + 'Video not found for {0}. Older than {1} days?'.format( + self.name, self._data.min_days_vdo_cache) + _LOGGER.debug(error_msg) self._state = None elif self._sensor_type == 'battery_level': @@ -135,6 +159,24 @@ def update(self): except TypeError: self._state = None + elif self._sensor_type == 'temperature': + try: + self._state = self._data.ambient_temperature + except TypeError: + self._state = None + + elif self._sensor_type == 'humidity': + try: + self._state = self._data.ambient_humidity + except TypeError: + self._state = None + + elif self._sensor_type == 'air_quality': + try: + self._state = self._data.ambient_air_quality + except TypeError: + self._state = None + @property def device_state_attributes(self): """Return the device state attributes.""" @@ -143,10 +185,7 @@ def device_state_attributes(self): attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION attrs['brand'] = DEFAULT_BRAND - if self._sensor_type == 'last_capture' or \ - self._sensor_type == 'captured_today' or \ - self._sensor_type == 'battery_level' or \ - self._sensor_type == 'signal_strength': + if self._sensor_type != 'total_cameras': attrs['model'] = self._data.model_id return attrs diff --git a/homeassistant/components/sensor/arwn.py b/homeassistant/components/sensor/arwn.py index 7308cd4f791c5b..6b0d3e569d78b0 100644 --- a/homeassistant/components/sensor/arwn.py +++ b/homeassistant/components/sensor/arwn.py @@ -8,7 +8,7 @@ import json import logging -import homeassistant.components.mqtt as mqtt +from homeassistant.components import mqtt from homeassistant.core import callback from homeassistant.const import TEMP_FAHRENHEIT, TEMP_CELSIUS from homeassistant.helpers.entity import Entity diff --git a/homeassistant/components/sensor/bbox.py b/homeassistant/components/sensor/bbox.py index 3689e94b05dc51..d24621becc9794 100644 --- a/homeassistant/components/sensor/bbox.py +++ b/homeassistant/components/sensor/bbox.py @@ -125,7 +125,7 @@ def update(self): 2) -class BboxData(object): +class BboxData: """Get data from the Bbox.""" def __init__(self): diff --git a/homeassistant/components/sensor/bitcoin.py b/homeassistant/components/sensor/bitcoin.py index 8bed72a67c28f3..f51b7dcd5bde40 100644 --- a/homeassistant/components/sensor/bitcoin.py +++ b/homeassistant/components/sensor/bitcoin.py @@ -15,7 +15,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['blockchain==1.4.0'] +REQUIREMENTS = ['blockchain==1.4.4'] _LOGGER = logging.getLogger(__name__) @@ -121,7 +121,6 @@ def update(self): stats = self.data.stats ticker = self.data.ticker - # pylint: disable=no-member if self.type == 'exchangerate': self._state = ticker[self._currency].p15min self._unit_of_measurement = self._currency @@ -170,7 +169,7 @@ def update(self): self._state = '{0:.2f}'.format(stats.market_price_usd) -class BitcoinData(object): +class BitcoinData: """Get the latest data and update the states.""" def __init__(self): diff --git a/homeassistant/components/sensor/bloomsky.py b/homeassistant/components/sensor/bloomsky.py index b460498c901e44..d33796d04ccc77 100644 --- a/homeassistant/components/sensor/bloomsky.py +++ b/homeassistant/components/sensor/bloomsky.py @@ -41,7 +41,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the available BloomSky weather sensors.""" bloomsky = hass.components.bloomsky diff --git a/homeassistant/components/sensor/bme280.py b/homeassistant/components/sensor/bme280.py index 8f3949046cae1e..1685d34c0ecf52 100644 --- a/homeassistant/components/sensor/bme280.py +++ b/homeassistant/components/sensor/bme280.py @@ -117,7 +117,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): except KeyError: pass - async_add_devices(dev) + async_add_devices(dev, True) class BME280Handler: diff --git a/homeassistant/components/sensor/bmw_connected_drive.py b/homeassistant/components/sensor/bmw_connected_drive.py index ed75520c1798f2..e3331cdc763cbd 100644 --- a/homeassistant/components/sensor/bmw_connected_drive.py +++ b/homeassistant/components/sensor/bmw_connected_drive.py @@ -9,22 +9,23 @@ from homeassistant.components.bmw_connected_drive import DOMAIN as BMW_DOMAIN from homeassistant.helpers.entity import Entity +from homeassistant.helpers.icon import icon_for_battery_level DEPENDENCIES = ['bmw_connected_drive'] _LOGGER = logging.getLogger(__name__) -LENGTH_ATTRIBUTES = { - 'remaining_range_fuel': ['Range (fuel)', 'mdi:ruler'], - 'mileage': ['Mileage', 'mdi:speedometer'] +ATTR_TO_HA = { + 'mileage': ['mdi:speedometer', 'km'], + 'remaining_range_total': ['mdi:ruler', 'km'], + 'remaining_range_electric': ['mdi:ruler', 'km'], + 'remaining_range_fuel': ['mdi:ruler', 'km'], + 'max_range_electric': ['mdi:ruler', 'km'], + 'remaining_fuel': ['mdi:gas-station', 'l'], + 'charging_time_remaining': ['mdi:update', 'h'], + 'charging_status': ['mdi:battery-charging', None] } -VALID_ATTRIBUTES = { - 'remaining_fuel': ['Remaining Fuel', 'mdi:gas-station'] -} - -VALID_ATTRIBUTES.update(LENGTH_ATTRIBUTES) - def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the BMW sensors.""" @@ -34,27 +35,26 @@ def setup_platform(hass, config, add_devices, discovery_info=None): devices = [] for account in accounts: for vehicle in account.account.vehicles: - for key, value in sorted(VALID_ATTRIBUTES.items()): - device = BMWConnectedDriveSensor(account, vehicle, key, - value[0], value[1]) + for attribute_name in vehicle.drive_train_attributes: + device = BMWConnectedDriveSensor(account, vehicle, + attribute_name) devices.append(device) + device = BMWConnectedDriveSensor(account, vehicle, 'mileage') + devices.append(device) add_devices(devices, True) class BMWConnectedDriveSensor(Entity): """Representation of a BMW vehicle sensor.""" - def __init__(self, account, vehicle, attribute: str, sensor_name, icon): + def __init__(self, account, vehicle, attribute: str): """Constructor.""" self._vehicle = vehicle self._account = account self._attribute = attribute self._state = None - self._unit_of_measurement = None self._name = '{} {}'.format(self._vehicle.name, self._attribute) self._unique_id = '{}-{}'.format(self._vehicle.vin, self._attribute) - self._sensor_name = sensor_name - self._icon = icon @property def should_poll(self) -> bool: @@ -74,7 +74,17 @@ def name(self) -> str: @property def icon(self): """Icon to use in the frontend, if any.""" - return self._icon + from bimmer_connected.state import ChargingState + vehicle_state = self._vehicle.state + charging_state = vehicle_state.charging_status in \ + [ChargingState.CHARGING] + + if self._attribute == 'charging_level_hv': + return icon_for_battery_level( + battery_level=vehicle_state.charging_level_hv, + charging=charging_state) + icon, _ = ATTR_TO_HA.get(self._attribute, [None, None]) + return icon @property def state(self): @@ -88,7 +98,8 @@ def state(self): @property def unit_of_measurement(self) -> str: """Get the unit of measurement.""" - return self._unit_of_measurement + _, unit = ATTR_TO_HA.get(self._attribute, [None, None]) + return unit @property def device_state_attributes(self): @@ -101,14 +112,10 @@ def update(self) -> None: """Read new state data from the library.""" _LOGGER.debug('Updating %s', self._vehicle.name) vehicle_state = self._vehicle.state - self._state = getattr(vehicle_state, self._attribute) - - if self._attribute in LENGTH_ATTRIBUTES: - self._unit_of_measurement = 'km' - elif self._attribute == 'remaining_fuel': - self._unit_of_measurement = 'l' + if self._attribute == 'charging_status': + self._state = getattr(vehicle_state, self._attribute).value else: - self._unit_of_measurement = None + self._state = getattr(vehicle_state, self._attribute) def update_callback(self): """Schedule a state update.""" diff --git a/homeassistant/components/sensor/bom.py b/homeassistant/components/sensor/bom.py index 128f532e459ae2..eb63e1162541ad 100644 --- a/homeassistant/components/sensor/bom.py +++ b/homeassistant/components/sensor/bom.py @@ -19,8 +19,8 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, STATE_UNKNOWN, CONF_NAME, - ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE) + CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, CONF_NAME, ATTR_ATTRIBUTION, + CONF_LATITUDE, CONF_LONGITUDE) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv @@ -28,6 +28,12 @@ _RESOURCE = 'http://www.bom.gov.au/fwo/{}/{}.{}.json' _LOGGER = logging.getLogger(__name__) +ATTR_LAST_UPDATE = 'last_update' +ATTR_SENSOR_ID = 'sensor_id' +ATTR_STATION_ID = 'station_id' +ATTR_STATION_NAME = 'station_name' +ATTR_ZONE_ID = 'zone_id' + CONF_ATTRIBUTION = "Data provided by the Australian Bureau of Meteorology" CONF_STATION = 'station' CONF_ZONE_ID = 'zone_id' @@ -35,7 +41,6 @@ MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(minutes=35) -# Sensor types are defined like: Name, units SENSOR_TYPES = { 'wmo': ['wmo', None], 'name': ['Station Name', None], @@ -70,7 +75,7 @@ 'weather': ['Weather', None], 'wind_dir': ['Wind Direction', None], 'wind_spd_kmh': ['Wind Speed kmh', 'km/h'], - 'wind_spd_kt': ['Wind Direction kt', 'kt'] + 'wind_spd_kt': ['Wind Speed kt', 'kt'] } @@ -98,6 +103,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the BOM sensor.""" station = config.get(CONF_STATION) zone_id, wmo_id = config.get(CONF_ZONE_ID), config.get(CONF_WMO_ID) + if station is not None: if zone_id and wmo_id: _LOGGER.warning( @@ -111,17 +117,18 @@ def setup_platform(hass, config, add_devices, discovery_info=None): hass.config.config_dir) if station is None: _LOGGER.error("Could not get BOM weather station from lat/lon") - return False + return bom_data = BOMCurrentData(hass, station) + try: bom_data.update() except ValueError as err: - _LOGGER.error("Received error from BOM_Current: %s", err) - return False + _LOGGER.error("Received error from BOM Current: %s", err) + return + add_devices([BOMCurrentSensor(bom_data, variable, config.get(CONF_NAME)) for variable in config[CONF_MONITORED_CONDITIONS]]) - return True class BOMCurrentSensor(Entity): @@ -145,22 +152,22 @@ def name(self): @property def state(self): """Return the state of the sensor.""" - if self.bom_data.data and self._condition in self.bom_data.data: - return self.bom_data.data[self._condition] - - return STATE_UNKNOWN + return self.bom_data.get_reading(self._condition) @property def device_state_attributes(self): """Return the state attributes of the device.""" - attr = {} - attr['Sensor Id'] = self._condition - attr['Zone Id'] = self.bom_data.data['history_product'] - attr['Station Id'] = self.bom_data.data['wmo'] - attr['Station Name'] = self.bom_data.data['name'] - attr['Last Update'] = datetime.datetime.strptime(str( - self.bom_data.data['local_date_time_full']), '%Y%m%d%H%M%S') - attr[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION + attr = { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_LAST_UPDATE: datetime.datetime.strptime( + str(self.bom_data.latest_data['local_date_time_full']), + '%Y%m%d%H%M%S'), + ATTR_SENSOR_ID: self._condition, + ATTR_STATION_ID: self.bom_data.latest_data['wmo'], + ATTR_STATION_NAME: self.bom_data.latest_data['name'], + ATTR_ZONE_ID: self.bom_data.latest_data['history_product'], + } + return attr @property @@ -173,29 +180,51 @@ def update(self): self.bom_data.update() -class BOMCurrentData(object): +class BOMCurrentData: """Get data from BOM.""" def __init__(self, hass, station_id): """Initialize the data object.""" self._hass = hass self._zone_id, self._wmo_id = station_id.split('.') - self.data = None + self._data = None def _build_url(self): + """Build the URL for the requests.""" url = _RESOURCE.format(self._zone_id, self._zone_id, self._wmo_id) - _LOGGER.info("BOM URL %s", url) + _LOGGER.debug("BOM URL: %s", url) return url + @property + def latest_data(self): + """Return the latest data object.""" + if self._data: + return self._data[0] + return None + + def get_reading(self, condition): + """Return the value for the given condition. + + BOM weather publishes condition readings for weather (and a few other + conditions) at intervals throughout the day. To avoid a `-` value in + the frontend for these conditions, we traverse the historical data + for the latest value that is not `-`. + + Iterators are used in this method to avoid iterating needlessly + iterating through the entire BOM provided dataset. + """ + condition_readings = (entry[condition] for entry in self._data) + return next((x for x in condition_readings if x != '-'), None) + @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data from BOM.""" try: result = requests.get(self._build_url(), timeout=10).json() - self.data = result['observations']['data'][0] + self._data = result['observations']['data'] except ValueError as err: _LOGGER.error("Check BOM %s", err.args) - self.data = None + self._data = None raise @@ -239,7 +268,7 @@ def _get_bom_stations(): def bom_stations(cache_dir): """Return {CONF_STATION: (lat, lon)} for all stations, for auto-config. - Results from internet requests are cached as compressed json, making + Results from internet requests are cached as compressed JSON, making subsequent calls very much faster. """ cache_file = os.path.join(cache_dir, '.bom-stations.json.gz') @@ -259,7 +288,7 @@ def closest_station(lat, lon, cache_dir): stations = bom_stations(cache_dir) def comparable_dist(wmo_id): - """Create a psudeo-distance from lat/lon.""" + """Create a psudeo-distance from latitude/longitude.""" station_lat, station_lon = stations[wmo_id] return (lat - station_lat) ** 2 + (lon - station_lon) ** 2 diff --git a/homeassistant/components/sensor/broadlink.py b/homeassistant/components/sensor/broadlink.py index 9376687cf131fa..06d7f512c9feb1 100644 --- a/homeassistant/components/sensor/broadlink.py +++ b/homeassistant/components/sensor/broadlink.py @@ -47,7 +47,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Broadlink device sensors.""" host = config.get(CONF_HOST) @@ -97,7 +96,7 @@ def update(self): self._state = self._broadlink_data.data[self._type] -class BroadlinkData(object): +class BroadlinkData: """Representation of a Broadlink data object.""" def __init__(self, interval, ip_addr, mac_addr, timeout): diff --git a/homeassistant/components/sensor/buienradar.py b/homeassistant/components/sensor/buienradar.py index 6eb67f7cbd862e..992c27bbe2ea91 100644 --- a/homeassistant/components/sensor/buienradar.py +++ b/homeassistant/components/sensor/buienradar.py @@ -5,7 +5,7 @@ https://home-assistant.io/components/sensor.buienradar/ """ import asyncio -from datetime import timedelta +from datetime import datetime, timedelta import logging import async_timeout @@ -197,7 +197,7 @@ def __init__(self, sensor_type, client_name, coordinates): def uid(self, coordinates): """Generate a unique id using coordinates and sensor type.""" - # The combination of the location, name an sensor type is unique + # The combination of the location, name and sensor type is unique return "%2.6f%2.6f%s" % (coordinates[CONF_LATITUDE], coordinates[CONF_LONGITUDE], self.type) @@ -262,13 +262,13 @@ def load_data(self, data): self._entity_picture = img return True return False - else: - try: - self._state = data.get(FORECAST)[fcday].get(self.type[:-3]) - return True - except IndexError: - _LOGGER.warning("No forecast for fcday=%s...", fcday) - return False + + try: + self._state = data.get(FORECAST)[fcday].get(self.type[:-3]) + return True + except IndexError: + _LOGGER.warning("No forecast for fcday=%s...", fcday) + return False if self.type == SYMBOL or self.type.startswith(CONDITION): # update weather symbol & status text @@ -287,7 +287,6 @@ def load_data(self, data): img = condition.get(IMAGE, None) - # pylint: disable=protected-access if new_state != self._state or img != self._entity_picture: self._state = new_state self._entity_picture = img @@ -299,12 +298,10 @@ def load_data(self, data): # update nested precipitation forecast sensors nested = data.get(PRECIPITATION_FORECAST) self._timeframe = nested.get(TIMEFRAME) - # pylint: disable=protected-access self._state = nested.get(self.type[len(PRECIPITATION_FORECAST)+1:]) return True # update all other sensors - # pylint: disable=protected-access self._state = data.get(self.type) return True @@ -329,7 +326,7 @@ def state(self): return self._state @property - def should_poll(self): # pylint: disable=no-self-use + def should_poll(self): """No polling needed.""" return False @@ -377,7 +374,7 @@ def force_update(self): return self._force_update -class BrData(object): +class BrData: """Get the latest data and updates the states.""" def __init__(self, hass, coordinates, timeframe, devices): @@ -484,9 +481,10 @@ def async_update(self, *_): _LOGGER.debug("Buienradar parsed data: %s", result) if result.get(SUCCESS) is not True: - _LOGGER.warning("Unable to parse data from Buienradar." - "(Msg: %s)", - result.get(MESSAGE),) + if int(datetime.now().strftime('%H')) > 0: + _LOGGER.warning("Unable to parse data from Buienradar." + "(Msg: %s)", + result.get(MESSAGE),) yield from self.schedule_update(SCHEDULE_NOK) return diff --git a/homeassistant/components/sensor/citybikes.py b/homeassistant/components/sensor/citybikes.py index b7635f729e22f2..c9a69923135ccc 100644 --- a/homeassistant/components/sensor/citybikes.py +++ b/homeassistant/components/sensor/citybikes.py @@ -4,32 +4,31 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.citybikes/ """ -import logging +import asyncio from datetime import timedelta +import logging -import asyncio import aiohttp import async_timeout import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.components.sensor import PLATFORM_SCHEMA, ENTITY_ID_FORMAT +from homeassistant.components.sensor import ENTITY_ID_FORMAT, PLATFORM_SCHEMA from homeassistant.const import ( - CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, - ATTR_ATTRIBUTION, ATTR_LOCATION, ATTR_LATITUDE, ATTR_LONGITUDE, - STATE_UNKNOWN, LENGTH_METERS, LENGTH_FEET, ATTR_ID) + ATTR_ATTRIBUTION, ATTR_ID, ATTR_LATITUDE, ATTR_LOCATION, ATTR_LONGITUDE, + ATTR_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_RADIUS, + LENGTH_FEET, LENGTH_METERS) from homeassistant.exceptions import PlatformNotReady -from homeassistant.helpers.event import async_track_time_interval -from homeassistant.helpers.entity import Entity, async_generate_entity_id from homeassistant.helpers.aiohttp_client import async_get_clientsession -from homeassistant.util import location, distance +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity, async_generate_entity_id +from homeassistant.helpers.event import async_track_time_interval +from homeassistant.util import distance, location _LOGGER = logging.getLogger(__name__) ATTR_EMPTY_SLOTS = 'empty_slots' ATTR_EXTRA = 'extra' ATTR_FREE_BIKES = 'free_bikes' -ATTR_NAME = 'name' ATTR_NETWORK = 'network' ATTR_NETWORKS_LIST = 'networks' ATTR_STATIONS_LIST = 'stations' @@ -126,7 +125,6 @@ def async_citybikes_request(hass, uri, schema): raise CityBikesRequestError -# pylint: disable=unused-argument @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): @@ -151,8 +149,7 @@ def async_setup_platform(hass, config, async_add_devices, network = CityBikesNetwork(hass, network_id) hass.data[PLATFORM][MONITORED_NETWORKS][network_id] = network hass.async_add_job(network.async_refresh) - async_track_time_interval(hass, network.async_refresh, - SCAN_INTERVAL) + async_track_time_interval(hass, network.async_refresh, SCAN_INTERVAL) else: network = hass.data[PLATFORM][MONITORED_NETWORKS][network_id] @@ -160,14 +157,14 @@ def async_setup_platform(hass, config, async_add_devices, devices = [] for station in network.stations: - dist = location.distance(latitude, longitude, - station[ATTR_LATITUDE], - station[ATTR_LONGITUDE]) + dist = location.distance( + latitude, longitude, station[ATTR_LATITUDE], + station[ATTR_LONGITUDE]) station_id = station[ATTR_ID] station_uid = str(station.get(ATTR_EXTRA, {}).get(ATTR_UID, '')) - if radius > dist or stations_list.intersection((station_id, - station_uid)): + if radius > dist or stations_list.intersection( + (station_id, station_uid)): devices.append(CityBikesStation(hass, network, station_id, name)) async_add_devices(devices, True) @@ -189,19 +186,14 @@ def get_closest_network_id(cls, hass, latitude, longitude): networks = yield from async_citybikes_request( hass, NETWORKS_URI, NETWORKS_RESPONSE_SCHEMA) cls.NETWORKS_LIST = networks[ATTR_NETWORKS_LIST] - networks_list = cls.NETWORKS_LIST - network = networks_list[0] - result = network[ATTR_ID] - minimum_dist = location.distance( - latitude, longitude, - network[ATTR_LOCATION][ATTR_LATITUDE], - network[ATTR_LOCATION][ATTR_LONGITUDE]) - for network in networks_list[1:]: + result = None + minimum_dist = None + for network in cls.NETWORKS_LIST: network_latitude = network[ATTR_LOCATION][ATTR_LATITUDE] network_longitude = network[ATTR_LOCATION][ATTR_LONGITUDE] - dist = location.distance(latitude, longitude, - network_latitude, network_longitude) - if dist < minimum_dist: + dist = location.distance( + latitude, longitude, network_latitude, network_longitude) + if minimum_dist is None or dist < minimum_dist: minimum_dist = dist result = network[ATTR_ID] @@ -246,13 +238,13 @@ def __init__(self, hass, network, station_id, base_name=''): uid = "_".join([network.network_id, base_name, station_id]) else: uid = "_".join([network.network_id, station_id]) - self.entity_id = async_generate_entity_id(ENTITY_ID_FORMAT, uid, - hass=hass) + self.entity_id = async_generate_entity_id( + ENTITY_ID_FORMAT, uid, hass=hass) @property def state(self): """Return the state of the sensor.""" - return self._station_data.get(ATTR_FREE_BIKES, STATE_UNKNOWN) + return self._station_data.get(ATTR_FREE_BIKES, None) @property def name(self): diff --git a/homeassistant/components/sensor/coinmarketcap.py b/homeassistant/components/sensor/coinmarketcap.py index f8ada07eec6608..c4f38b1be02d85 100644 --- a/homeassistant/components/sensor/coinmarketcap.py +++ b/homeassistant/components/sensor/coinmarketcap.py @@ -13,65 +13,78 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - ATTR_ATTRIBUTION, CONF_CURRENCY, CONF_DISPLAY_CURRENCY) + ATTR_ATTRIBUTION, CONF_DISPLAY_CURRENCY) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['coinmarketcap==4.2.1'] +REQUIREMENTS = ['coinmarketcap==5.0.3'] _LOGGER = logging.getLogger(__name__) -ATTR_24H_VOLUME = '24h_volume' +ATTR_VOLUME_24H = 'volume_24h' ATTR_AVAILABLE_SUPPLY = 'available_supply' +ATTR_CIRCULATING_SUPPLY = 'circulating_supply' ATTR_MARKET_CAP = 'market_cap' -ATTR_NAME = 'name' ATTR_PERCENT_CHANGE_24H = 'percent_change_24h' ATTR_PERCENT_CHANGE_7D = 'percent_change_7d' ATTR_PERCENT_CHANGE_1H = 'percent_change_1h' ATTR_PRICE = 'price' +ATTR_RANK = 'rank' ATTR_SYMBOL = 'symbol' ATTR_TOTAL_SUPPLY = 'total_supply' CONF_ATTRIBUTION = "Data provided by CoinMarketCap" +CONF_CURRENCY_ID = 'currency_id' +CONF_DISPLAY_CURRENCY_DECIMALS = 'display_currency_decimals' -DEFAULT_CURRENCY = 'bitcoin' +DEFAULT_CURRENCY_ID = 1 DEFAULT_DISPLAY_CURRENCY = 'USD' +DEFAULT_DISPLAY_CURRENCY_DECIMALS = 2 ICON = 'mdi:currency-usd' SCAN_INTERVAL = timedelta(minutes=15) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_CURRENCY, default=DEFAULT_CURRENCY): cv.string, + vol.Optional(CONF_CURRENCY_ID, default=DEFAULT_CURRENCY_ID): + cv.positive_int, vol.Optional(CONF_DISPLAY_CURRENCY, default=DEFAULT_DISPLAY_CURRENCY): cv.string, + vol.Optional(CONF_DISPLAY_CURRENCY_DECIMALS, + default=DEFAULT_DISPLAY_CURRENCY_DECIMALS): + vol.All(vol.Coerce(int), vol.Range(min=1)), }) def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the CoinMarketCap sensor.""" - currency = config.get(CONF_CURRENCY) - display_currency = config.get(CONF_DISPLAY_CURRENCY).lower() + currency_id = config.get(CONF_CURRENCY_ID) + display_currency = config.get(CONF_DISPLAY_CURRENCY).upper() + display_currency_decimals = config.get(CONF_DISPLAY_CURRENCY_DECIMALS) try: - CoinMarketCapData(currency, display_currency).update() + CoinMarketCapData(currency_id, display_currency).update() except HTTPError: - _LOGGER.warning("Currency %s or display currency %s is not available. " - "Using bitcoin and USD.", currency, display_currency) - currency = DEFAULT_CURRENCY + _LOGGER.warning("Currency ID %s or display currency %s " + "is not available. Using 1 (bitcoin) " + "and USD.", currency_id, display_currency) + currency_id = DEFAULT_CURRENCY_ID display_currency = DEFAULT_DISPLAY_CURRENCY add_devices([CoinMarketCapSensor( - CoinMarketCapData(currency, display_currency))], True) + CoinMarketCapData( + currency_id, display_currency), display_currency_decimals)], True) class CoinMarketCapSensor(Entity): """Representation of a CoinMarketCap sensor.""" - def __init__(self, data): + def __init__(self, data, display_currency_decimals): """Initialize the sensor.""" self.data = data + self.display_currency_decimals = display_currency_decimals self._ticker = None - self._unit_of_measurement = self.data.display_currency.upper() + self._unit_of_measurement = self.data.display_currency @property def name(self): @@ -81,8 +94,9 @@ def name(self): @property def state(self): """Return the state of the sensor.""" - return round(float(self._ticker.get( - 'price_{}'.format(self.data.display_currency))), 2) + return round(float( + self._ticker.get('quotes').get(self.data.display_currency) + .get('price')), self.display_currency_decimals) @property def unit_of_measurement(self): @@ -98,15 +112,24 @@ def icon(self): def device_state_attributes(self): """Return the state attributes of the sensor.""" return { - ATTR_24H_VOLUME: self._ticker.get( - '24h_volume_{}'.format(self.data.display_currency)), + ATTR_VOLUME_24H: + self._ticker.get('quotes').get(self.data.display_currency) + .get('volume_24h'), ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - ATTR_AVAILABLE_SUPPLY: self._ticker.get('available_supply'), - ATTR_MARKET_CAP: self._ticker.get( - 'market_cap_{}'.format(self.data.display_currency)), - ATTR_PERCENT_CHANGE_24H: self._ticker.get('percent_change_24h'), - ATTR_PERCENT_CHANGE_7D: self._ticker.get('percent_change_7d'), - ATTR_PERCENT_CHANGE_1H: self._ticker.get('percent_change_1h'), + ATTR_CIRCULATING_SUPPLY: self._ticker.get('circulating_supply'), + ATTR_MARKET_CAP: + self._ticker.get('quotes').get(self.data.display_currency) + .get('market_cap'), + ATTR_PERCENT_CHANGE_24H: + self._ticker.get('quotes').get(self.data.display_currency) + .get('percent_change_24h'), + ATTR_PERCENT_CHANGE_7D: + self._ticker.get('quotes').get(self.data.display_currency) + .get('percent_change_7d'), + ATTR_PERCENT_CHANGE_1H: + self._ticker.get('quotes').get(self.data.display_currency) + .get('percent_change_1h'), + ATTR_RANK: self._ticker.get('rank'), ATTR_SYMBOL: self._ticker.get('symbol'), ATTR_TOTAL_SUPPLY: self._ticker.get('total_supply'), } @@ -114,22 +137,20 @@ def device_state_attributes(self): def update(self): """Get the latest data and updates the states.""" self.data.update() - self._ticker = self.data.ticker[0] + self._ticker = self.data.ticker.get('data') -class CoinMarketCapData(object): +class CoinMarketCapData: """Get the latest data and update the states.""" - def __init__(self, currency, display_currency): + def __init__(self, currency_id, display_currency): """Initialize the data object.""" - self.currency = currency + self.currency_id = currency_id self.display_currency = display_currency self.ticker = None def update(self): - """Get the latest data from blockchain.info.""" + """Get the latest data from coinmarketcap.com.""" from coinmarketcap import Market self.ticker = Market().ticker( - self.currency, - limit=1, - convert=self.display_currency) + self.currency_id, convert=self.display_currency) diff --git a/homeassistant/components/sensor/comed_hourly_pricing.py b/homeassistant/components/sensor/comed_hourly_pricing.py index 01e9f443e0e1ed..c0c477ade0b926 100644 --- a/homeassistant/components/sensor/comed_hourly_pricing.py +++ b/homeassistant/components/sensor/comed_hourly_pricing.py @@ -4,19 +4,21 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.comed_hourly_pricing/ """ -from datetime import timedelta -import logging import asyncio +from datetime import timedelta import json -import async_timeout +import logging + import aiohttp +import async_timeout import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, STATE_UNKNOWN -from homeassistant.helpers.entity import Entity +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_NAME, CONF_OFFSET, STATE_UNKNOWN) from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) _RESOURCE = 'https://hourlypricing.comed.com/api' @@ -27,8 +29,6 @@ CONF_CURRENT_HOUR_AVERAGE = 'current_hour_average' CONF_FIVE_MINUTE = 'five_minute' CONF_MONITORED_FEEDS = 'monitored_feeds' -CONF_NAME = 'name' -CONF_OFFSET = 'offset' CONF_SENSOR_TYPE = 'type' SENSOR_TYPES = { @@ -40,12 +40,12 @@ SENSORS_SCHEMA = vol.Schema({ vol.Required(CONF_SENSOR_TYPE): TYPES_SCHEMA, + vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_OFFSET, default=0.0): vol.Coerce(float), - vol.Optional(CONF_NAME): cv.string }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_MONITORED_FEEDS): [SENSORS_SCHEMA] + vol.Required(CONF_MONITORED_FEEDS): [SENSORS_SCHEMA], }) diff --git a/homeassistant/components/sensor/command_line.py b/homeassistant/components/sensor/command_line.py index f326a57b137fd2..846604a9ff5884 100644 --- a/homeassistant/components/sensor/command_line.py +++ b/homeassistant/components/sensor/command_line.py @@ -4,58 +4,71 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.command_line/ """ +import collections +from datetime import timedelta +import json import logging -import subprocess import shlex - -from datetime import timedelta +import subprocess import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.helpers import template -from homeassistant.exceptions import TemplateError from homeassistant.const import ( - CONF_NAME, CONF_VALUE_TEMPLATE, CONF_UNIT_OF_MEASUREMENT, CONF_COMMAND, + CONF_COMMAND, CONF_NAME, CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, STATE_UNKNOWN) +from homeassistant.exceptions import TemplateError +from homeassistant.helpers import template +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) +CONF_COMMAND_TIMEOUT = 'command_timeout' +CONF_JSON_ATTRIBUTES = 'json_attributes' + DEFAULT_NAME = 'Command Sensor' +DEFAULT_TIMEOUT = 15 SCAN_INTERVAL = timedelta(seconds=60) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_COMMAND): cv.string, + vol.Optional(CONF_COMMAND_TIMEOUT, default=DEFAULT_TIMEOUT): + cv.positive_int, + vol.Optional(CONF_JSON_ATTRIBUTES): cv.ensure_list_csv, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_VALUE_TEMPLATE): cv.template, }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Command Sensor.""" name = config.get(CONF_NAME) command = config.get(CONF_COMMAND) unit = config.get(CONF_UNIT_OF_MEASUREMENT) value_template = config.get(CONF_VALUE_TEMPLATE) + command_timeout = config.get(CONF_COMMAND_TIMEOUT) if value_template is not None: value_template.hass = hass - data = CommandSensorData(hass, command) + json_attributes = config.get(CONF_JSON_ATTRIBUTES) + data = CommandSensorData(hass, command, command_timeout) - add_devices([CommandSensor(hass, data, name, unit, value_template)], True) + add_devices([CommandSensor( + hass, data, name, unit, value_template, json_attributes)], True) class CommandSensor(Entity): """Representation of a sensor that is using shell commands.""" - def __init__(self, hass, data, name, unit_of_measurement, value_template): + def __init__(self, hass, data, name, unit_of_measurement, value_template, + json_attributes): """Initialize the sensor.""" self._hass = hass self.data = data + self._attributes = None + self._json_attributes = json_attributes self._name = name self._state = None self._unit_of_measurement = unit_of_measurement @@ -76,11 +89,33 @@ def state(self): """Return the state of the device.""" return self._state + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attributes + def update(self): """Get the latest data and updates the state.""" self.data.update() value = self.data.value + if self._json_attributes: + self._attributes = {} + if value: + try: + json_dict = json.loads(value) + if isinstance(json_dict, collections.Mapping): + self._attributes = {k: json_dict[k] for k in + self._json_attributes + if k in json_dict} + else: + _LOGGER.warning("JSON result was not a dictionary") + except ValueError: + _LOGGER.warning( + "Unable to parse output as JSON: %s", value) + else: + _LOGGER.warning("Empty reply found when expecting JSON data") + if value is None: value = STATE_UNKNOWN elif self._value_template is not None: @@ -90,14 +125,15 @@ def update(self): self._state = value -class CommandSensorData(object): +class CommandSensorData: """The class for handling the data retrieval.""" - def __init__(self, hass, command): + def __init__(self, hass, command, command_timeout): """Initialize the data object.""" self.value = None self.hass = hass self.command = command + self.timeout = command_timeout def update(self): """Get the latest data with a shell command.""" @@ -136,7 +172,7 @@ def update(self): try: _LOGGER.info("Running command: %s", command) return_value = subprocess.check_output( - command, shell=shell, timeout=15) + command, shell=shell, timeout=self.timeout) self.value = return_value.strip().decode('utf-8') except subprocess.CalledProcessError: _LOGGER.error("Command failed: %s", command) diff --git a/homeassistant/components/sensor/cpuspeed.py b/homeassistant/components/sensor/cpuspeed.py index c39ae43aef0ad5..c6a7106663f699 100644 --- a/homeassistant/components/sensor/cpuspeed.py +++ b/homeassistant/components/sensor/cpuspeed.py @@ -30,7 +30,6 @@ }) -# pylint: disable=unused-variable def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the CPU speed sensor.""" name = config.get(CONF_NAME) diff --git a/homeassistant/components/sensor/crimereports.py b/homeassistant/components/sensor/crimereports.py index a2d7315a314cc5..adf7e3c0fa9719 100644 --- a/homeassistant/components/sensor/crimereports.py +++ b/homeassistant/components/sensor/crimereports.py @@ -41,7 +41,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Crime Reports platform.""" latitude = config.get(CONF_LATITUDE, hass.config.latitude) diff --git a/homeassistant/components/sensor/cups.py b/homeassistant/components/sensor/cups.py index 7c1d9fc3d49cd8..846b109afca9e5 100644 --- a/homeassistant/components/sensor/cups.py +++ b/homeassistant/components/sensor/cups.py @@ -128,8 +128,8 @@ def update(self): self._printer = self.data.printers.get(self._name) -# pylint: disable=import-error, no-name-in-module -class CupsData(object): +# pylint: disable=no-name-in-module +class CupsData: """Get the latest data from CUPS and update the state.""" def __init__(self, host, port): diff --git a/homeassistant/components/sensor/currencylayer.py b/homeassistant/components/sensor/currencylayer.py index f5d6f278da0b12..4a7face0156733 100644 --- a/homeassistant/components/sensor/currencylayer.py +++ b/homeassistant/components/sensor/currencylayer.py @@ -54,8 +54,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): sensors.append(CurrencylayerSensor(rest, base, variable)) if 'error' in response.json(): return False - else: - add_devices(sensors, True) + add_devices(sensors, True) class CurrencylayerSensor(Entity): @@ -104,7 +103,7 @@ def update(self): value['{}{}'.format(self._base, self._quote)], 4) -class CurrencylayerData(object): +class CurrencylayerData: """Get data from Currencylayer.org.""" def __init__(self, resource, parameters): diff --git a/homeassistant/components/sensor/daikin.py b/homeassistant/components/sensor/daikin.py index e045043e09c7a3..2da5cb5cdf040e 100644 --- a/homeassistant/components/sensor/daikin.py +++ b/homeassistant/components/sensor/daikin.py @@ -85,7 +85,7 @@ def get(self, key): if value is None: _LOGGER.warning("Invalid value requested for key %s", key) else: - if value == "-" or value == "--": + if value in ("-", "--"): value = None elif cast_to_float: try: diff --git a/homeassistant/components/sensor/darksky.py b/homeassistant/components/sensor/darksky.py index ac09de9c699e2a..b2bb7bb4da2354 100644 --- a/homeassistant/components/sensor/darksky.py +++ b/homeassistant/components/sensor/darksky.py @@ -33,6 +33,11 @@ DEFAULT_NAME = 'Dark Sky' +DEPRECATED_SENSOR_TYPES = {'apparent_temperature_max', + 'apparent_temperature_min', + 'temperature_max', + 'temperature_min'} + # Sensor types are defined like so: # Name, si unit, us unit, ca unit, uk unit, uk2 unit SENSOR_TYPES = { @@ -90,16 +95,28 @@ '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', ['currently', 'hourly', 'daily']], + 'apparent_temperature_high': ["Daytime High Apparent Temperature", + '°C', '°F', '°C', '°C', '°C', + 'mdi:thermometer', ['daily']], 'apparent_temperature_min': ['Daily Low Apparent Temperature', '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', ['currently', 'hourly', 'daily']], + 'apparent_temperature_low': ['Overnight Low Apparent Temperature', + '°C', '°F', '°C', '°C', '°C', + 'mdi:thermometer', ['daily']], 'temperature_max': ['Daily High Temperature', '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', - ['currently', 'hourly', 'daily']], + ['daily']], + 'temperature_high': ['Daytime High Temperature', + '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', + ['daily']], 'temperature_min': ['Daily Low Temperature', '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', - ['currently', 'hourly', 'daily']], + ['daily']], + 'temperature_low': ['Overnight Low Temperature', + '°C', '°F', '°C', '°C', '°C', 'mdi:thermometer', + ['daily']], 'precip_intensity_max': ['Daily Max Precip Intensity', 'mm/h', 'in', 'mm/h', 'mm/h', 'mm/h', 'mdi:thermometer', @@ -185,6 +202,9 @@ def setup_platform(hass, config, add_devices, discovery_info=None): forecast = config.get(CONF_FORECAST) sensors = [] for variable in config[CONF_MONITORED_CONDITIONS]: + if variable in DEPRECATED_SENSOR_TYPES: + _LOGGER.warning("Monitored condition %s is deprecated.", + variable) sensors.append(DarkSkySensor(forecast_data, variable, name)) if forecast is not None and 'daily' in SENSOR_TYPES[variable][7]: for forecast_day in forecast: @@ -288,9 +308,13 @@ def update(self): elif self.forecast_day > 0 or ( self.type in ['daily_summary', 'temperature_min', + 'temperature_low', 'temperature_max', + 'temperature_high', 'apparent_temperature_min', + 'apparent_temperature_low', 'apparent_temperature_max', + 'apparent_temperature_high', 'precip_intensity_max', 'precip_accumulation']): self.forecast_data.update_daily() @@ -328,12 +352,12 @@ def get_state(self, data): # percentages if self.type in ['precip_probability', 'cloud_cover', 'humidity']: return round(state * 100, 1) - elif (self.type in ['dew_point', 'temperature', 'apparent_temperature', - 'temperature_min', 'temperature_max', - 'apparent_temperature_min', - 'apparent_temperature_max', - 'precip_accumulation', - 'pressure', 'ozone', 'uvIndex']): + if self.type in ['dew_point', 'temperature', 'apparent_temperature', + 'temperature_min', 'temperature_max', + 'apparent_temperature_min', + 'apparent_temperature_max', + 'precip_accumulation', + 'pressure', 'ozone', 'uvIndex']: return round(state, 1) return state @@ -348,7 +372,7 @@ def convert_to_camel(data): return components[0] + "".join(x.title() for x in components[1:]) -class DarkSkyData(object): +class DarkSkyData: """Get the latest data from Darksky.""" def __init__(self, api_key, latitude, longitude, units, language, diff --git a/homeassistant/components/sensor/deconz.py b/homeassistant/components/sensor/deconz.py index 221cdf2129e823..7c492fd496d263 100644 --- a/homeassistant/components/sensor/deconz.py +++ b/homeassistant/components/sensor/deconz.py @@ -4,8 +4,9 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/sensor.deconz/ """ -from homeassistant.components.deconz import ( - DOMAIN as DATA_DECONZ, DATA_DECONZ_ID, DATA_DECONZ_UNSUB) +from homeassistant.components.deconz.const import ( + ATTR_DARK, ATTR_ON, CONF_ALLOW_CLIP_SENSOR, DOMAIN as DATA_DECONZ, + DATA_DECONZ_ID, DATA_DECONZ_UNSUB) from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_VOLTAGE, DEVICE_CLASS_BATTERY) from homeassistant.core import callback @@ -33,14 +34,17 @@ def async_add_sensor(sensors): """Add sensors from deCONZ.""" from pydeconz.sensor import DECONZ_SENSOR, SWITCH as DECONZ_REMOTE entities = [] + allow_clip_sensor = config_entry.data.get(CONF_ALLOW_CLIP_SENSOR, True) for sensor in sensors: - if sensor.type in DECONZ_SENSOR: + if sensor.type in DECONZ_SENSOR and \ + not (not allow_clip_sensor and sensor.type.startswith('CLIP')): if sensor.type in DECONZ_REMOTE: if sensor.battery: entities.append(DeconzBattery(sensor)) else: entities.append(DeconzSensor(sensor)) async_add_devices(entities, True) + hass.data[DATA_DECONZ_UNSUB].append( async_dispatcher_connect(hass, 'deconz_new_sensor', async_add_sensor)) @@ -68,7 +72,8 @@ def async_update_callback(self, reason): """ if reason['state'] or \ 'reachable' in reason['attr'] or \ - 'battery' in reason['attr']: + 'battery' in reason['attr'] or \ + 'on' in reason['attr']: self.async_schedule_update_ha_state() @property @@ -114,9 +119,14 @@ def should_poll(self): @property def device_state_attributes(self): """Return the state attributes of the sensor.""" + from pydeconz.sensor import LIGHTLEVEL attr = {} if self._sensor.battery: attr[ATTR_BATTERY_LEVEL] = self._sensor.battery + if self._sensor.on is not None: + attr[ATTR_ON] = self._sensor.on + if self._sensor.type in LIGHTLEVEL and self._sensor.dark is not None: + attr[ATTR_DARK] = self._sensor.dark if self.unit_of_measurement == 'Watts': attr[ATTR_CURRENT] = self._sensor.current attr[ATTR_VOLTAGE] = self._sensor.voltage diff --git a/homeassistant/components/sensor/deluge.py b/homeassistant/components/sensor/deluge.py index 8acbda74d7d461..b9109f6428c1df 100644 --- a/homeassistant/components/sensor/deluge.py +++ b/homeassistant/components/sensor/deluge.py @@ -42,7 +42,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Deluge sensors.""" from deluge_client import DelugeRPCClient diff --git a/homeassistant/components/sensor/demo.py b/homeassistant/components/sensor/demo.py index 325d3e0ae58050..15cc0ec46aebe4 100644 --- a/homeassistant/components/sensor/demo.py +++ b/homeassistant/components/sensor/demo.py @@ -10,7 +10,6 @@ from homeassistant.helpers.entity import Entity -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Demo sensors.""" add_devices([ diff --git a/homeassistant/components/sensor/deutsche_bahn.py b/homeassistant/components/sensor/deutsche_bahn.py index ec9b14883a9d44..0e6ab164d4f73a 100644 --- a/homeassistant/components/sensor/deutsche_bahn.py +++ b/homeassistant/components/sensor/deutsche_bahn.py @@ -85,7 +85,7 @@ def update(self): self._state += " + {}".format(self.data.connections[0]['delay']) -class SchieneData(object): +class SchieneData: """Pull data from the bahn.de web page.""" def __init__(self, start, goal, only_direct): diff --git a/homeassistant/components/sensor/dht.py b/homeassistant/components/sensor/dht.py index cbf06783dc752c..e3aaf2f84840fd 100644 --- a/homeassistant/components/sensor/dht.py +++ b/homeassistant/components/sensor/dht.py @@ -17,10 +17,7 @@ from homeassistant.util import Throttle from homeassistant.util.temperature import celsius_to_fahrenheit -# Update this requirement to upstream as soon as it supports Python 3. -REQUIREMENTS = ['https://github.com/adafruit/Adafruit_Python_DHT/archive/' - 'da8cddf7fb629c1ef4f046ca44f42523c9cf2d11.zip' - '#Adafruit_DHT==1.3.2'] +REQUIREMENTS = ['Adafruit-DHT==1.3.3'] _LOGGER = logging.getLogger(__name__) @@ -131,7 +128,7 @@ def update(self): temperature = data[SENSOR_TEMPERATURE] _LOGGER.debug("Temperature %.1f \u00b0C + offset %.1f", temperature, temperature_offset) - if (temperature >= -20) and (temperature < 80): + if -20 <= temperature < 80: self._state = round(temperature + temperature_offset, 1) if self.temp_unit == TEMP_FAHRENHEIT: self._state = round(celsius_to_fahrenheit(temperature), 1) @@ -139,11 +136,11 @@ def update(self): humidity = data[SENSOR_HUMIDITY] _LOGGER.debug("Humidity %.1f%% + offset %.1f", humidity, humidity_offset) - if (humidity >= 0) and (humidity <= 100): + if 0 <= humidity <= 100: self._state = round(humidity + humidity_offset, 1) -class DHTClient(object): +class DHTClient: """Get the latest data from the DHT sensor.""" def __init__(self, adafruit_dht, sensor, pin): diff --git a/homeassistant/components/sensor/dovado.py b/homeassistant/components/sensor/dovado.py index ee2292d412215e..2a78d4ad864887 100644 --- a/homeassistant/components/sensor/dovado.py +++ b/homeassistant/components/sensor/dovado.py @@ -129,17 +129,16 @@ def _compute_state(self): if self._sensor == SENSOR_NETWORK: match = re.search(r"\((.+)\)", state) return match.group(1) if match else None - elif self._sensor == SENSOR_SIGNAL: + if self._sensor == SENSOR_SIGNAL: try: return int(state.split()[0]) except ValueError: return 0 - elif self._sensor == SENSOR_SMS_UNREAD: + if self._sensor == SENSOR_SMS_UNREAD: return int(state) - elif self._sensor in [SENSOR_UPLOAD, SENSOR_DOWNLOAD]: + if self._sensor in [SENSOR_UPLOAD, SENSOR_DOWNLOAD]: return round(float(state) / 1e6, 1) - else: - return state + return state def update(self): """Update sensor values.""" diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index d7982f1c9dba65..3a1bf1da39e1a5 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -255,7 +255,7 @@ def icon(self): return ICON_POWER_FAILURE if 'Power' in self._name: return ICON_POWER - elif 'Gas' in self._name: + if 'Gas' in self._name: return ICON_GAS @property @@ -285,7 +285,7 @@ def translate_tariff(value): # used for normal rate. if value == '0002': return 'normal' - elif value == '0001': + if value == '0001': return 'low' return STATE_UNKNOWN diff --git a/homeassistant/components/sensor/dublin_bus_transport.py b/homeassistant/components/sensor/dublin_bus_transport.py index f6d791f9fd623c..a443c78b2b1725 100644 --- a/homeassistant/components/sensor/dublin_bus_transport.py +++ b/homeassistant/components/sensor/dublin_bus_transport.py @@ -125,7 +125,7 @@ def update(self): pass -class PublicTransportData(object): +class PublicTransportData: """The Class for handling the data retrieval.""" def __init__(self, stop, route): diff --git a/homeassistant/components/sensor/duke_energy.py b/homeassistant/components/sensor/duke_energy.py new file mode 100644 index 00000000000000..458a2929d0b6b3 --- /dev/null +++ b/homeassistant/components/sensor/duke_energy.py @@ -0,0 +1,84 @@ +""" +Support for Duke Energy Gas and Electric meters. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/sensor.duke_energy/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_USERNAME, CONF_PASSWORD +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['pydukeenergy==0.0.6'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, +}) + +LAST_BILL_USAGE = "last_bills_usage" +LAST_BILL_AVERAGE_USAGE = "last_bills_average_usage" +LAST_BILL_DAYS_BILLED = "last_bills_days_billed" + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup all Duke Energy meters.""" + from pydukeenergy.api import DukeEnergy, DukeEnergyException + + try: + duke = DukeEnergy(config[CONF_USERNAME], + config[CONF_PASSWORD], + update_interval=120) + except DukeEnergyException: + _LOGGER.error("Failed to setup Duke Energy") + return + + add_devices([DukeEnergyMeter(meter) for meter in duke.get_meters()]) + + +class DukeEnergyMeter(Entity): + """Representation of a Duke Energy meter.""" + + def __init__(self, meter): + """Initialize the meter.""" + self.duke_meter = meter + + @property + def name(self): + """Return the name.""" + return "duke_energy_{}".format(self.duke_meter.id) + + @property + def unique_id(self): + """Return the unique ID.""" + return self.duke_meter.id + + @property + def state(self): + """Return yesterdays usage.""" + return self.duke_meter.get_usage() + + @property + def unit_of_measurement(self): + """Return the unit of measurement this sensor expresses itself in.""" + return self.duke_meter.get_unit() + + @property + def device_state_attributes(self): + """Return the state attributes.""" + attributes = { + LAST_BILL_USAGE: self.duke_meter.get_total(), + LAST_BILL_AVERAGE_USAGE: self.duke_meter.get_average(), + LAST_BILL_DAYS_BILLED: self.duke_meter.get_days_billed() + } + return attributes + + def update(self): + """Update meter.""" + self.duke_meter.update() diff --git a/homeassistant/components/sensor/dwd_weather_warnings.py b/homeassistant/components/sensor/dwd_weather_warnings.py index 9105e30eb422d1..4f9664617a31d2 100644 --- a/homeassistant/components/sensor/dwd_weather_warnings.py +++ b/homeassistant/components/sensor/dwd_weather_warnings.py @@ -95,7 +95,6 @@ def unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._var_units - # pylint: disable=no-member @property def state(self): """Return the state of the device.""" @@ -104,7 +103,6 @@ def state(self): except TypeError: return self._api.data[self._var_id] - # pylint: disable=no-member @property def device_state_attributes(self): """Return the state attributes of the DWD-Weather-Warnings.""" @@ -165,7 +163,7 @@ def update(self): self._api.update() -class DwdWeatherWarningsAPI(object): +class DwdWeatherWarningsAPI: """Get the latest data and update the states.""" def __init__(self, region_name): diff --git a/homeassistant/components/sensor/dweet.py b/homeassistant/components/sensor/dweet.py index 157f366c0c40c1..065c88d8332b45 100644 --- a/homeassistant/components/sensor/dweet.py +++ b/homeassistant/components/sensor/dweet.py @@ -34,7 +34,6 @@ }) -# pylint: disable=unused-variable, too-many-function-args def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Dweet sensor.""" import dweepy @@ -100,7 +99,7 @@ def update(self): values, STATE_UNKNOWN) -class DweetData(object): +class DweetData: """The class for handling the data retrieval.""" def __init__(self, device): diff --git a/homeassistant/components/sensor/ebox.py b/homeassistant/components/sensor/ebox.py index aca2d7bdb9aa5d..218968ecee8c09 100644 --- a/homeassistant/components/sensor/ebox.py +++ b/homeassistant/components/sensor/ebox.py @@ -9,7 +9,6 @@ import logging from datetime import timedelta -import requests import voluptuous as vol import homeassistant.helpers.config_validation as cv @@ -18,9 +17,11 @@ CONF_USERNAME, CONF_PASSWORD, CONF_NAME, CONF_MONITORED_VARIABLES) from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle +from homeassistant.exceptions import PlatformNotReady -# pylint: disable=import-error -REQUIREMENTS = [] # ['pyebox==0.1.0'] - disabled because it breaks pip10 + +REQUIREMENTS = ['pyebox==1.1.4'] _LOGGER = logging.getLogger(__name__) @@ -32,7 +33,8 @@ DEFAULT_NAME = 'EBox' REQUESTS_TIMEOUT = 15 -SCAN_INTERVAL = timedelta(minutes=5) +SCAN_INTERVAL = timedelta(minutes=15) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) SENSOR_TYPES = { 'usage': ['Usage', PERCENT, 'mdi:percent'], @@ -62,25 +64,29 @@ }) -def setup_platform(hass, config, add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the EBox sensor.""" username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) - try: - ebox_data = EBoxData(username, password) - ebox_data.update() - except requests.exceptions.HTTPError as error: - _LOGGER.error("Failed login: %s", error) - return False + httpsession = hass.helpers.aiohttp_client.async_get_clientsession() + ebox_data = EBoxData(username, password, httpsession) name = config.get(CONF_NAME) + from pyebox.client import PyEboxError + try: + await ebox_data.async_update() + except PyEboxError as exp: + _LOGGER.error("Failed login: %s", exp) + raise PlatformNotReady + sensors = [] for variable in config[CONF_MONITORED_VARIABLES]: sensors.append(EBoxSensor(ebox_data, variable, name)) - add_devices(sensors, True) + async_add_devices(sensors, True) class EBoxSensor(Entity): @@ -116,28 +122,31 @@ def icon(self): """Icon to use in the frontend, if any.""" return self._icon - def update(self): + async def async_update(self): """Get the latest data from EBox and update the state.""" - self.ebox_data.update() + await self.ebox_data.async_update() if self.type in self.ebox_data.data: self._state = round(self.ebox_data.data[self.type], 2) -class EBoxData(object): +class EBoxData: """Get data from Ebox.""" - def __init__(self, username, password): + def __init__(self, username, password, httpsession): """Initialize the data object.""" from pyebox import EboxClient - self.client = EboxClient(username, password, REQUESTS_TIMEOUT) + self.client = EboxClient(username, password, + REQUESTS_TIMEOUT, httpsession) self.data = {} - def update(self): + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self): """Get the latest data from Ebox.""" from pyebox.client import PyEboxError try: - self.client.fetch_data() + await self.client.fetch_data() except PyEboxError as exp: _LOGGER.error("Error on receive last EBox data: %s", exp) return + # Update data self.data = self.client.get_data() diff --git a/homeassistant/components/sensor/eddystone_temperature.py b/homeassistant/components/sensor/eddystone_temperature.py index 2c8ad4781d003d..4c209d17d07d38 100644 --- a/homeassistant/components/sensor/eddystone_temperature.py +++ b/homeassistant/components/sensor/eddystone_temperature.py @@ -39,7 +39,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Validate configuration, create devices and start monitoring thread.""" bt_device_id = config.get("bt_device_id") @@ -121,7 +120,7 @@ def should_poll(self): return False -class Monitor(object): +class Monitor: """Continuously scan for BLE advertisements.""" def __init__(self, hass, devices, bt_device_id): diff --git a/homeassistant/components/sensor/efergy.py b/homeassistant/components/sensor/efergy.py index c14a33dce01736..b9fe294146315d 100644 --- a/homeassistant/components/sensor/efergy.py +++ b/homeassistant/components/sensor/efergy.py @@ -5,12 +5,13 @@ https://home-assistant.io/components/sensor.efergy/ """ import logging -import voluptuous as vol -from requests import RequestException, get +import requests +import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_CURRENCY +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) @@ -21,7 +22,6 @@ CONF_MONITORED_VARIABLES = 'monitored_variables' CONF_SENSOR_TYPE = 'type' -CONF_CURRENCY = 'currency' CONF_PERIOD = 'period' CONF_INSTANT = 'instant_readings' @@ -60,17 +60,18 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Efergy sensor.""" app_token = config.get(CONF_APPTOKEN) utc_offset = str(config.get(CONF_UTC_OFFSET)) + dev = [] for variable in config[CONF_MONITORED_VARIABLES]: if variable[CONF_SENSOR_TYPE] == CONF_CURRENT_VALUES: - url_string = _RESOURCE + 'getCurrentValuesSummary?token=' \ - + app_token - response = get(url_string, timeout=10) + url_string = '{}getCurrentValuesSummary?token={}'.format( + _RESOURCE, app_token) + response = requests.get(url_string, timeout=10) for sensor in response.json(): sid = sensor['sid'] - dev.append(EfergySensor(variable[CONF_SENSOR_TYPE], app_token, - utc_offset, variable[CONF_PERIOD], - variable[CONF_CURRENCY], sid)) + dev.append(EfergySensor( + variable[CONF_SENSOR_TYPE], app_token, utc_offset, + variable[CONF_PERIOD], variable[CONF_CURRENCY], sid)) dev.append(EfergySensor( variable[CONF_SENSOR_TYPE], app_token, utc_offset, variable[CONF_PERIOD], variable[CONF_CURRENCY])) @@ -86,7 +87,7 @@ def __init__(self, sensor_type, app_token, utc_offset, period, """Initialize the sensor.""" self.sid = sid if sid: - self._name = 'efergy_' + sid + self._name = 'efergy_{}'.format(sid) else: self._name = SENSOR_TYPES[sensor_type][0] self.type = sensor_type @@ -96,7 +97,8 @@ def __init__(self, sensor_type, app_token, utc_offset, period, self.period = period self.currency = currency if self.type == 'cost': - self._unit_of_measurement = self.currency + '/' + self.period + self._unit_of_measurement = '{}/{}'.format( + self.currency, self.period) else: self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] @@ -119,34 +121,34 @@ def update(self): """Get the Efergy monitor data from the web service.""" try: if self.type == 'instant_readings': - url_string = _RESOURCE + 'getInstant?token=' + self.app_token - response = get(url_string, timeout=10) + url_string = '{}getInstant?token={}'.format( + _RESOURCE, self.app_token) + response = requests.get(url_string, timeout=10) self._state = response.json()['reading'] elif self.type == 'amount': - url_string = _RESOURCE + 'getEnergy?token=' + self.app_token \ - + '&offset=' + self.utc_offset + '&period=' \ - + self.period - response = get(url_string, timeout=10) + url_string = '{}getEnergy?token={}&offset={}&period={}'.format( + _RESOURCE, self.app_token, self.utc_offset, self.period) + response = requests.get(url_string, timeout=10) self._state = response.json()['sum'] elif self.type == 'budget': - url_string = _RESOURCE + 'getBudget?token=' + self.app_token - response = get(url_string, timeout=10) + url_string = '{}getBudget?token={}'.format( + _RESOURCE, self.app_token) + response = requests.get(url_string, timeout=10) self._state = response.json()['status'] elif self.type == 'cost': - url_string = _RESOURCE + 'getCost?token=' + self.app_token \ - + '&offset=' + self.utc_offset + '&period=' \ - + self.period - response = get(url_string, timeout=10) + url_string = '{}getCost?token={}&offset={}&period={}'.format( + _RESOURCE, self.app_token, self.utc_offset, self.period) + response = requests.get(url_string, timeout=10) self._state = response.json()['sum'] elif self.type == 'current_values': - url_string = _RESOURCE + 'getCurrentValuesSummary?token=' \ - + self.app_token - response = get(url_string, timeout=10) + url_string = '{}getCurrentValuesSummary?token={}'.format( + _RESOURCE, self.app_token) + response = requests.get(url_string, timeout=10) for sensor in response.json(): if self.sid == sensor['sid']: measurement = next(iter(sensor['data'][0].values())) self._state = measurement else: - self._state = 'Unknown' - except (RequestException, ValueError, KeyError): + self._state = None + except (requests.RequestException, ValueError, KeyError): _LOGGER.warning("Could not update status for %s", self.name) diff --git a/homeassistant/components/sensor/eight_sleep.py b/homeassistant/components/sensor/eight_sleep.py index e0a42fdb6a8dea..5899ef267cb8ae 100644 --- a/homeassistant/components/sensor/eight_sleep.py +++ b/homeassistant/components/sensor/eight_sleep.py @@ -5,7 +5,6 @@ https://home-assistant.io/components/sensor.eight_sleep/ """ import logging -import asyncio from homeassistant.components.eight_sleep import ( DATA_EIGHT, EightSleepHeatEntity, EightSleepUserEntity, @@ -24,20 +23,20 @@ ATTR_SLEEP_DUR = 'Time Slept' ATTR_LIGHT_PERC = 'Light Sleep %' ATTR_DEEP_PERC = 'Deep Sleep %' +ATTR_REM_PERC = 'REM Sleep %' ATTR_TNT = 'Tosses & Turns' ATTR_SLEEP_STAGE = 'Sleep Stage' ATTR_TARGET_HEAT = 'Target Heating Level' ATTR_ACTIVE_HEAT = 'Heating Active' ATTR_DURATION_HEAT = 'Heating Time Remaining' -ATTR_LAST_SEEN = 'Last In Bed' ATTR_PROCESSING = 'Processing' ATTR_SESSION_START = 'Session Start' _LOGGER = logging.getLogger(__name__) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): """Set up the eight sleep sensors.""" if discovery_info is None: return @@ -98,8 +97,7 @@ def unit_of_measurement(self): """Return the unit the value is expressed in.""" return '%' - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Retrieve latest state.""" _LOGGER.debug("Updating Heat sensor: %s", self._sensor) self._state = self._usrobj.heating_level @@ -110,7 +108,6 @@ def device_state_attributes(self): state_attr = {ATTR_TARGET_HEAT: self._usrobj.target_heating_level} state_attr[ATTR_ACTIVE_HEAT] = self._usrobj.now_heating state_attr[ATTR_DURATION_HEAT] = self._usrobj.heating_remaining - state_attr[ATTR_LAST_SEEN] = self._usrobj.last_seen return state_attr @@ -152,7 +149,7 @@ def unit_of_measurement(self): """Return the unit the value is expressed in.""" if 'current_sleep' in self._sensor or 'last_sleep' in self._sensor: return 'Score' - elif 'bed_temp' in self._sensor: + if 'bed_temp' in self._sensor: if self._units == 'si': return '°C' return '°F' @@ -164,8 +161,7 @@ def icon(self): if 'bed_temp' in self._sensor: return 'mdi:thermometer' - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Retrieve latest state.""" _LOGGER.debug("Updating User sensor: %s", self._sensor) if 'current' in self._sensor: @@ -176,10 +172,13 @@ def async_update(self): self._attr = self._usrobj.last_values elif 'bed_temp' in self._sensor: temp = self._usrobj.current_values['bed_temp'] - if self._units == 'si': - self._state = round(temp, 2) - else: - self._state = round((temp*1.8)+32, 2) + try: + if self._units == 'si': + self._state = round(temp, 2) + else: + self._state = round((temp*1.8)+32, 2) + except TypeError: + self._state = None elif 'sleep_stage' in self._sensor: self._state = self._usrobj.current_values['stage'] @@ -208,12 +207,27 @@ def device_state_attributes(self): except ZeroDivisionError: state_attr[ATTR_DEEP_PERC] = 0 - if self._units == 'si': - room_temp = round(self._attr['room_temp'], 2) - bed_temp = round(self._attr['bed_temp'], 2) - else: - room_temp = round((self._attr['room_temp']*1.8)+32, 2) - bed_temp = round((self._attr['bed_temp']*1.8)+32, 2) + try: + state_attr[ATTR_REM_PERC] = round(( + self._attr['breakdown']['rem'] / sleep_time) * 100, 2) + except ZeroDivisionError: + state_attr[ATTR_REM_PERC] = 0 + + try: + if self._units == 'si': + room_temp = round(self._attr['room_temp'], 2) + else: + room_temp = round((self._attr['room_temp']*1.8)+32, 2) + except TypeError: + room_temp = None + + try: + if self._units == 'si': + bed_temp = round(self._attr['bed_temp'], 2) + else: + bed_temp = round((self._attr['bed_temp']*1.8)+32, 2) + except TypeError: + bed_temp = None if 'current' in self._sensor_root: state_attr[ATTR_RESP_RATE] = round(self._attr['resp_rate'], 2) @@ -255,15 +269,17 @@ def state(self): """Return the state of the sensor.""" return self._state - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Retrieve latest state.""" _LOGGER.debug("Updating Room sensor: %s", self._sensor) temp = self._eight.room_temperature() - if self._units == 'si': - self._state = round(temp, 2) - else: - self._state = round((temp*1.8)+32, 2) + try: + if self._units == 'si': + self._state = round(temp, 2) + else: + self._state = round((temp*1.8)+32, 2) + except TypeError: + self._state = None @property def unit_of_measurement(self): diff --git a/homeassistant/components/sensor/emoncms.py b/homeassistant/components/sensor/emoncms.py index cd02137f4d5334..a62eaba7df8459 100644 --- a/homeassistant/components/sensor/emoncms.py +++ b/homeassistant/components/sensor/emoncms.py @@ -190,7 +190,7 @@ def update(self): self._state = round(float(elem["value"]), DECIMALS) -class EmonCmsData(object): +class EmonCmsData: """The class for handling the data retrieval.""" def __init__(self, hass, url, apikey, interval): diff --git a/homeassistant/components/sensor/enphase_envoy.py b/homeassistant/components/sensor/enphase_envoy.py new file mode 100644 index 00000000000000..3c132fcf7df536 --- /dev/null +++ b/homeassistant/components/sensor/enphase_envoy.py @@ -0,0 +1,107 @@ +""" +Support for Enphase Envoy solar energy monitor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.enphase_envoy/ +""" +import logging + +import voluptuous as vol + +from homeassistant.helpers.entity import Entity +from homeassistant.components.sensor import PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (CONF_IP_ADDRESS, CONF_MONITORED_CONDITIONS) + + +REQUIREMENTS = ['envoy_reader==0.1'] +_LOGGER = logging.getLogger(__name__) + +SENSORS = { + "production": ("Envoy Current Energy Production", 'W'), + "daily_production": ("Envoy Today's Energy Production", "Wh"), + "7_days_production": ("Envoy Last Seven Days Energy Production", "Wh"), + "lifetime_production": ("Envoy Lifetime Energy Production", "Wh"), + "consumption": ("Envoy Current Energy Consumption", "W"), + "daily_consumption": ("Envoy Today's Energy Consumption", "Wh"), + "7_days_consumption": ("Envoy Last Seven Days Energy Consumption", "Wh"), + "lifetime_consumption": ("Envoy Lifetime Energy Consumption", "Wh") + } + + +ICON = 'mdi:flash' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_IP_ADDRESS): cv.string, + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): + vol.All(cv.ensure_list, [vol.In(list(SENSORS))])}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Enphase Envoy sensor.""" + ip_address = config[CONF_IP_ADDRESS] + monitored_conditions = config[CONF_MONITORED_CONDITIONS] + + # Iterate through the list of sensors + for condition in monitored_conditions: + add_devices([Envoy(ip_address, condition, SENSORS[condition][0], + SENSORS[condition][1])], True) + + +class Envoy(Entity): + """Implementation of the Enphase Envoy sensors.""" + + def __init__(self, ip_address, sensor_type, name, unit): + """Initialize the sensor.""" + self._ip_address = ip_address + self._name = name + self._unit_of_measurement = unit + self._type = sensor_type + self._state = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return ICON + + def update(self): + """Get the energy production data from the Enphase Envoy.""" + import envoy_reader + + if self._type == "production": + self._state = int(envoy_reader.production(self._ip_address)) + elif self._type == "daily_production": + self._state = int(envoy_reader.daily_production(self._ip_address)) + elif self._type == "7_days_production": + self._state = int(envoy_reader.seven_days_production( + self._ip_address)) + elif self._type == "lifetime_production": + self._state = int(envoy_reader.lifetime_production( + self._ip_address)) + + elif self._type == "consumption": + self._state = int(envoy_reader.consumption(self._ip_address)) + elif self._type == "daily_consumption": + self._state = int(envoy_reader.daily_consumption( + self._ip_address)) + elif self._type == "7_days_consumption": + self._state = int(envoy_reader.seven_days_consumption( + self._ip_address)) + elif self._type == "lifetime_consumption": + self._state = int(envoy_reader.lifetime_consumption( + self._ip_address)) diff --git a/homeassistant/components/sensor/envirophat.py b/homeassistant/components/sensor/envirophat.py index b11dae8e1682e7..bf4ee55c446ba4 100644 --- a/homeassistant/components/sensor/envirophat.py +++ b/homeassistant/components/sensor/envirophat.py @@ -55,7 +55,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Sense HAT sensor platform.""" try: - # pylint: disable=import-error import envirophat except OSError: _LOGGER.error("No Enviro pHAT was found.") @@ -139,7 +138,7 @@ def update(self): self._state = self.data.voltage_3 -class EnvirophatData(object): +class EnvirophatData: """Get the latest data and update.""" def __init__(self, envirophat, use_leds): @@ -175,7 +174,6 @@ def update(self): self.light_red, self.light_green, self.light_blue = \ self.envirophat.light.rgb() if self.use_leds: - # pylint: disable=no-value-for-parameter self.envirophat.leds.off() # accelerometer readings in G diff --git a/homeassistant/components/sensor/fail2ban.py b/homeassistant/components/sensor/fail2ban.py index 87c301d34f5cd7..bf868d49201a08 100644 --- a/homeassistant/components/sensor/fail2ban.py +++ b/homeassistant/components/sensor/fail2ban.py @@ -112,7 +112,7 @@ def update(self): self.last_ban = 'None' -class BanLogParser(object): +class BanLogParser: """Class to parse fail2ban logs.""" def __init__(self, interval, log_file): diff --git a/homeassistant/components/sensor/fastdotcom.py b/homeassistant/components/sensor/fastdotcom.py index 9143ccaf23fcc4..65474cd4bf66d3 100644 --- a/homeassistant/components/sensor/fastdotcom.py +++ b/homeassistant/components/sensor/fastdotcom.py @@ -102,7 +102,7 @@ def icon(self): return ICON -class SpeedtestData(object): +class SpeedtestData: """Get the latest data from fast.com.""" def __init__(self, hass, config): diff --git a/homeassistant/components/sensor/fedex.py b/homeassistant/components/sensor/fedex.py index f86de1d865c964..991588f07f326c 100644 --- a/homeassistant/components/sensor/fedex.py +++ b/homeassistant/components/sensor/fedex.py @@ -41,7 +41,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Fedex platform.""" import fedexdeliverymanager diff --git a/homeassistant/components/sensor/fido.py b/homeassistant/components/sensor/fido.py index a2ee18b3659b42..4f724b5b851d6d 100644 --- a/homeassistant/components/sensor/fido.py +++ b/homeassistant/components/sensor/fido.py @@ -147,7 +147,7 @@ def async_update(self): self._state = round(self._state, 2) -class FidoData(object): +class FidoData: """Get data from Fido.""" def __init__(self, username, password, httpsession): diff --git a/homeassistant/components/sensor/filter.py b/homeassistant/components/sensor/filter.py index 9c05028b3944ff..15059b08a179be 100644 --- a/homeassistant/components/sensor/filter.py +++ b/homeassistant/components/sensor/filter.py @@ -23,11 +23,12 @@ from homeassistant.util.decorator import Registry from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_state_change -import homeassistant.components.history as history +from homeassistant.components import history import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) +FILTER_NAME_RANGE = 'range' FILTER_NAME_LOWPASS = 'lowpass' FILTER_NAME_OUTLIER = 'outlier' FILTER_NAME_THROTTLE = 'throttle' @@ -40,6 +41,8 @@ CONF_FILTER_PRECISION = 'precision' CONF_FILTER_RADIUS = 'radius' CONF_FILTER_TIME_CONSTANT = 'time_constant' +CONF_FILTER_LOWER_BOUND = 'lower_bound' +CONF_FILTER_UPPER_BOUND = 'upper_bound' CONF_TIME_SMA_TYPE = 'type' TIME_SMA_LAST = 'last' @@ -77,6 +80,12 @@ default=DEFAULT_FILTER_TIME_CONSTANT): vol.Coerce(int), }) +FILTER_RANGE_SCHEMA = FILTER_SCHEMA.extend({ + vol.Required(CONF_FILTER_NAME): FILTER_NAME_RANGE, + vol.Optional(CONF_FILTER_LOWER_BOUND): vol.Coerce(float), + vol.Optional(CONF_FILTER_UPPER_BOUND): vol.Coerce(float), +}) + FILTER_TIME_SMA_SCHEMA = FILTER_SCHEMA.extend({ vol.Required(CONF_FILTER_NAME): FILTER_NAME_TIME_SMA, vol.Optional(CONF_TIME_SMA_TYPE, @@ -100,7 +109,8 @@ [vol.Any(FILTER_OUTLIER_SCHEMA, FILTER_LOWPASS_SCHEMA, FILTER_TIME_SMA_SCHEMA, - FILTER_THROTTLE_SCHEMA)]) + FILTER_THROTTLE_SCHEMA, + FILTER_RANGE_SCHEMA)]) }) @@ -248,7 +258,7 @@ def device_state_attributes(self): return state_attr -class FilterState(object): +class FilterState: """State abstraction for filter usage.""" def __init__(self, state): @@ -273,7 +283,7 @@ def __repr__(self): return "{} : {}".format(self.timestamp, self.state) -class Filter(object): +class Filter: """Filter skeleton. Args: @@ -325,6 +335,49 @@ def filter_state(self, new_state): return new_state +@FILTERS.register(FILTER_NAME_RANGE) +class RangeFilter(Filter): + """Range filter. + + Determines if new state is in the range of upper_bound and lower_bound. + If not inside, lower or upper bound is returned instead. + + Args: + upper_bound (float): band upper bound + lower_bound (float): band lower bound + """ + + def __init__(self, entity, + lower_bound, upper_bound): + """Initialize Filter.""" + super().__init__(FILTER_NAME_RANGE, entity=entity) + self._lower_bound = lower_bound + self._upper_bound = upper_bound + self._stats_internal = Counter() + + def _filter_state(self, new_state): + """Implement the range filter.""" + if self._upper_bound and new_state.state > self._upper_bound: + + self._stats_internal['erasures_up'] += 1 + + _LOGGER.debug("Upper outlier nr. %s in %s: %s", + self._stats_internal['erasures_up'], + self._entity, new_state) + new_state.state = self._upper_bound + + elif self._lower_bound and new_state.state < self._lower_bound: + + self._stats_internal['erasures_low'] += 1 + + _LOGGER.debug("Lower outlier nr. %s in %s: %s", + self._stats_internal['erasures_low'], + self._entity, new_state) + new_state.state = self._lower_bound + + return new_state + + @FILTERS.register(FILTER_NAME_OUTLIER) class OutlierFilter(Filter): """BASIC outlier filter. diff --git a/homeassistant/components/sensor/fints.py b/homeassistant/components/sensor/fints.py new file mode 100644 index 00000000000000..ef064e842282e4 --- /dev/null +++ b/homeassistant/components/sensor/fints.py @@ -0,0 +1,284 @@ +""" +Read the balance of your bank accounts via FinTS. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.fints/ +""" + +from collections import namedtuple +from datetime import timedelta +import logging +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_USERNAME, CONF_PIN, CONF_URL, CONF_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['fints==0.2.1'] + +_LOGGER = logging.getLogger(__name__) + +SCAN_INTERVAL = timedelta(hours=4) + +ICON = 'mdi:currency-eur' + +BankCredentials = namedtuple('BankCredentials', 'blz login pin url') + +CONF_BIN = 'bank_identification_number' +CONF_ACCOUNTS = 'accounts' +CONF_HOLDINGS = 'holdings' +CONF_ACCOUNT = 'account' + +ATTR_ACCOUNT = CONF_ACCOUNT +ATTR_BANK = 'bank' +ATTR_ACCOUNT_TYPE = 'account_type' + +SCHEMA_ACCOUNTS = vol.Schema({ + vol.Required(CONF_ACCOUNT): cv.string, + vol.Optional(CONF_NAME, default=None): vol.Any(None, cv.string), +}) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_BIN): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PIN): cv.string, + vol.Required(CONF_URL): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_ACCOUNTS, default=[]): cv.ensure_list(SCHEMA_ACCOUNTS), + vol.Optional(CONF_HOLDINGS, default=[]): cv.ensure_list(SCHEMA_ACCOUNTS), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the sensors. + + Login to the bank and get a list of existing accounts. Create a + sensor for each account. + """ + credentials = BankCredentials(config[CONF_BIN], config[CONF_USERNAME], + config[CONF_PIN], config[CONF_URL]) + fints_name = config.get(CONF_NAME, config[CONF_BIN]) + + account_config = {acc[CONF_ACCOUNT]: acc[CONF_NAME] + for acc in config[CONF_ACCOUNTS]} + + holdings_config = {acc[CONF_ACCOUNT]: acc[CONF_NAME] + for acc in config[CONF_HOLDINGS]} + + client = FinTsClient(credentials, fints_name) + balance_accounts, holdings_accounts = client.detect_accounts() + accounts = [] + + for account in balance_accounts: + if config[CONF_ACCOUNTS] and account.iban not in account_config: + _LOGGER.info('skipping account %s for bank %s', + account.iban, fints_name) + continue + + account_name = account_config.get(account.iban) + if not account_name: + account_name = '{} - {}'.format(fints_name, account.iban) + accounts.append(FinTsAccount(client, account, account_name)) + _LOGGER.debug('Creating account %s for bank %s', + account.iban, fints_name) + + for account in holdings_accounts: + if config[CONF_HOLDINGS] and \ + account.accountnumber not in holdings_config: + _LOGGER.info('skipping holdings %s for bank %s', + account.accountnumber, fints_name) + continue + + account_name = holdings_config.get(account.accountnumber) + if not account_name: + account_name = '{} - {}'.format( + fints_name, account.accountnumber) + accounts.append(FinTsHoldingsAccount(client, account, account_name)) + _LOGGER.debug('Creating holdings %s for bank %s', + account.accountnumber, fints_name) + + add_devices(accounts, True) + + +class FinTsClient: + """Wrapper around the FinTS3PinTanClient. + + Use this class as Context Manager to get the FinTS3Client object. + """ + + def __init__(self, credentials: BankCredentials, name: str): + """Constructor for class FinTsClient.""" + self._credentials = credentials + self.name = name + + @property + def client(self): + """Get the client object. + + As the fints library is stateless, there is not benefit in caching + the client objects. If that ever changes, consider caching the client + object and also think about potential concurrency problems. + """ + from fints.client import FinTS3PinTanClient + return FinTS3PinTanClient( + self._credentials.blz, self._credentials.login, + self._credentials.pin, self._credentials.url) + + def detect_accounts(self): + """Identify the accounts of the bank.""" + from fints.dialog import FinTSDialogError + balance_accounts = [] + holdings_accounts = [] + for account in self.client.get_sepa_accounts(): + try: + self.client.get_balance(account) + balance_accounts.append(account) + except IndexError: + # account is not a balance account. + pass + except FinTSDialogError: + # account is not a balance account. + pass + try: + self.client.get_holdings(account) + holdings_accounts.append(account) + except FinTSDialogError: + # account is not a holdings account. + pass + + return balance_accounts, holdings_accounts + + +class FinTsAccount(Entity): + """Sensor for a FinTS balanc account. + + A balance account contains an amount of money (=balance). The amount may + also be negative. + """ + + def __init__(self, client: FinTsClient, account, name: str) -> None: + """Constructor for class FinTsAccount.""" + self._client = client # type: FinTsClient + self._account = account + self._name = name # type: str + self._balance = None # type: float + self._currency = None # type: str + + @property + def should_poll(self) -> bool: + """Data needs to be polled from the bank servers.""" + return True + + def update(self) -> None: + """Get the current balance and currency for the account.""" + bank = self._client.client + balance = bank.get_balance(self._account) + self._balance = balance.amount.amount + self._currency = balance.amount.currency + _LOGGER.debug('updated balance of account %s', self.name) + + @property + def name(self) -> str: + """Friendly name of the sensor.""" + return self._name + + @property + def state(self) -> float: + """Return the balance of the account as state.""" + return self._balance + + @property + def unit_of_measurement(self) -> str: + """Use the currency as unit of measurement.""" + return self._currency + + @property + def device_state_attributes(self) -> dict: + """Additional attributes of the sensor.""" + attributes = { + ATTR_ACCOUNT: self._account.iban, + ATTR_ACCOUNT_TYPE: 'balance', + } + if self._client.name: + attributes[ATTR_BANK] = self._client.name + return attributes + + @property + def icon(self) -> str: + """Set the icon for the sensor.""" + return ICON + + +class FinTsHoldingsAccount(Entity): + """Sensor for a FinTS holdings account. + + A holdings account does not contain money but rather some financial + instruments, e.g. stocks. + """ + + def __init__(self, client: FinTsClient, account, name: str) -> None: + """Constructor for class FinTsHoldingsAccount.""" + self._client = client # type: FinTsClient + self._name = name # type: str + self._account = account + self._holdings = [] + self._total = None # type: float + + @property + def should_poll(self) -> bool: + """Data needs to be polled from the bank servers.""" + return True + + def update(self) -> None: + """Get the current holdings for the account.""" + bank = self._client.client + self._holdings = bank.get_holdings(self._account) + self._total = sum(h.total_value for h in self._holdings) + + @property + def state(self) -> float: + """Return total market value as state.""" + return self._total + + @property + def icon(self) -> str: + """Set the icon for the sensor.""" + return ICON + + @property + def device_state_attributes(self) -> dict: + """Additional attributes of the sensor. + + Lists each holding of the account with the current value. + """ + attributes = { + ATTR_ACCOUNT: self._account.accountnumber, + ATTR_ACCOUNT_TYPE: 'holdings', + } + if self._client.name: + attributes[ATTR_BANK] = self._client.name + for holding in self._holdings: + total_name = '{} total'.format(holding.name) + attributes[total_name] = holding.total_value + pieces_name = '{} pieces'.format(holding.name) + attributes[pieces_name] = holding.pieces + price_name = '{} price'.format(holding.name) + attributes[price_name] = holding.market_value + + return attributes + + @property + def name(self) -> str: + """Friendly name of the sensor.""" + return self._name + + @property + def unit_of_measurement(self) -> str: + """Get the unit of measurement. + + Hardcoded to EUR, as the library does not provide the currency for the + holdings. And as FinTS is only used in Germany, most accounts will be + in EUR anyways. + """ + return "EUR" diff --git a/homeassistant/components/sensor/fitbit.py b/homeassistant/components/sensor/fitbit.py index 8d64a8d8229d0b..87bd735a03df1d 100644 --- a/homeassistant/components/sensor/fitbit.py +++ b/homeassistant/components/sensor/fitbit.py @@ -156,7 +156,6 @@ def request_app_setup(hass, config, add_devices, config_path, """Assist user with configuring the Fitbit dev application.""" configurator = hass.components.configurator - # pylint: disable=unused-argument def fitbit_configuration_callback(callback_data): """Handle configuration updates.""" config_path = hass.config.path(FITBIT_CONFIG_FILE) @@ -202,7 +201,6 @@ def request_oauth_completion(hass): return - # pylint: disable=unused-argument def fitbit_configuration_callback(callback_data): """Handle configuration updates.""" @@ -227,7 +225,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): hass, config, add_devices, config_path, discovery_info=None) return False else: - config_file = save_json(config_path, DEFAULT_CONFIG) + save_json(config_path, DEFAULT_CONFIG) request_app_setup( hass, config, add_devices, config_path, discovery_info=None) return False diff --git a/homeassistant/components/sensor/fixer.py b/homeassistant/components/sensor/fixer.py index 3e909b7b21de55..5a6f8da79b2246 100644 --- a/homeassistant/components/sensor/fixer.py +++ b/homeassistant/components/sensor/fixer.py @@ -10,15 +10,14 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_BASE, CONF_NAME +from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, CONF_NAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['fixerio==0.1.1'] +REQUIREMENTS = ['fixerio==1.0.0a0'] _LOGGER = logging.getLogger(__name__) -ATTR_BASE = 'Base currency' ATTR_EXCHANGE_RATE = 'Exchange rate' ATTR_TARGET = 'Target currency' @@ -33,8 +32,8 @@ SCAN_INTERVAL = timedelta(days=1) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_TARGET): cv.string, - vol.Optional(CONF_BASE, default=DEFAULT_BASE): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) @@ -43,17 +42,17 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Fixer.io sensor.""" from fixerio import Fixerio, exceptions + api_key = config.get(CONF_API_KEY) name = config.get(CONF_NAME) - base = config.get(CONF_BASE) target = config.get(CONF_TARGET) try: - Fixerio(base=base, symbols=[target], secure=True).latest() + Fixerio(symbols=[target], access_key=api_key).latest() except exceptions.FixerioException: _LOGGER.error("One of the given currencies is not supported") - return False + return - data = ExchangeData(base, target) + data = ExchangeData(target, api_key) add_devices([ExchangeRateSensor(data, name, target)], True) @@ -87,10 +86,9 @@ def device_state_attributes(self): """Return the state attributes.""" if self.data.rate is not None: return { - ATTR_BASE: self.data.rate['base'], - ATTR_TARGET: self._target, - ATTR_EXCHANGE_RATE: self.data.rate['rates'][self._target], ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_EXCHANGE_RATE: self.data.rate['rates'][self._target], + ATTR_TARGET: self._target, } @property @@ -104,19 +102,18 @@ def update(self): self._state = round(self.data.rate['rates'][self._target], 3) -class ExchangeData(object): +class ExchangeData: """Get the latest data and update the states.""" - def __init__(self, base_currency, target_currency): + def __init__(self, target_currency, api_key): """Initialize the data object.""" from fixerio import Fixerio + self.api_key = api_key self.rate = None - self.base_currency = base_currency self.target_currency = target_currency self.exchange = Fixerio( - base=self.base_currency, symbols=[self.target_currency], - secure=True) + symbols=[self.target_currency], access_key=self.api_key) def update(self): """Get the latest data from Fixer.io.""" diff --git a/homeassistant/components/sensor/fritzbox_callmonitor.py b/homeassistant/components/sensor/fritzbox_callmonitor.py index b443bd56f03d4f..3da9c512ebdd72 100644 --- a/homeassistant/components/sensor/fritzbox_callmonitor.py +++ b/homeassistant/components/sensor/fritzbox_callmonitor.py @@ -70,7 +70,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): phonebook = FritzBoxPhonebook( host=host, port=port, username=username, password=password, phonebook_id=phonebook_id, prefixes=prefixes) - except: # noqa: E722 # pylint: disable=bare-except + except: # noqa: E722 pylint: disable=bare-except phonebook = None _LOGGER.warning("Phonebook with ID %s not found on Fritz!Box", phonebook_id) @@ -143,7 +143,7 @@ def update(self): self.phonebook.update_phonebook() -class FritzBoxCallMonitor(object): +class FritzBoxCallMonitor: """Event listener to monitor calls on the Fritz!Box.""" def __init__(self, host, port, sensor): @@ -187,7 +187,6 @@ def _listen(self): line = response.split("\n", 1)[0] self._parse(line) time.sleep(1) - return def _parse(self, line): """Parse the call information and set the sensor states.""" @@ -225,7 +224,7 @@ def _parse(self, line): self._sensor.schedule_update_ha_state() -class FritzBoxPhonebook(object): +class FritzBoxPhonebook: """This connects to a FritzBox router and downloads its phone book.""" def __init__(self, host, port, username, password, diff --git a/homeassistant/components/sensor/fritzbox_netmonitor.py b/homeassistant/components/sensor/fritzbox_netmonitor.py index 857e6cc4a074e8..b980323abe1e48 100644 --- a/homeassistant/components/sensor/fritzbox_netmonitor.py +++ b/homeassistant/components/sensor/fritzbox_netmonitor.py @@ -65,8 +65,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if fstatus is None: _LOGGER.error("Failed to establish connection to FRITZ!Box: %s", host) return 1 - else: - _LOGGER.info("Successfully connected to FRITZ!Box") + _LOGGER.info("Successfully connected to FRITZ!Box") add_devices([FritzboxMonitorSensor(name, fstatus)], True) diff --git a/homeassistant/components/sensor/gearbest.py b/homeassistant/components/sensor/gearbest.py index aa1d2d9eff049e..d71419ba79e69e 100644 --- a/homeassistant/components/sensor/gearbest.py +++ b/homeassistant/components/sensor/gearbest.py @@ -16,7 +16,7 @@ from homeassistant.helpers.event import track_time_interval from homeassistant.const import (CONF_NAME, CONF_ID, CONF_URL, CONF_CURRENCY) -REQUIREMENTS = ['gearbest_parser==1.0.5'] +REQUIREMENTS = ['gearbest_parser==1.0.7'] _LOGGER = logging.getLogger(__name__) CONF_ITEMS = 'items' diff --git a/homeassistant/components/sensor/geizhals.py b/homeassistant/components/sensor/geizhals.py index 94f3f1884d188f..06062b26b004a9 100644 --- a/homeassistant/components/sensor/geizhals.py +++ b/homeassistant/components/sensor/geizhals.py @@ -15,7 +15,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.const import (CONF_DOMAIN, CONF_NAME) -REQUIREMENTS = ['beautifulsoup4==4.6.0'] +REQUIREMENTS = ['beautifulsoup4==4.6.1'] _LOGGER = logging.getLogger(__name__) CONF_PRODUCT_ID = 'product_id' @@ -98,7 +98,7 @@ def update(self): self._state = self.data.prices[0] -class GeizParser(object): +class GeizParser: """Pull data from the geizhals website.""" def __init__(self, product_id, domain, regex): diff --git a/homeassistant/components/sensor/geo_rss_events.py b/homeassistant/components/sensor/geo_rss_events.py index c8c4db17c8d630..b79e6e69adf492 100644 --- a/homeassistant/components/sensor/geo_rss_events.py +++ b/homeassistant/components/sensor/geo_rss_events.py @@ -149,7 +149,7 @@ def update(self): self._state_attributes = matrix -class GeoRssServiceData(object): +class GeoRssServiceData: """Provide access to GeoRSS feed and stores the latest data.""" def __init__(self, home_latitude, home_longitude, url, radius_in_km): diff --git a/homeassistant/components/sensor/gitter.py b/homeassistant/components/sensor/gitter.py index 58f33635750169..907af07a2dba24 100644 --- a/homeassistant/components/sensor/gitter.py +++ b/homeassistant/components/sensor/gitter.py @@ -8,12 +8,12 @@ import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME, CONF_API_KEY, CONF_ROOM +from homeassistant.const import CONF_API_KEY, CONF_NAME, CONF_ROOM +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['gitterpy==0.1.6'] +REQUIREMENTS = ['gitterpy==0.1.7'] _LOGGER = logging.getLogger(__name__) @@ -47,7 +47,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): username = gitter.auth.get_my_id['name'] except GitterTokenError: _LOGGER.error("Token is not valid") - return False + return add_devices([GitterSensor(gitter, room, name, username)], True) @@ -96,7 +96,14 @@ def icon(self): def update(self): """Get the latest data and updates the state.""" - data = self._data.user.unread_items(self._room) + from gitterpy.errors import GitterRoomError + + try: + data = self._data.user.unread_items(self._room) + except GitterRoomError as error: + _LOGGER.error(error) + return + if 'error' not in data.keys(): self._mention = len(data['mention']) self._state = len(data['chat']) diff --git a/homeassistant/components/sensor/glances.py b/homeassistant/components/sensor/glances.py index 3b6f3ddc99d4b4..a6dfd89e45a137 100644 --- a/homeassistant/components/sensor/glances.py +++ b/homeassistant/components/sensor/glances.py @@ -56,7 +56,6 @@ }) -# pylint: disable=unused-variable def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Glances sensor.""" name = config.get(CONF_NAME) @@ -155,13 +154,14 @@ def update(self): self._state = value['processcount']['sleeping'] elif self.type == 'cpu_temp': for sensor in value['sensors']: - if sensor['label'] == 'CPU': + if sensor['label'] in ['CPU', "Package id 0", + "Physical id 0"]: self._state = sensor['value'] - self._state = None elif self.type == 'docker_active': count = 0 for container in value['docker']['containers']: - if container['Status'] == 'running': + if container['Status'] == 'running' or \ + 'Up' in container['Status']: count += 1 self._state = count elif self.type == 'docker_cpu_use': @@ -176,7 +176,7 @@ def update(self): self._state = round(use / 1024**2, 1) -class GlancesData(object): +class GlancesData: """The class for handling the data retrieval.""" def __init__(self, resource): diff --git a/homeassistant/components/sensor/google_travel_time.py b/homeassistant/components/sensor/google_travel_time.py index e7d258727019a0..d14a70ecc84cf9 100644 --- a/homeassistant/components/sensor/google_travel_time.py +++ b/homeassistant/components/sensor/google_travel_time.py @@ -17,7 +17,7 @@ ATTR_LONGITUDE, CONF_MODE) from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.location as location +from homeassistant.helpers import location import homeassistant.util.dt as dt_util REQUIREMENTS = ['googlemaps==2.5.1'] diff --git a/homeassistant/components/sensor/google_wifi.py b/homeassistant/components/sensor/google_wifi.py index c070a3e990f70d..cc5461ed548124 100644 --- a/homeassistant/components/sensor/google_wifi.py +++ b/homeassistant/components/sensor/google_wifi.py @@ -10,7 +10,7 @@ import voluptuous as vol import requests -import homeassistant.util.dt as dt +from homeassistant.util import dt import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( @@ -138,7 +138,7 @@ def update(self): self._state = STATE_UNKNOWN -class GoogleWifiAPI(object): +class GoogleWifiAPI: """Get the latest data and update the states.""" def __init__(self, host, conditions): diff --git a/homeassistant/components/sensor/gpsd.py b/homeassistant/components/sensor/gpsd.py index 472dd1d70f6cb8..f463d0fb8d1ad0 100644 --- a/homeassistant/components/sensor/gpsd.py +++ b/homeassistant/components/sensor/gpsd.py @@ -86,13 +86,12 @@ def name(self): """Return the name.""" return self._name - # pylint: disable=no-member @property def state(self): """Return the state of GPSD.""" if self.agps_thread.data_stream.mode == 3: return "3D Fix" - elif self.agps_thread.data_stream.mode == 2: + if self.agps_thread.data_stream.mode == 2: return "2D Fix" return STATE_UNKNOWN diff --git a/homeassistant/components/sensor/gtfs.py b/homeassistant/components/sensor/gtfs.py index 616144d2bc67ce..120fe8fdb225bd 100644 --- a/homeassistant/components/sensor/gtfs.py +++ b/homeassistant/components/sensor/gtfs.py @@ -16,9 +16,7 @@ from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ["https://github.com/robbiet480/pygtfs/archive/" - "00546724e4bbcb3053110d844ca44e2246267dd8.zip#" - "pygtfs==0.1.3"] +REQUIREMENTS = ['pygtfs-homeassistant==0.1.3.dev0'] _LOGGER = logging.getLogger(__name__) @@ -178,7 +176,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): gtfs = pygtfs.Schedule(joined_path) # pylint: disable=no-member - if len(gtfs.feeds) < 1: + if not gtfs.feeds: pygtfs.append_feed(gtfs, os.path.join(gtfs_dir, data)) add_devices([GTFSDepartureSensor(gtfs, name, origin, destination, offset)]) diff --git a/homeassistant/components/sensor/haveibeenpwned.py b/homeassistant/components/sensor/haveibeenpwned.py index 3b041127a5b18f..bc79c4d0c1dac0 100644 --- a/homeassistant/components/sensor/haveibeenpwned.py +++ b/homeassistant/components/sensor/haveibeenpwned.py @@ -35,7 +35,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the HaveIBeenPwned sensor.""" emails = config.get(CONF_EMAIL) @@ -123,7 +122,7 @@ def update(self): self._state = len(self._data.data[self._email]) -class HaveIBeenPwnedData(object): +class HaveIBeenPwnedData: """Class for handling the data retrieval.""" def __init__(self, emails): diff --git a/homeassistant/components/sensor/hddtemp.py b/homeassistant/components/sensor/hddtemp.py index 006542a777f7fb..f8afe9c7637023 100644 --- a/homeassistant/components/sensor/hddtemp.py +++ b/homeassistant/components/sensor/hddtemp.py @@ -108,7 +108,7 @@ def update(self): self._state = None -class HddTempData(object): +class HddTempData: """Get the latest data from HDDTemp and update the states.""" def __init__(self, host, port): diff --git a/homeassistant/components/sensor/history_stats.py b/homeassistant/components/sensor/history_stats.py index 7af858b9d94bef..c3d0fe8f1b6eb8 100644 --- a/homeassistant/components/sensor/history_stats.py +++ b/homeassistant/components/sensor/history_stats.py @@ -10,7 +10,7 @@ import voluptuous as vol -import homeassistant.components.history as history +from homeassistant.components import history import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util from homeassistant.components.sensor import PLATFORM_SCHEMA @@ -294,7 +294,7 @@ def pretty_duration(hours): minutes, seconds = divmod(seconds, 60) if days > 0: return '%dd %dh %dm' % (days, hours, minutes) - elif hours > 0: + if hours > 0: return '%dh %dm' % (hours, minutes) return '%dm' % minutes diff --git a/homeassistant/components/sensor/hive.py b/homeassistant/components/sensor/hive.py index 8f8ce2d16815c0..2d609070415883 100644 --- a/homeassistant/components/sensor/hive.py +++ b/homeassistant/components/sensor/hive.py @@ -10,7 +10,7 @@ DEPENDENCIES = ['hive'] -FRIENDLY_NAMES = {'Hub_OnlineStatus': 'Hub Status', +FRIENDLY_NAMES = {'Hub_OnlineStatus': 'Hive Hub Status', 'Hive_OutsideTemperature': 'Outside Temperature'} DEVICETYPE_ICONS = {'Hub_OnlineStatus': 'mdi:switch', 'Hive_OutsideTemperature': 'mdi:thermometer'} @@ -55,7 +55,7 @@ def state(self): """Return the state of the sensor.""" if self.device_type == "Hub_OnlineStatus": return self.session.sensor.hub_online_status(self.node_id) - elif self.device_type == "Hive_OutsideTemperature": + if self.device_type == "Hive_OutsideTemperature": return self.session.weather.temperature() @property @@ -70,7 +70,7 @@ def icon(self): return DEVICETYPE_ICONS.get(self.device_type) def update(self): - """Update all Node data frome Hive.""" + """Update all Node data from Hive.""" if self.session.core.update_data(self.node_id): for entity in self.session.entities: entity.handle_update(self.data_updatesource) diff --git a/homeassistant/components/sensor/homematic.py b/homeassistant/components/sensor/homematic.py index bdbc207a79ca9f..60741a9f3c8475 100644 --- a/homeassistant/components/sensor/homematic.py +++ b/homeassistant/components/sensor/homematic.py @@ -16,6 +16,9 @@ 'RotaryHandleSensor': {0: 'closed', 1: 'tilted', 2: 'open'}, + 'RotaryHandleSensorIP': {0: 'closed', + 1: 'tilted', + 2: 'open'}, 'WaterSensor': {0: 'dry', 1: 'wet', 2: 'water'}, diff --git a/homeassistant/components/sensor/homematicip_cloud.py b/homeassistant/components/sensor/homematicip_cloud.py index aa350f7be5d048..7292e3b2f40488 100644 --- a/homeassistant/components/sensor/homematicip_cloud.py +++ b/homeassistant/components/sensor/homematicip_cloud.py @@ -8,9 +8,11 @@ import logging from homeassistant.components.homematicip_cloud import ( - HomematicipGenericDevice, DOMAIN as HOMEMATICIP_CLOUD_DOMAIN, - ATTR_HOME_ID) -from homeassistant.const import TEMP_CELSIUS + HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN, + HMIPC_HAPID) +from homeassistant.const import ( + TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_HUMIDITY, + DEVICE_CLASS_ILLUMINANCE) _LOGGER = logging.getLogger(__name__) @@ -22,27 +24,21 @@ ATTR_TEMPERATURE_OFFSET = 'temperature_offset' ATTR_HUMIDITY = 'humidity' -HMIP_UPTODATE = 'up_to_date' -HMIP_VALVE_DONE = 'adaption_done' -HMIP_SABOTAGE = 'sabotage' - -STATE_OK = 'ok' -STATE_LOW_BATTERY = 'low_battery' -STATE_SABOTAGE = 'sabotage' - async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the HomematicIP sensors devices.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the HomematicIP sensors from a config entry.""" from homematicip.device import ( HeatingThermostat, TemperatureHumiditySensorWithoutDisplay, - TemperatureHumiditySensorDisplay) + TemperatureHumiditySensorDisplay, MotionDetectorIndoor) - if discovery_info is None: - return - home = hass.data[HOMEMATICIP_CLOUD_DOMAIN][discovery_info[ATTR_HOME_ID]] + home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [HomematicipAccesspointStatus(home)] - for device in home.devices: if isinstance(device, HeatingThermostat): devices.append(HomematicipHeatingThermostat(home, device)) @@ -50,6 +46,8 @@ async def async_setup_platform(hass, config, async_add_devices, TemperatureHumiditySensorWithoutDisplay)): devices.append(HomematicipTemperatureSensor(home, device)) devices.append(HomematicipHumiditySensor(home, device)) + if isinstance(device, MotionDetectorIndoor): + devices.append(HomematicipIlluminanceSensor(home, device)) if devices: async_add_devices(devices) @@ -78,41 +76,9 @@ def available(self): return self._home.connected @property - def device_state_attributes(self): - """Return the state attributes of the access point.""" - return {} - - -class HomematicipDeviceStatus(HomematicipGenericDevice): - """Representation of an HomematicIP device status.""" - - def __init__(self, home, device): - """Initialize generic status device.""" - super().__init__(home, device, 'Status') - - @property - def icon(self): - """Return the icon of the status device.""" - if (hasattr(self._device, 'sabotage') and - self._device.sabotage == HMIP_SABOTAGE): - return 'mdi:alert' - elif self._device.lowBat: - return 'mdi:battery-outline' - elif self._device.updateState.lower() != HMIP_UPTODATE: - return 'mdi:refresh' - return 'mdi:check' - - @property - def state(self): - """Return the state of the generic device.""" - if (hasattr(self._device, 'sabotage') and - self._device.sabotage == HMIP_SABOTAGE): - return STATE_SABOTAGE - elif self._device.lowBat: - return STATE_LOW_BATTERY - elif self._device.updateState.lower() != HMIP_UPTODATE: - return self._device.updateState.lower() - return STATE_OK + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return '%' class HomematicipHeatingThermostat(HomematicipGenericDevice): @@ -125,15 +91,21 @@ def __init__(self, home, device): @property def icon(self): """Return the icon.""" - if self._device.valveState.lower() != HMIP_VALVE_DONE: + from homematicip.base.enums import ValveState + + if super().icon: + return super().icon + if self._device.valveState != ValveState.ADAPTION_DONE: return 'mdi:alert' return 'mdi:radiator' @property def state(self): """Return the state of the radiator valve.""" - if self._device.valveState.lower() != HMIP_VALVE_DONE: - return self._device.valveState.lower() + from homematicip.base.enums import ValveState + + if self._device.valveState != ValveState.ADAPTION_DONE: + return self._device.valveState return round(self._device.valvePosition*100) @property @@ -150,9 +122,9 @@ def __init__(self, home, device): super().__init__(home, device, 'Humidity') @property - def icon(self): - """Return the icon.""" - return 'mdi:water-percent' + def device_class(self): + """Return the device class of the sensor.""" + return DEVICE_CLASS_HUMIDITY @property def state(self): @@ -173,9 +145,9 @@ def __init__(self, home, device): super().__init__(home, device, 'Temperature') @property - def icon(self): - """Return the icon.""" - return 'mdi:thermometer' + def device_class(self): + """Return the device class of the sensor.""" + return DEVICE_CLASS_TEMPERATURE @property def state(self): @@ -186,3 +158,26 @@ def state(self): def unit_of_measurement(self): """Return the unit this state is expressed in.""" return TEMP_CELSIUS + + +class HomematicipIlluminanceSensor(HomematicipGenericDevice): + """MomematicIP the thermometer device.""" + + def __init__(self, home, device): + """Initialize the device.""" + super().__init__(home, device, 'Illuminance') + + @property + def device_class(self): + """Return the device class of the sensor.""" + return DEVICE_CLASS_ILLUMINANCE + + @property + def state(self): + """Return the state.""" + return self._device.illumination + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return 'lx' diff --git a/homeassistant/components/sensor/hp_ilo.py b/homeassistant/components/sensor/hp_ilo.py index 922ed04a8d9e2a..98ee83f8958355 100644 --- a/homeassistant/components/sensor/hp_ilo.py +++ b/homeassistant/components/sensor/hp_ilo.py @@ -59,7 +59,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the HP ILO sensor.""" hostname = config.get(CONF_HOST) @@ -148,7 +147,7 @@ def update(self): self._state = ilo_data -class HpIloData(object): +class HpIloData: """Gets the latest data from HP ILO.""" def __init__(self, host, port, login, password): diff --git a/homeassistant/components/sensor/hydrawise.py b/homeassistant/components/sensor/hydrawise.py new file mode 100644 index 00000000000000..fea2780da0756e --- /dev/null +++ b/homeassistant/components/sensor/hydrawise.py @@ -0,0 +1,72 @@ +""" +Support for Hydrawise sprinkler. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.hydrawise/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.hydrawise import ( + DATA_HYDRAWISE, HydrawiseEntity, DEVICE_MAP, DEVICE_MAP_INDEX, SENSORS) +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_MONITORED_CONDITIONS + +DEPENDENCIES = ['hydrawise'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=SENSORS): + vol.All(cv.ensure_list, [vol.In(SENSORS)]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up a sensor for a Hydrawise device.""" + hydrawise = hass.data[DATA_HYDRAWISE].data + + sensors = [] + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + for zone in hydrawise.relays: + sensors.append(HydrawiseSensor(zone, sensor_type)) + + add_devices(sensors, True) + + +class HydrawiseSensor(HydrawiseEntity): + """A sensor implementation for Hydrawise device.""" + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + def update(self): + """Get the latest data and updates the states.""" + mydata = self.hass.data[DATA_HYDRAWISE].data + _LOGGER.debug("Updating Hydrawise sensor: %s", self._name) + if self._sensor_type == 'watering_time': + if not mydata.running: + self._state = 0 + else: + if int(mydata.running[0]['relay']) == self.data['relay']: + self._state = int(mydata.running[0]['time_left']/60) + else: + self._state = 0 + else: # _sensor_type == 'next_cycle' + for relay in mydata.relays: + if relay['relay'] == self.data['relay']: + if relay['nicetime'] == 'Not scheduled': + self._state = 'not_scheduled' + else: + self._state = relay['nicetime'].split(',')[0] + \ + ' ' + relay['nicetime'].split(' ')[3] + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return DEVICE_MAP[self._sensor_type][ + DEVICE_MAP_INDEX.index('ICON_INDEX')] diff --git a/homeassistant/components/sensor/hydroquebec.py b/homeassistant/components/sensor/hydroquebec.py index 2195153ab1edfb..db75d51fbad5c0 100644 --- a/homeassistant/components/sensor/hydroquebec.py +++ b/homeassistant/components/sensor/hydroquebec.py @@ -162,7 +162,7 @@ def async_update(self): self._state = round(self.hydroquebec_data.data[self.type], 2) -class HydroquebecData(object): +class HydroquebecData: """Get data from HydroQuebec.""" def __init__(self, username, password, httpsession, contract=None): diff --git a/homeassistant/components/sensor/ihc.py b/homeassistant/components/sensor/ihc.py index b30a242c17c191..2dcf2c3f7bee84 100644 --- a/homeassistant/components/sensor/ihc.py +++ b/homeassistant/components/sensor/ihc.py @@ -3,8 +3,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.ihc/ """ -from xml.etree.ElementTree import Element - import voluptuous as vol from homeassistant.components.ihc import ( @@ -62,7 +60,7 @@ class IHCSensor(IHCDevice, Entity): """Implementation of the IHC sensor.""" def __init__(self, ihc_controller, name, ihc_id: int, info: bool, - unit, product: Element = None) -> None: + unit, product=None) -> None: """Initialize the IHC sensor.""" super().__init__(ihc_controller, name, ihc_id, info, product) self._state = None diff --git a/homeassistant/components/sensor/imap_email_content.py b/homeassistant/components/sensor/imap_email_content.py index c0c9bf62efdfd3..a1a604df3e496b 100644 --- a/homeassistant/components/sensor/imap_email_content.py +++ b/homeassistant/components/sensor/imap_email_content.py @@ -58,7 +58,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): return False -class EmailReader(object): +class EmailReader: """A class to read emails from an IMAP server.""" def __init__(self, user, password, server, port): diff --git a/homeassistant/components/sensor/influxdb.py b/homeassistant/components/sensor/influxdb.py index c0d492984e0a52..8bfbaf49837f30 100644 --- a/homeassistant/components/sensor/influxdb.py +++ b/homeassistant/components/sensor/influxdb.py @@ -155,7 +155,7 @@ def update(self): self._state = value -class InfluxSensorData(object): +class InfluxSensorData: """Class for handling the data retrieval.""" def __init__(self, influx, group, field, measurement, where): diff --git a/homeassistant/components/sensor/ios.py b/homeassistant/components/sensor/ios.py index 398c0b350ee361..1fd556b17c0c7b 100644 --- a/homeassistant/components/sensor/ios.py +++ b/homeassistant/components/sensor/ios.py @@ -86,8 +86,8 @@ def icon(self): battery_level = device_battery[ios.ATTR_BATTERY_LEVEL] charging = True icon_state = DEFAULT_ICON_STATE - if (battery_state == ios.ATTR_BATTERY_STATE_FULL or - battery_state == ios.ATTR_BATTERY_STATE_UNPLUGGED): + if battery_state in (ios.ATTR_BATTERY_STATE_FULL, + ios.ATTR_BATTERY_STATE_UNPLUGGED): charging = False icon_state = "{}-off".format(DEFAULT_ICON_STATE) elif battery_state == ios.ATTR_BATTERY_STATE_UNKNOWN: diff --git a/homeassistant/components/sensor/iota.py b/homeassistant/components/sensor/iota.py index c973fa831485f0..2e3e58a18f3e43 100644 --- a/homeassistant/components/sensor/iota.py +++ b/homeassistant/components/sensor/iota.py @@ -7,10 +7,18 @@ import logging from datetime import timedelta -from homeassistant.components.iota import IotaDevice +from homeassistant.components.iota import IotaDevice, CONF_WALLETS +from homeassistant.const import CONF_NAME _LOGGER = logging.getLogger(__name__) +ATTR_TESTNET = 'testnet' +ATTR_URL = 'url' + +CONF_IRI = 'iri' +CONF_SEED = 'seed' +CONF_TESTNET = 'testnet' + DEPENDENCIES = ['iota'] SCAN_INTERVAL = timedelta(minutes=3) @@ -21,7 +29,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): # Add sensors for wallet balance iota_config = discovery_info sensors = [IotaBalanceSensor(wallet, iota_config) - for wallet in iota_config['wallets']] + for wallet in iota_config[CONF_WALLETS]] # Add sensor for node information sensors.append(IotaNodeSensor(iota_config=iota_config)) @@ -34,10 +42,9 @@ class IotaBalanceSensor(IotaDevice): def __init__(self, wallet_config, iota_config): """Initialize the sensor.""" - super().__init__(name=wallet_config['name'], - seed=wallet_config['seed'], - iri=iota_config['iri'], - is_testnet=iota_config['testnet']) + super().__init__( + name=wallet_config[CONF_NAME], seed=wallet_config[CONF_SEED], + iri=iota_config[CONF_IRI], is_testnet=iota_config[CONF_TESTNET]) self._state = None @property @@ -65,10 +72,11 @@ class IotaNodeSensor(IotaDevice): def __init__(self, iota_config): """Initialize the sensor.""" - super().__init__(name='Node Info', seed=None, iri=iota_config['iri'], - is_testnet=iota_config['testnet']) + super().__init__( + name='Node Info', seed=None, iri=iota_config[CONF_IRI], + is_testnet=iota_config[CONF_TESTNET]) self._state = None - self._attr = {'url': self.iri, 'testnet': self.is_testnet} + self._attr = {ATTR_URL: self.iri, ATTR_TESTNET: self.is_testnet} @property def name(self): diff --git a/homeassistant/components/sensor/iperf3.py b/homeassistant/components/sensor/iperf3.py new file mode 100644 index 00000000000000..8e030390f501b1 --- /dev/null +++ b/homeassistant/components/sensor/iperf3.py @@ -0,0 +1,195 @@ +""" +Support for Iperf3 network measurement tool. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.iperf3/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.components.sensor import DOMAIN, PLATFORM_SCHEMA +from homeassistant.const import ( + ATTR_ATTRIBUTION, ATTR_ENTITY_ID, CONF_MONITORED_CONDITIONS, + CONF_HOST, CONF_PORT, CONF_PROTOCOL) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['iperf3==0.1.10'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_PROTOCOL = 'Protocol' +ATTR_REMOTE_HOST = 'Remote Server' +ATTR_REMOTE_PORT = 'Remote Port' +ATTR_VERSION = 'Version' + +CONF_ATTRIBUTION = 'Data retrieved using Iperf3' +CONF_DURATION = 'duration' +CONF_PARALLEL = 'parallel' + +DEFAULT_DURATION = 10 +DEFAULT_PORT = 5201 +DEFAULT_PARALLEL = 1 +DEFAULT_PROTOCOL = 'tcp' + +IPERF3_DATA = 'iperf3' + +SCAN_INTERVAL = timedelta(minutes=60) + +SERVICE_NAME = 'iperf3_update' + +ICON = 'mdi:speedometer' + +SENSOR_TYPES = { + 'download': ['Download', 'Mbit/s'], + 'upload': ['Upload', 'Mbit/s'], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [vol.In(list(SENSOR_TYPES))]), + vol.Required(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_DURATION, default=DEFAULT_DURATION): vol.Range(5, 10), + vol.Optional(CONF_PARALLEL, default=DEFAULT_PARALLEL): vol.Range(1, 20), + vol.Optional(CONF_PROTOCOL, default=DEFAULT_PROTOCOL): + vol.In(['tcp', 'udp']), +}) + + +SERVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_ENTITY_ID): cv.string, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Iperf3 sensor.""" + if hass.data.get(IPERF3_DATA) is None: + hass.data[IPERF3_DATA] = {} + hass.data[IPERF3_DATA]['sensors'] = [] + + dev = [] + for sensor in config[CONF_MONITORED_CONDITIONS]: + dev.append( + Iperf3Sensor(config[CONF_HOST], + config[CONF_PORT], + config[CONF_DURATION], + config[CONF_PARALLEL], + config[CONF_PROTOCOL], + sensor)) + + hass.data[IPERF3_DATA]['sensors'].extend(dev) + add_devices(dev) + + def _service_handler(service): + """Update service for manual updates.""" + entity_id = service.data.get('entity_id') + all_iperf3_sensors = hass.data[IPERF3_DATA]['sensors'] + + for sensor in all_iperf3_sensors: + if entity_id is not None: + if sensor.entity_id == entity_id: + sensor.update() + sensor.schedule_update_ha_state() + break + else: + sensor.update() + sensor.schedule_update_ha_state() + + for sensor in dev: + hass.services.register(DOMAIN, SERVICE_NAME, _service_handler, + schema=SERVICE_SCHEMA) + + +class Iperf3Sensor(Entity): + """A Iperf3 sensor implementation.""" + + def __init__(self, server, port, duration, streams, + protocol, sensor_type): + """Initialize the sensor.""" + self._attrs = { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + ATTR_PROTOCOL: protocol, + } + self._name = \ + "{} {}".format(SENSOR_TYPES[sensor_type][0], server) + self._state = None + self._sensor_type = sensor_type + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._port = port + self._server = server + self._duration = duration + self._num_streams = streams + self._protocol = protocol + self.result = None + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def device_state_attributes(self): + """Return the state attributes.""" + if self.result is not None: + self._attrs[ATTR_ATTRIBUTION] = CONF_ATTRIBUTION + self._attrs[ATTR_REMOTE_HOST] = self.result.remote_host + self._attrs[ATTR_REMOTE_PORT] = self.result.remote_port + self._attrs[ATTR_VERSION] = self.result.version + return self._attrs + + def update(self): + """Get the latest data and update the states.""" + import iperf3 + client = iperf3.Client() + client.duration = self._duration + client.server_hostname = self._server + client.port = self._port + client.verbose = False + client.num_streams = self._num_streams + client.protocol = self._protocol + + # when testing download bandwith, reverse must be True + if self._sensor_type == 'download': + client.reverse = True + + try: + self.result = client.run() + except (AttributeError, OSError, ValueError) as error: + self.result = None + _LOGGER.error("Iperf3 sensor error: %s", error) + return + + if self.result is not None and \ + hasattr(self.result, 'error') and \ + self.result.error is not None: + _LOGGER.error("Iperf3 sensor error: %s", self.result.error) + self.result = None + return + + # UDP only have 1 way attribute + if self._protocol == 'udp': + self._state = round(self.result.Mbps, 2) + + elif self._sensor_type == 'download': + self._state = round(self.result.received_Mbps, 2) + + elif self._sensor_type == 'upload': + self._state = round(self.result.sent_Mbps, 2) + + @property + def icon(self): + """Return icon.""" + return ICON diff --git a/homeassistant/components/sensor/irish_rail_transport.py b/homeassistant/components/sensor/irish_rail_transport.py index 603d82359de6b5..38fd910260ad6d 100644 --- a/homeassistant/components/sensor/irish_rail_transport.py +++ b/homeassistant/components/sensor/irish_rail_transport.py @@ -132,7 +132,7 @@ def update(self): self._state = None -class IrishRailTransportData(object): +class IrishRailTransportData: """The Class for handling the data retrieval.""" def __init__(self, irish_rail, station, direction, destination, stops_at): @@ -164,7 +164,7 @@ def update(self): ATTR_TRAIN_TYPE: train.get('type')} self.info.append(train_data) - if not self.info or not self.info: + if not self.info: self.info = self._empty_train_data() def _empty_train_data(self): diff --git a/homeassistant/components/sensor/isy994.py b/homeassistant/components/sensor/isy994.py index ecf7bc0b8c2665..19dcfc87014009 100644 --- a/homeassistant/components/sensor/isy994.py +++ b/homeassistant/components/sensor/isy994.py @@ -5,7 +5,7 @@ https://home-assistant.io/components/sensor.isy994/ """ import logging -from typing import Callable # noqa +from typing import Callable from homeassistant.components.sensor import DOMAIN from homeassistant.components.isy994 import (ISY994_NODES, ISY994_WEATHER, @@ -235,7 +235,6 @@ } -# pylint: disable=unused-argument def setup_platform(hass, config: ConfigType, add_devices: Callable[[list], None], discovery_info=None): """Set up the ISY994 sensor platform.""" @@ -260,14 +259,11 @@ def raw_unit_of_measurement(self) -> str: if len(self._node.uom) == 1: if self._node.uom[0] in UOM_FRIENDLY_NAME: friendly_name = UOM_FRIENDLY_NAME.get(self._node.uom[0]) - if friendly_name == TEMP_CELSIUS or \ - friendly_name == TEMP_FAHRENHEIT: + if friendly_name in (TEMP_CELSIUS, TEMP_FAHRENHEIT): friendly_name = self.hass.config.units.temperature_unit return friendly_name - else: - return self._node.uom[0] - else: - return None + return self._node.uom[0] + return None @property def state(self) -> str: diff --git a/homeassistant/components/sensor/kira.py b/homeassistant/components/sensor/kira.py index b5d3073ea9a06f..19566100f99534 100644 --- a/homeassistant/components/sensor/kira.py +++ b/homeassistant/components/sensor/kira.py @@ -18,7 +18,6 @@ CONF_SENSOR = 'sensor' -# pylint: disable=unused-argument, too-many-function-args def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a Kira sensor.""" if discovery_info is not None: diff --git a/homeassistant/components/sensor/knx.py b/homeassistant/components/sensor/knx.py index 8eeb75fb0f17d6..925b16cb4c7d20 100644 --- a/homeassistant/components/sensor/knx.py +++ b/homeassistant/components/sensor/knx.py @@ -73,7 +73,6 @@ def async_register_callbacks(self): """Register callbacks to update hass after device was changed.""" async def after_update_callback(device): """Call after device was updated.""" - # pylint: disable=unused-argument await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) diff --git a/homeassistant/components/sensor/lastfm.py b/homeassistant/components/sensor/lastfm.py index 9fec4b4b5e3651..45eddee9f7e174 100644 --- a/homeassistant/components/sensor/lastfm.py +++ b/homeassistant/components/sensor/lastfm.py @@ -13,7 +13,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pylast==2.2.0'] +REQUIREMENTS = ['pylast==2.4.0'] ATTR_LAST_PLAYED = 'last_played' ATTR_PLAY_COUNT = 'play_count' @@ -29,7 +29,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Last.fm platform.""" import pylast as lastfm @@ -69,7 +68,6 @@ def state(self): """Return the state of the sensor.""" return self._state - # pylint: disable=no-member def update(self): """Update device state.""" self._cover = self._user.get_image() diff --git a/homeassistant/components/sensor/linux_battery.py b/homeassistant/components/sensor/linux_battery.py index aad8c2f7a92cb3..e7b8bf600a44d8 100644 --- a/homeassistant/components/sensor/linux_battery.py +++ b/homeassistant/components/sensor/linux_battery.py @@ -10,15 +10,14 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME, DEVICE_CLASS_BATTERY -from homeassistant.helpers.entity import Entity +from homeassistant.const import ATTR_NAME, CONF_NAME, DEVICE_CLASS_BATTERY import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity REQUIREMENTS = ['batinfo==0.4.2'] _LOGGER = logging.getLogger(__name__) -ATTR_NAME = 'name' ATTR_PATH = 'path' ATTR_ALARM = 'alarm' ATTR_CAPACITY = 'capacity' diff --git a/homeassistant/components/sensor/london_air.py b/homeassistant/components/sensor/london_air.py index 2ffbb91427565d..bbb5993b0644cf 100644 --- a/homeassistant/components/sensor/london_air.py +++ b/homeassistant/components/sensor/london_air.py @@ -71,7 +71,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices(sensors, True) -class APIData(object): +class APIData: """Get the latest data for all authorities.""" def __init__(self): diff --git a/homeassistant/components/sensor/london_underground.py b/homeassistant/components/sensor/london_underground.py index fe13c0db8a7e85..4619eda061123a 100644 --- a/homeassistant/components/sensor/london_underground.py +++ b/homeassistant/components/sensor/london_underground.py @@ -95,7 +95,7 @@ def update(self): self._description = self._data.data[self.name]['Description'] -class TubeData(object): +class TubeData: """Get the latest tube data from TFL.""" def __init__(self): diff --git a/homeassistant/components/sensor/loopenergy.py b/homeassistant/components/sensor/loopenergy.py index 8bf95d4ef6ee16..d888a6c634d655 100644 --- a/homeassistant/components/sensor/loopenergy.py +++ b/homeassistant/components/sensor/loopenergy.py @@ -2,18 +2,18 @@ Support for Loop Energy sensors. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.loop_energy/ +https://home-assistant.io/components/sensor.loopenergy/ """ import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity -from homeassistant.const import ( - CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL) from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.const import ( + CONF_UNIT_SYSTEM_IMPERIAL, CONF_UNIT_SYSTEM_METRIC, + EVENT_HOMEASSISTANT_STOP) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) @@ -47,12 +47,12 @@ vol.Optional(CONF_GAS_TYPE, default=CONF_UNIT_SYSTEM_METRIC): GAS_TYPE_SCHEMA, vol.Optional(CONF_GAS_CALORIFIC, default=DEFAULT_CALORIFIC): - vol.Coerce(float) + vol.Coerce(float), }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ELEC): ELEC_SCHEMA, - vol.Optional(CONF_GAS): GAS_SCHEMA + vol.Optional(CONF_GAS): GAS_SCHEMA, }) @@ -63,7 +63,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): elec_config = config.get(CONF_ELEC) gas_config = config.get(CONF_GAS, {}) - # pylint: disable=too-many-function-args controller = pyloopenergy.LoopEnergy( elec_config.get(CONF_ELEC_SERIAL), elec_config.get(CONF_ELEC_SECRET), diff --git a/homeassistant/components/sensor/luftdaten.py b/homeassistant/components/sensor/luftdaten.py index c5e0b12b0e0924..c9bc7205ce6305 100644 --- a/homeassistant/components/sensor/luftdaten.py +++ b/homeassistant/components/sensor/luftdaten.py @@ -4,7 +4,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.luftdaten/ """ -import asyncio from datetime import timedelta import logging @@ -19,7 +18,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['luftdaten==0.1.3'] +REQUIREMENTS = ['luftdaten==0.2.0'] _LOGGER = logging.getLogger(__name__) @@ -59,8 +58,8 @@ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): """Set up the Luftdaten sensor.""" from luftdaten import Luftdaten @@ -71,7 +70,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): session = async_get_clientsession(hass) luftdaten = LuftdatenData(Luftdaten(sensor_id, hass.loop, session)) - yield from luftdaten.async_update() + await luftdaten.async_update() if luftdaten.data is None: _LOGGER.error("Sensor is not available: %s", sensor_id) @@ -138,7 +137,7 @@ async def async_update(self): await self.luftdaten.async_update() -class LuftdatenData(object): +class LuftdatenData: """Class for handling the data retrieval.""" def __init__(self, data): diff --git a/homeassistant/components/sensor/lyft.py b/homeassistant/components/sensor/lyft.py index c2f6412049cda7..57e5f1c6b02c88 100644 --- a/homeassistant/components/sensor/lyft.py +++ b/homeassistant/components/sensor/lyft.py @@ -183,7 +183,7 @@ def update(self): estimate.get('estimated_cost_cents_max', 0)) / 2) / 100) -class LyftEstimate(object): +class LyftEstimate: """The class for handling the time and price estimate.""" def __init__(self, session, start_latitude, start_longitude, diff --git a/homeassistant/components/sensor/magicseaweed.py b/homeassistant/components/sensor/magicseaweed.py new file mode 100644 index 00000000000000..02c61024e30c5f --- /dev/null +++ b/homeassistant/components/sensor/magicseaweed.py @@ -0,0 +1,201 @@ +""" +Support for magicseaweed data from magicseaweed.com. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.magicseaweed/ +""" +from datetime import timedelta +import logging +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import ( + CONF_API_KEY, CONF_NAME, CONF_MONITORED_CONDITIONS, ATTR_ATTRIBUTION) +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['magicseaweed==1.0.0'] + +_LOGGER = logging.getLogger(__name__) + +CONF_HOURS = 'hours' +CONF_SPOT_ID = 'spot_id' +CONF_UNITS = 'units' +CONF_UPDATE_INTERVAL = 'update_interval' + +DEFAULT_UNIT = 'us' +DEFAULT_NAME = 'MSW' +DEFAULT_ATTRIBUTION = "Data provided by magicseaweed.com" + +ICON = 'mdi:waves' + +HOURS = ['12AM', '3AM', '6AM', '9AM', '12PM', '3PM', '6PM', '9PM'] + +SENSOR_TYPES = { + 'max_breaking_swell': ['Max'], + 'min_breaking_swell': ['Min'], + 'swell_forecast': ['Forecast'], +} + +UNITS = ['eu', 'uk', 'us'] + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Required(CONF_API_KEY): cv.string, + vol.Required(CONF_SPOT_ID): vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_HOURS, default=None): + vol.All(cv.ensure_list, [vol.In(HOURS)]), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNITS): vol.In(UNITS), +}) + +# Return cached results if last scan was less then this time ago. +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Magicseaweed sensor.""" + name = config.get(CONF_NAME) + spot_id = config[CONF_SPOT_ID] + api_key = config[CONF_API_KEY] + hours = config.get(CONF_HOURS) + + if CONF_UNITS in config: + units = config.get(CONF_UNITS) + elif hass.config.units.is_metric: + units = UNITS[0] + else: + units = UNITS[2] + + forecast_data = MagicSeaweedData( + api_key=api_key, + spot_id=spot_id, + units=units) + forecast_data.update() + + # If connection failed don't setup platform. + if forecast_data.currently is None or forecast_data.hourly is None: + return + + sensors = [] + for variable in config[CONF_MONITORED_CONDITIONS]: + sensors.append(MagicSeaweedSensor(forecast_data, variable, name, + units)) + if 'forecast' not in variable and hours is not None: + for hour in hours: + sensors.append(MagicSeaweedSensor( + forecast_data, variable, name, units, hour)) + add_devices(sensors, True) + + +class MagicSeaweedSensor(Entity): + """Implementation of a MagicSeaweed sensor.""" + + def __init__(self, forecast_data, sensor_type, name, unit_system, + hour=None): + """Initialize the sensor.""" + self.client_name = name + self.data = forecast_data + self.hour = hour + self.type = sensor_type + self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._name = SENSOR_TYPES[sensor_type][0] + self._icon = None + self._state = None + self._unit_system = unit_system + self._unit_of_measurement = None + + @property + def name(self): + """Return the name of the sensor.""" + if self.hour is None and 'forecast' in self.type: + return "{} {}".format(self.client_name, self._name) + if self.hour is None: + return "Current {} {}".format(self.client_name, self._name) + return "{} {} {}".format( + self.hour, self.client_name, self._name) + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_system(self): + """Return the unit system of this entity.""" + return self._unit_system + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity, if any.""" + return self._unit_of_measurement + + @property + def icon(self): + """Return the entity weather icon, if any.""" + return ICON + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attrs + + def update(self): + """Get the latest data from Magicseaweed and updates the states.""" + self.data.update() + if self.hour is None: + forecast = self.data.currently + else: + forecast = self.data.hourly[self.hour] + + self._unit_of_measurement = forecast.swell_unit + if self.type == 'min_breaking_swell': + self._state = forecast.swell_minBreakingHeight + elif self.type == 'max_breaking_swell': + self._state = forecast.swell_maxBreakingHeight + elif self.type == 'swell_forecast': + summary = "{} - {}".format( + forecast.swell_minBreakingHeight, + forecast.swell_maxBreakingHeight) + self._state = summary + if self.hour is None: + for hour, data in self.data.hourly.items(): + occurs = hour + hr_summary = "{} - {} {}".format( + data.swell_minBreakingHeight, + data.swell_maxBreakingHeight, + data.swell_unit) + self._attrs[occurs] = hr_summary + + if self.type != 'swell_forecast': + self._attrs.update(forecast.attrs) + + +class MagicSeaweedData: + """Get the latest data from MagicSeaweed.""" + + def __init__(self, api_key, spot_id, units): + """Initialize the data object.""" + import magicseaweed + self._msw = magicseaweed.MSW_Forecast(api_key, spot_id, + None, units) + self.currently = None + self.hourly = {} + + # Apply throttling to methods using configured interval + self.update = Throttle(MIN_TIME_BETWEEN_UPDATES)(self._update) + + def _update(self): + """Get the latest data from MagicSeaweed.""" + try: + forecasts = self._msw.get_future() + self.currently = forecasts.data[0] + for forecast in forecasts.data[:8]: + hour = dt_util.utc_from_timestamp( + forecast.localTimestamp).strftime("%-I%p") + self.hourly[hour] = forecast + except ConnectionError: + _LOGGER.error("Unable to retrieve data from Magicseaweed") diff --git a/homeassistant/components/sensor/metoffice.py b/homeassistant/components/sensor/metoffice.py index b6366de6432094..ec3d3f47ba7f30 100644 --- a/homeassistant/components/sensor/metoffice.py +++ b/homeassistant/components/sensor/metoffice.py @@ -174,7 +174,7 @@ def update(self): self.data.update() -class MetOfficeCurrentData(object): +class MetOfficeCurrentData: """Get data from Datapoint.""" def __init__(self, hass, datapoint, site): diff --git a/homeassistant/components/sensor/mfi.py b/homeassistant/components/sensor/mfi.py index ecea0815e79df6..f575768b505df0 100644 --- a/homeassistant/components/sensor/mfi.py +++ b/homeassistant/components/sensor/mfi.py @@ -33,6 +33,7 @@ SENSOR_MODELS = [ 'Ubiquiti mFi-THS', 'Ubiquiti mFi-CS', + 'Ubiquiti mFi-DS', 'Outlet', 'Input Analog', 'Input Digital', @@ -48,7 +49,6 @@ }) -# pylint: disable=unused-variable def setup_platform(hass, config, add_devices, discovery_info=None): """Set up mFi sensors.""" host = config.get(CONF_HOST) @@ -96,7 +96,7 @@ def state(self): tag = None if tag is None: return STATE_OFF - elif self._port.model == 'Input Digital': + if self._port.model == 'Input Digital': return STATE_ON if self._port.value > 0 else STATE_OFF digits = DIGITS.get(self._port.tag, 0) return round(self._port.value, digits) @@ -111,9 +111,9 @@ def unit_of_measurement(self): if tag == 'temperature': return TEMP_CELSIUS - elif tag == 'active_pwr': + if tag == 'active_pwr': return 'Watts' - elif self._port.model == 'Input Digital': + if self._port.model == 'Input Digital': return 'State' return tag diff --git a/homeassistant/components/sensor/mhz19.py b/homeassistant/components/sensor/mhz19.py index cd559d3bbd26cf..60f6598ab21064 100644 --- a/homeassistant/components/sensor/mhz19.py +++ b/homeassistant/components/sensor/mhz19.py @@ -117,7 +117,7 @@ def device_state_attributes(self): return result -class MHZClient(object): +class MHZClient: """Get the latest data from the MH-Z sensor.""" def __init__(self, co2sensor, serial): diff --git a/homeassistant/components/sensor/miflora.py b/homeassistant/components/sensor/miflora.py index f1f8adab062e0a..6f50a57b3ab034 100644 --- a/homeassistant/components/sensor/miflora.py +++ b/homeassistant/components/sensor/miflora.py @@ -62,7 +62,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the MiFlora sensor.""" from miflora import miflora_poller try: - import bluepy.btle # noqa: F401 # pylint: disable=unused-variable + import bluepy.btle # noqa: F401 pylint: disable=unused-variable from btlewrap import BluepyBackend backend = BluepyBackend except ImportError: diff --git a/homeassistant/components/sensor/min_max.py b/homeassistant/components/sensor/min_max.py index 912bf7b750010b..f3a30724732726 100644 --- a/homeassistant/components/sensor/min_max.py +++ b/homeassistant/components/sensor/min_max.py @@ -124,7 +124,6 @@ def __init__(self, hass, entity_ids, name, sensor_type, round_digits): self.states = {} @callback - # pylint: disable=invalid-name def async_min_max_sensor_state_listener(entity, old_state, new_state): """Handle the sensor state changes.""" if new_state.state is None or new_state.state in STATE_UNKNOWN: diff --git a/homeassistant/components/sensor/mitemp_bt.py b/homeassistant/components/sensor/mitemp_bt.py index 3628765293b433..249a69578db957 100644 --- a/homeassistant/components/sensor/mitemp_bt.py +++ b/homeassistant/components/sensor/mitemp_bt.py @@ -60,7 +60,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the MiTempBt sensor.""" from mitemp_bt import mitemp_bt_poller try: - import bluepy.btle # noqa: F401 # pylint: disable=unused-variable + import bluepy.btle # noqa: F401 pylint: disable=unused-variable from btlewrap import BluepyBackend backend = BluepyBackend except ImportError: diff --git a/homeassistant/components/sensor/modbus.py b/homeassistant/components/sensor/modbus.py index c4014fbd1dd038..5f404ccd5f7f32 100644 --- a/homeassistant/components/sensor/modbus.py +++ b/homeassistant/components/sensor/modbus.py @@ -9,7 +9,7 @@ import voluptuous as vol -import homeassistant.components.modbus as modbus +from homeassistant.components import modbus from homeassistant.const import ( CONF_NAME, CONF_OFFSET, CONF_UNIT_OF_MEASUREMENT, CONF_SLAVE, CONF_STRUCTURE) diff --git a/homeassistant/components/sensor/modem_callerid.py b/homeassistant/components/sensor/modem_callerid.py index f80ea5853c8a51..58e8becd6bb603 100644 --- a/homeassistant/components/sensor/modem_callerid.py +++ b/homeassistant/components/sensor/modem_callerid.py @@ -95,7 +95,6 @@ def _stop_modem(self, event): if self.modem: self.modem.close() self.modem = None - return def _incomingcallcallback(self, newstate): """Handle new states.""" @@ -117,4 +116,3 @@ def _incomingcallcallback(self, newstate): elif newstate == self.modem.STATE_IDLE: self._state = STATE_IDLE self.schedule_update_ha_state() - return diff --git a/homeassistant/components/sensor/mold_indicator.py b/homeassistant/components/sensor/mold_indicator.py index 057718400c4b59..319185923cd1fd 100644 --- a/homeassistant/components/sensor/mold_indicator.py +++ b/homeassistant/components/sensor/mold_indicator.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -import homeassistant.util as util +from homeassistant import util from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import track_state_change from homeassistant.const import ( @@ -41,7 +41,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up MoldIndicator sensor.""" name = config.get(CONF_NAME, DEFAULT_NAME) @@ -108,11 +107,10 @@ def _update_temp_sensor(state): # convert to celsius if necessary if unit == TEMP_FAHRENHEIT: return util.temperature.fahrenheit_to_celsius(temp) - elif unit == TEMP_CELSIUS: + if unit == TEMP_CELSIUS: return temp - else: - _LOGGER.error("Temp sensor has unsupported unit: %s (allowed: %s, " - "%s)", unit, TEMP_CELSIUS, TEMP_FAHRENHEIT) + _LOGGER.error("Temp sensor has unsupported unit: %s (allowed: %s, " + "%s)", unit, TEMP_CELSIUS, TEMP_FAHRENHEIT) return None diff --git a/homeassistant/components/sensor/moon.py b/homeassistant/components/sensor/moon.py index 75b8a1f72bd9bb..50f4f72078cbcb 100644 --- a/homeassistant/components/sensor/moon.py +++ b/homeassistant/components/sensor/moon.py @@ -4,7 +4,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.moon/ """ -import asyncio import logging import voluptuous as vol @@ -26,8 +25,8 @@ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): """Set up the Moon sensor.""" name = config.get(CONF_NAME) @@ -51,28 +50,27 @@ def name(self): def state(self): """Return the state of the device.""" if self._state == 0: - return 'New moon' - elif self._state < 7: - return 'Waxing crescent' - elif self._state == 7: - return 'First quarter' - elif self._state < 14: - return 'Waxing gibbous' - elif self._state == 14: - return 'Full moon' - elif self._state < 21: - return 'Waning gibbous' - elif self._state == 21: - return 'Last quarter' - return 'Waning crescent' + return 'new_moon' + if self._state < 7: + return 'waxing_crescent' + if self._state == 7: + return 'first_quarter' + if self._state < 14: + return 'waxing_gibbous' + if self._state == 14: + return 'full_moon' + if self._state < 21: + return 'waning_gibbous' + if self._state == 21: + return 'last_quarter' + return 'waning_crescent' @property def icon(self): """Icon to use in the frontend, if any.""" return ICON - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Get the time and updates the states.""" from astral import Astral diff --git a/homeassistant/components/sensor/mopar.py b/homeassistant/components/sensor/mopar.py index 99ea4ef6135ad5..81c48555cfca32 100644 --- a/homeassistant/components/sensor/mopar.py +++ b/homeassistant/components/sensor/mopar.py @@ -41,7 +41,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Mopar platform.""" import motorparts @@ -71,7 +70,7 @@ def _handle_service(service): for index, _ in enumerate(data.vehicles)], True) -class MoparData(object): +class MoparData: """Container for Mopar vehicle data. Prevents session expiry re-login race condition. diff --git a/homeassistant/components/sensor/mqtt.py b/homeassistant/components/sensor/mqtt.py index 997fd312a6a5b4..0e29c55d39d16e 100644 --- a/homeassistant/components/sensor/mqtt.py +++ b/homeassistant/components/sensor/mqtt.py @@ -20,7 +20,7 @@ CONF_FORCE_UPDATE, CONF_NAME, CONF_VALUE_TEMPLATE, STATE_UNKNOWN, CONF_UNIT_OF_MEASUREMENT, CONF_ICON, CONF_DEVICE_CLASS) from homeassistant.helpers.entity import Entity -import homeassistant.components.mqtt as mqtt +from homeassistant.components import mqtt import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import HomeAssistantType, ConfigType from homeassistant.helpers.event import async_track_point_in_utc_time diff --git a/homeassistant/components/sensor/mqtt_room.py b/homeassistant/components/sensor/mqtt_room.py index 2c0f8eb4d5a7c0..2a61c1143eeec5 100644 --- a/homeassistant/components/sensor/mqtt_room.py +++ b/homeassistant/components/sensor/mqtt_room.py @@ -11,7 +11,7 @@ import voluptuous as vol -import homeassistant.components.mqtt as mqtt +from homeassistant.components import mqtt import homeassistant.helpers.config_validation as cv from homeassistant.components.mqtt import CONF_STATE_TOPIC from homeassistant.components.sensor import PLATFORM_SCHEMA diff --git a/homeassistant/components/sensor/mvglive.py b/homeassistant/components/sensor/mvglive.py index 46d79c1121ba84..e066bb5e0b9b4f 100644 --- a/homeassistant/components/sensor/mvglive.py +++ b/homeassistant/components/sensor/mvglive.py @@ -7,6 +7,7 @@ import logging from datetime import timedelta +from copy import deepcopy import voluptuous as vol import homeassistant.helpers.config_validation as cv @@ -28,6 +29,7 @@ CONF_LINES = 'lines' CONF_PRODUCTS = 'products' CONF_TIMEOFFSET = 'timeoffset' +CONF_NUMBER = 'number' DEFAULT_PRODUCT = ['U-Bahn', 'Tram', 'Bus', 'S-Bahn'] @@ -52,6 +54,7 @@ vol.Optional(CONF_PRODUCTS, default=DEFAULT_PRODUCT): cv.ensure_list_csv, vol.Optional(CONF_TIMEOFFSET, default=0): cv.positive_int, + vol.Optional(CONF_NUMBER, default=1): cv.positive_int, vol.Optional(CONF_NAME): cv.string}] }) @@ -68,21 +71,21 @@ def setup_platform(hass, config, add_devices, discovery_info=None): nextdeparture.get(CONF_LINES), nextdeparture.get(CONF_PRODUCTS), nextdeparture.get(CONF_TIMEOFFSET), + nextdeparture.get(CONF_NUMBER), nextdeparture.get(CONF_NAME))) add_devices(sensors, True) -# pylint: disable=too-few-public-methods class MVGLiveSensor(Entity): """Implementation of an MVG Live sensor.""" def __init__(self, station, destinations, directions, - lines, products, timeoffset, name): + lines, products, timeoffset, number, name): """Initialize the sensor.""" self._station = station self._name = name self.data = MVGLiveData(station, destinations, directions, - lines, products, timeoffset) + lines, products, timeoffset, number) self._state = STATE_UNKNOWN self._icon = ICONS['-'] @@ -99,9 +102,14 @@ def state(self): return self._state @property - def state_attributes(self): + def device_state_attributes(self): """Return the state attributes.""" - return self.data.departures + dep = self.data.departures + if not dep: + return None + attr = dep[0] # next depature attributes + attr['departures'] = deepcopy(dep) # all departures dictionary + return attr @property def icon(self): @@ -120,15 +128,15 @@ def update(self): self._state = '-' self._icon = ICONS['-'] else: - self._state = self.data.departures.get('time', '-') - self._icon = ICONS[self.data.departures.get('product', '-')] + self._state = self.data.departures[0].get('time', '-') + self._icon = ICONS[self.data.departures[0].get('product', '-')] -class MVGLiveData(object): +class MVGLiveData: """Pull data from the mvg-live.de web page.""" def __init__(self, station, destinations, directions, - lines, products, timeoffset): + lines, products, timeoffset, number): """Initialize the sensor.""" import MVGLive self._station = station @@ -137,25 +145,30 @@ def __init__(self, station, destinations, directions, self._lines = lines self._products = products self._timeoffset = timeoffset + self._number = number self._include_ubahn = True if 'U-Bahn' in self._products else False self._include_tram = True if 'Tram' in self._products else False self._include_bus = True if 'Bus' in self._products else False self._include_sbahn = True if 'S-Bahn' in self._products else False self.mvg = MVGLive.MVGLive() - self.departures = {} + self.departures = [] def update(self): """Update the connection data.""" try: _departures = self.mvg.getlivedata( - station=self._station, ubahn=self._include_ubahn, - tram=self._include_tram, bus=self._include_bus, + station=self._station, + timeoffset=self._timeoffset, + ubahn=self._include_ubahn, + tram=self._include_tram, + bus=self._include_bus, sbahn=self._include_sbahn) except ValueError: - self.departures = {} + self.departures = [] _LOGGER.warning("Returned data not understood") return - for _departure in _departures: + self.departures = [] + for i, _departure in enumerate(_departures): # find the first departure meeting the criteria if ('' not in self._destinations[:1] and _departure['destination'] not in self._destinations): @@ -174,5 +187,6 @@ def update(self): 'product']: _nextdep[k] = _departure.get(k, '') _nextdep['time'] = int(_nextdep['time']) - self.departures = _nextdep - break + self.departures.append(_nextdep) + if i == self._number - 1: + break diff --git a/homeassistant/components/sensor/mychevy.py b/homeassistant/components/sensor/mychevy.py index bdbffc46ca8ad4..ef7c7ba86083f3 100644 --- a/homeassistant/components/sensor/mychevy.py +++ b/homeassistant/components/sensor/mychevy.py @@ -17,14 +17,15 @@ from homeassistant.helpers.icon import icon_for_battery_level from homeassistant.util import slugify -BATTERY_SENSOR = "percent" +BATTERY_SENSOR = "batteryLevel" SENSORS = [ - EVSensorConfig("Mileage", "mileage", "miles", "mdi:speedometer"), - EVSensorConfig("Range", "range", "miles", "mdi:speedometer"), - EVSensorConfig("Charging", "charging"), - EVSensorConfig("Charge Mode", "charge_mode"), - EVSensorConfig("EVCharge", BATTERY_SENSOR, "%", "mdi:battery") + EVSensorConfig("Mileage", "totalMiles", "miles", "mdi:speedometer"), + EVSensorConfig("Electric Range", "electricRange", "miles", + "mdi:speedometer"), + EVSensorConfig("Charged By", "estimatedFullChargeBy"), + EVSensorConfig("Charge Mode", "chargeMode"), + EVSensorConfig("Battery Level", BATTERY_SENSOR, "%", "mdi:battery") ] _LOGGER = logging.getLogger(__name__) @@ -38,7 +39,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): hub = hass.data[MYCHEVY_DOMAIN] sensors = [MyChevyStatus()] for sconfig in SENSORS: - sensors.append(EVSensor(hub, sconfig)) + for car in hub.cars: + sensors.append(EVSensor(hub, sconfig, car.vid)) add_devices(sensors) @@ -112,7 +114,7 @@ class EVSensor(Entity): """ - def __init__(self, connection, config): + def __init__(self, connection, config, car_vid): """Initialize sensor with car connection.""" self._conn = connection self._name = config.name @@ -120,9 +122,12 @@ def __init__(self, connection, config): self._unit_of_measurement = config.unit_of_measurement self._icon = config.icon self._state = None + self._car_vid = car_vid self.entity_id = ENTITY_ID_FORMAT.format( - '{}_{}'.format(MYCHEVY_DOMAIN, slugify(self._name))) + '{}_{}_{}'.format(MYCHEVY_DOMAIN, + slugify(self._car.name), + slugify(self._name))) @asyncio.coroutine def async_added_to_hass(self): @@ -130,6 +135,11 @@ def async_added_to_hass(self): self.hass.helpers.dispatcher.async_dispatcher_connect( UPDATE_TOPIC, self.async_update_callback) + @property + def _car(self): + """Return the car.""" + return self._conn.get_car(self._car_vid) + @property def icon(self): """Return the icon.""" @@ -145,8 +155,8 @@ def name(self): @callback def async_update_callback(self): """Update state.""" - if self._conn.car is not None: - self._state = getattr(self._conn.car, self._attr, None) + if self._car is not None: + self._state = getattr(self._car, self._attr, None) self.async_schedule_update_ha_state() @property diff --git a/homeassistant/components/sensor/mysensors.py b/homeassistant/components/sensor/mysensors.py index 1add4157f0e952..2fbfc0e97a4169 100644 --- a/homeassistant/components/sensor/mysensors.py +++ b/homeassistant/components/sensor/mysensors.py @@ -42,7 +42,7 @@ async def async_setup_platform( async_add_devices=async_add_devices) -class MySensorsSensor(mysensors.MySensorsEntity): +class MySensorsSensor(mysensors.device.MySensorsEntity): """Representation of a MySensors Sensor child node.""" @property diff --git a/homeassistant/components/sensor/nest.py b/homeassistant/components/sensor/nest.py index 9ce50dc61e5bd3..d2e1501ad7e961 100644 --- a/homeassistant/components/sensor/nest.py +++ b/homeassistant/components/sensor/nest.py @@ -4,53 +4,70 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.nest/ """ -from itertools import chain import logging -from homeassistant.components.nest import DATA_NEST -from homeassistant.helpers.entity import Entity +from homeassistant.components.nest import ( + DATA_NEST, DATA_NEST_CONFIG, CONF_SENSORS, NestSensorDevice) from homeassistant.const import ( TEMP_CELSIUS, TEMP_FAHRENHEIT, CONF_MONITORED_CONDITIONS, - DEVICE_CLASS_TEMPERATURE) + DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_HUMIDITY) DEPENDENCIES = ['nest'] -SENSOR_TYPES = ['humidity', - 'operation_mode', - 'hvac_state'] -SENSOR_TYPES_DEPRECATED = ['last_ip', - 'local_ip', - 'last_connection'] +SENSOR_TYPES = ['humidity', 'operation_mode', 'hvac_state'] + +TEMP_SENSOR_TYPES = ['temperature', 'target'] -DEPRECATED_WEATHER_VARS = {'weather_humidity': 'humidity', - 'weather_temperature': 'temperature', - 'weather_condition': 'condition', - 'wind_speed': 'kph', - 'wind_direction': 'direction'} +PROTECT_SENSOR_TYPES = ['co_status', + 'smoke_status', + 'battery_health', + # color_status: "gray", "green", "yellow", "red" + 'color_status'] -SENSOR_UNITS = {'humidity': '%', 'temperature': '°C'} +STRUCTURE_SENSOR_TYPES = ['eta'] -PROTECT_VARS = ['co_status', 'smoke_status', 'battery_health'] +# security_state is structure level sensor, but only meaningful when +# Nest Cam exist +STRUCTURE_CAMERA_SENSOR_TYPES = ['security_state'] -PROTECT_VARS_DEPRECATED = ['battery_level'] +_VALID_SENSOR_TYPES = SENSOR_TYPES + TEMP_SENSOR_TYPES + PROTECT_SENSOR_TYPES \ + + STRUCTURE_SENSOR_TYPES + STRUCTURE_CAMERA_SENSOR_TYPES -SENSOR_TEMP_TYPES = ['temperature', 'target'] +SENSOR_UNITS = {'humidity': '%'} -_SENSOR_TYPES_DEPRECATED = SENSOR_TYPES_DEPRECATED \ - + list(DEPRECATED_WEATHER_VARS.keys()) + PROTECT_VARS_DEPRECATED +SENSOR_DEVICE_CLASSES = {'humidity': DEVICE_CLASS_HUMIDITY} -_VALID_SENSOR_TYPES = SENSOR_TYPES + SENSOR_TEMP_TYPES + PROTECT_VARS +VARIABLE_NAME_MAPPING = {'eta': 'eta_begin', 'operation_mode': 'mode'} + +SENSOR_TYPES_DEPRECATED = ['last_ip', + 'local_ip', + 'last_connection', + 'battery_level'] + +DEPRECATED_WEATHER_VARS = ['weather_humidity', + 'weather_temperature', + 'weather_condition', + 'wind_speed', + 'wind_direction'] + +_SENSOR_TYPES_DEPRECATED = SENSOR_TYPES_DEPRECATED + DEPRECATED_WEATHER_VARS _LOGGER = logging.getLogger(__name__) def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Nest Sensor.""" - if discovery_info is None: - return + """Set up the Nest Sensor. + + No longer used. + """ + +async def async_setup_entry(hass, entry, async_add_devices): + """Set up a Nest sensor based on a config entry.""" nest = hass.data[DATA_NEST] + discovery_info = hass.data.get(DATA_NEST_CONFIG, {}).get(CONF_SENSORS, {}) + # Add all available sensors if no Nest sensor config is set if discovery_info == {}: conditions = _VALID_SENSOR_TYPES @@ -69,53 +86,43 @@ def setup_platform(hass, config, add_devices, discovery_info=None): "monitored_conditions. See " "https://home-assistant.io/components/" "binary_sensor.nest/ for valid options.") - _LOGGER.error(wstr) - all_sensors = [] - for structure, device in chain(nest.thermostats(), nest.smoke_co_alarms()): - sensors = [NestBasicSensor(structure, device, variable) - for variable in conditions - if variable in SENSOR_TYPES and device.is_thermostat] - sensors += [NestTempSensor(structure, device, variable) - for variable in conditions - if variable in SENSOR_TEMP_TYPES and device.is_thermostat] - sensors += [NestProtectSensor(structure, device, variable) - for variable in conditions - if variable in PROTECT_VARS and device.is_smoke_co_alarm] - all_sensors.extend(sensors) - - add_devices(all_sensors, True) - - -class NestSensor(Entity): - """Representation of a Nest sensor.""" - - def __init__(self, structure, device, variable): - """Initialize the sensor.""" - self.structure = structure - self.device = device - self.variable = variable - - # device specific - self._location = self.device.where - self._name = "{} {}".format(self.device.name_long, - self.variable.replace("_", " ")) - self._state = None - self._unit = None - - @property - def name(self): - """Return the name of the nest, if any.""" - return self._name - - @property - def unit_of_measurement(self): - """Return the unit the value is expressed in.""" - return self._unit - - -class NestBasicSensor(NestSensor): + def get_sensors(): + """Get the Nest sensors.""" + all_sensors = [] + for structure in nest.structures(): + all_sensors += [NestBasicSensor(structure, None, variable) + for variable in conditions + if variable in STRUCTURE_SENSOR_TYPES] + + for structure, device in nest.thermostats(): + all_sensors += [NestBasicSensor(structure, device, variable) + for variable in conditions + if variable in SENSOR_TYPES] + all_sensors += [NestTempSensor(structure, device, variable) + for variable in conditions + if variable in TEMP_SENSOR_TYPES] + + for structure, device in nest.smoke_co_alarms(): + all_sensors += [NestBasicSensor(structure, device, variable) + for variable in conditions + if variable in PROTECT_SENSOR_TYPES] + + structures_has_camera = {} + for structure, device in nest.cameras(): + structures_has_camera[structure] = True + for structure in structures_has_camera: + all_sensors += [NestBasicSensor(structure, None, variable) + for variable in conditions + if variable in STRUCTURE_CAMERA_SENSOR_TYPES] + + return all_sensors + + async_add_devices(await hass.async_add_job(get_sensors), True) + + +class NestBasicSensor(NestSensorDevice): """Representation a basic Nest sensor.""" @property @@ -123,17 +130,28 @@ def state(self): """Return the state of the sensor.""" return self._state + @property + def device_class(self): + """Return the device class of the sensor.""" + return SENSOR_DEVICE_CLASSES.get(self.variable) + def update(self): """Retrieve latest state.""" - self._unit = SENSOR_UNITS.get(self.variable, None) - - if self.variable == 'operation_mode': - self._state = getattr(self.device, "mode") + self._unit = SENSOR_UNITS.get(self.variable) + + if self.variable in VARIABLE_NAME_MAPPING: + self._state = getattr(self.device, + VARIABLE_NAME_MAPPING[self.variable]) + elif self.variable in PROTECT_SENSOR_TYPES \ + and self.variable != 'color_status': + # keep backward compatibility + state = getattr(self.device, self.variable) + self._state = state.capitalize() if state is not None else None else: self._state = getattr(self.device, self.variable) -class NestTempSensor(NestSensor): +class NestTempSensor(NestSensorDevice): """Representation of a Nest Temperature sensor.""" @property @@ -162,16 +180,3 @@ def update(self): self._state = "%s-%s" % (int(low), int(high)) else: self._state = round(temp, 1) - - -class NestProtectSensor(NestSensor): - """Return the state of nest protect.""" - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - def update(self): - """Retrieve latest state.""" - self._state = getattr(self.device, self.variable).capitalize() diff --git a/homeassistant/components/sensor/netatmo.py b/homeassistant/components/sensor/netatmo.py index 4aeba082e55bc2..3e3f7ce9486ebd 100644 --- a/homeassistant/components/sensor/netatmo.py +++ b/homeassistant/components/sensor/netatmo.py @@ -5,14 +5,16 @@ https://home-assistant.io/components/sensor.netatmo/ """ import logging -from datetime import timedelta +from time import time +import threading import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import TEMP_CELSIUS, STATE_UNKNOWN +from homeassistant.const import ( + TEMP_CELSIUS, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, + STATE_UNKNOWN) from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -22,32 +24,33 @@ DEPENDENCIES = ['netatmo'] -# NetAtmo Data is uploaded to server every 10 minutes -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=600) +# This is the NetAtmo data upload interval in seconds +NETATMO_UPDATE_INTERVAL = 600 SENSOR_TYPES = { - 'temperature': ['Temperature', TEMP_CELSIUS, 'mdi:thermometer'], - 'co2': ['CO2', 'ppm', 'mdi:cloud'], - 'pressure': ['Pressure', 'mbar', 'mdi:gauge'], - 'noise': ['Noise', 'dB', 'mdi:volume-high'], - 'humidity': ['Humidity', '%', 'mdi:water-percent'], - 'rain': ['Rain', 'mm', 'mdi:weather-rainy'], - 'sum_rain_1': ['sum_rain_1', 'mm', 'mdi:weather-rainy'], - 'sum_rain_24': ['sum_rain_24', 'mm', 'mdi:weather-rainy'], - 'battery_vp': ['Battery', '', 'mdi:battery'], - 'battery_lvl': ['Battery_lvl', '', 'mdi:battery'], - 'min_temp': ['Min Temp.', TEMP_CELSIUS, 'mdi:thermometer'], - 'max_temp': ['Max Temp.', TEMP_CELSIUS, 'mdi:thermometer'], - 'windangle': ['Angle', '', 'mdi:compass'], - 'windangle_value': ['Angle Value', 'º', 'mdi:compass'], - 'windstrength': ['Strength', 'km/h', 'mdi:weather-windy'], - 'gustangle': ['Gust Angle', '', 'mdi:compass'], - 'gustangle_value': ['Gust Angle Value', 'º', 'mdi:compass'], - 'guststrength': ['Gust Strength', 'km/h', 'mdi:weather-windy'], - 'rf_status': ['Radio', '', 'mdi:signal'], - 'rf_status_lvl': ['Radio_lvl', '', 'mdi:signal'], - 'wifi_status': ['Wifi', '', 'mdi:wifi'], - 'wifi_status_lvl': ['Wifi_lvl', 'dBm', 'mdi:wifi'] + 'temperature': ['Temperature', TEMP_CELSIUS, None, + DEVICE_CLASS_TEMPERATURE], + 'co2': ['CO2', 'ppm', 'mdi:cloud', None], + 'pressure': ['Pressure', 'mbar', 'mdi:gauge', None], + 'noise': ['Noise', 'dB', 'mdi:volume-high', None], + 'humidity': ['Humidity', '%', None, DEVICE_CLASS_HUMIDITY], + 'rain': ['Rain', 'mm', 'mdi:weather-rainy', None], + 'sum_rain_1': ['sum_rain_1', 'mm', 'mdi:weather-rainy', None], + 'sum_rain_24': ['sum_rain_24', 'mm', 'mdi:weather-rainy', None], + 'battery_vp': ['Battery', '', 'mdi:battery', None], + 'battery_lvl': ['Battery_lvl', '', 'mdi:battery', None], + 'min_temp': ['Min Temp.', TEMP_CELSIUS, 'mdi:thermometer', None], + 'max_temp': ['Max Temp.', TEMP_CELSIUS, 'mdi:thermometer', None], + 'windangle': ['Angle', '', 'mdi:compass', None], + 'windangle_value': ['Angle Value', 'º', 'mdi:compass', None], + 'windstrength': ['Strength', 'km/h', 'mdi:weather-windy', None], + 'gustangle': ['Gust Angle', '', 'mdi:compass', None], + 'gustangle_value': ['Gust Angle Value', 'º', 'mdi:compass', None], + 'guststrength': ['Gust Strength', 'km/h', 'mdi:weather-windy', None], + 'rf_status': ['Radio', '', 'mdi:signal', None], + 'rf_status_lvl': ['Radio_lvl', '', 'mdi:signal', None], + 'wifi_status': ['Wifi', '', 'mdi:wifi', None], + 'wifi_status_lvl': ['Wifi_lvl', 'dBm', 'mdi:wifi', None], } MODULE_SCHEMA = vol.Schema({ @@ -67,17 +70,17 @@ def setup_platform(hass, config, add_devices, discovery_info=None): data = NetAtmoData(netatmo.NETATMO_AUTH, config.get(CONF_STATION, None)) dev = [] - import lnetatmo + import pyatmo try: if CONF_MODULES in config: # Iterate each module for module_name, monitored_conditions in\ config[CONF_MODULES].items(): - # Test if module exist """ + # Test if module exists if module_name not in data.get_module_names(): _LOGGER.error('Module name: "%s" not found', module_name) continue - # Only create sensor for monitored """ + # Only create sensors for monitored properties for variable in monitored_conditions: dev.append(NetAtmoSensor(data, module_name, variable)) else: @@ -89,7 +92,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): else: _LOGGER.warning("Ignoring unknown var %s for mod %s", variable, module_name) - except lnetatmo.NoDevice: + except pyatmo.NoDevice: return None add_devices(dev, True) @@ -106,7 +109,9 @@ def __init__(self, netatmo_data, module_name, sensor_type): self.module_name = module_name self.type = sensor_type self._state = None - self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._device_class = SENSOR_TYPES[self.type][3] + self._icon = SENSOR_TYPES[self.type][2] + self._unit_of_measurement = SENSOR_TYPES[self.type][1] module_id = self.netatmo_data.\ station_data.moduleByName(module=module_name)['_id'] self.module_id = module_id[1] @@ -119,7 +124,12 @@ def name(self): @property def icon(self): """Icon to use in the frontend, if any.""" - return SENSOR_TYPES[self.type][2] + return self._icon + + @property + def device_class(self): + """Return the device class of the sensor.""" + return self._device_class @property def state(self): @@ -277,7 +287,7 @@ def update(self): self._state = "Full" -class NetAtmoData(object): +class NetAtmoData: """Get the latest data from NetAtmo.""" def __init__(self, auth, station): @@ -286,20 +296,57 @@ def __init__(self, auth, station): self.data = None self.station_data = None self.station = station + self._next_update = time() + self._update_in_progress = threading.Lock() def get_module_names(self): """Return all module available on the API as a list.""" self.update() return self.data.keys() - @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): - """Call the Netatmo API to update the data.""" - import lnetatmo - self.station_data = lnetatmo.WeatherStationData(self.auth) + """Call the Netatmo API to update the data. - if self.station is not None: - self.data = self.station_data.lastData( - station=self.station, exclude=3600) - else: - self.data = self.station_data.lastData(exclude=3600) + This method is not throttled by the builtin Throttle decorator + but with a custom logic, which takes into account the time + of the last update from the cloud. + """ + if time() < self._next_update or \ + not self._update_in_progress.acquire(False): + return + + try: + import pyatmo + self.station_data = pyatmo.WeatherStationData(self.auth) + + if self.station is not None: + self.data = self.station_data.lastData( + station=self.station, exclude=3600) + else: + self.data = self.station_data.lastData(exclude=3600) + + newinterval = 0 + for module in self.data: + if 'When' in self.data[module]: + newinterval = self.data[module]['When'] + break + if newinterval: + # Try and estimate when fresh data will be available + newinterval += NETATMO_UPDATE_INTERVAL - time() + if newinterval > NETATMO_UPDATE_INTERVAL - 30: + newinterval = NETATMO_UPDATE_INTERVAL + else: + if newinterval < NETATMO_UPDATE_INTERVAL / 2: + # Never hammer the NetAtmo API more than + # twice per update interval + newinterval = NETATMO_UPDATE_INTERVAL / 2 + _LOGGER.info( + "NetAtmo refresh interval reset to %d seconds", + newinterval) + else: + # Last update time not found, fall back to default value + newinterval = NETATMO_UPDATE_INTERVAL + + self._next_update = time() + newinterval + finally: + self._update_in_progress.release() diff --git a/homeassistant/components/sensor/netdata.py b/homeassistant/components/sensor/netdata.py index 0d2a542c7bb5b1..488b16113999a3 100644 --- a/homeassistant/components/sensor/netdata.py +++ b/homeassistant/components/sensor/netdata.py @@ -4,154 +4,152 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.netdata/ """ -import logging from datetime import timedelta -from urllib.parse import urlsplit +import logging -import requests import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_PORT, CONF_NAME, CONF_RESOURCES) + CONF_HOST, CONF_ICON, CONF_NAME, CONF_PORT, CONF_RESOURCES) +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['netdata==0.1.2'] _LOGGER = logging.getLogger(__name__) -_RESOURCE = 'api/v1' -_REALTIME = 'before=0&after=-1&options=seconds' + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) + +CONF_ELEMENT = 'element' DEFAULT_HOST = 'localhost' DEFAULT_NAME = 'Netdata' -DEFAULT_PORT = '19999' - -SCAN_INTERVAL = timedelta(minutes=1) - -SENSOR_TYPES = { - 'memory_free': ['RAM Free', 'MiB', 'system.ram', 'free', 1], - 'memory_used': ['RAM Used', 'MiB', 'system.ram', 'used', 1], - 'memory_cached': ['RAM Cached', 'MiB', 'system.ram', 'cached', 1], - 'memory_buffers': ['RAM Buffers', 'MiB', 'system.ram', 'buffers', 1], - 'swap_free': ['Swap Free', 'MiB', 'system.swap', 'free', 1], - 'swap_used': ['Swap Used', 'MiB', 'system.swap', 'used', 1], - 'processes_running': ['Processes Running', 'Count', 'system.processes', - 'running', 0], - 'processes_blocked': ['Processes Blocked', 'Count', 'system.processes', - 'blocked', 0], - 'system_load': ['System Load', '15 min', 'system.load', 'load15', 2], - 'system_io_in': ['System IO In', 'Count', 'system.io', 'in', 0], - 'system_io_out': ['System IO Out', 'Count', 'system.io', 'out', 0], - 'ipv4_in': ['IPv4 In', 'kb/s', 'system.ipv4', 'received', 0], - 'ipv4_out': ['IPv4 Out', 'kb/s', 'system.ipv4', 'sent', 0], - 'disk_free': ['Disk Free', 'GiB', 'disk_space._', 'avail', 2], - 'cpu_iowait': ['CPU IOWait', '%', 'system.cpu', 'iowait', 1], - 'cpu_user': ['CPU User', '%', 'system.cpu', 'user', 1], - 'cpu_system': ['CPU System', '%', 'system.cpu', 'system', 1], - 'cpu_softirq': ['CPU SoftIRQ', '%', 'system.cpu', 'softirq', 1], - 'cpu_guest': ['CPU Guest', '%', 'system.cpu', 'guest', 1], - 'uptime': ['Uptime', 's', 'system.uptime', 'uptime', 0], - 'packets_received': ['Packets Received', 'packets/s', 'ipv4.packets', - 'received', 0], - 'packets_sent': ['Packets Sent', 'packets/s', 'ipv4.packets', - 'sent', 0], - 'connections': ['Active Connections', 'Count', - 'netfilter.conntrack_sockets', 'connections', 0] -} +DEFAULT_PORT = 19999 + +DEFAULT_ICON = 'mdi:desktop-classic' + +RESOURCE_SCHEMA = vol.Any({ + vol.Required(CONF_ELEMENT): cv.string, + vol.Optional(CONF_ICON, default=DEFAULT_ICON): cv.icon, + vol.Optional(CONF_NAME): cv.string, +}) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_RESOURCES, default=['memory_free']): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Required(CONF_RESOURCES): vol.Schema({cv.string: RESOURCE_SCHEMA}), }) -# pylint: disable=unused-variable -def setup_platform(hass, config, add_devices, discovery_info=None): +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): """Set up the Netdata sensor.""" + from netdata import Netdata + name = config.get(CONF_NAME) host = config.get(CONF_HOST) port = config.get(CONF_PORT) - url = 'http://{}:{}'.format(host, port) - data_url = '{}/{}/data?chart='.format(url, _RESOURCE) resources = config.get(CONF_RESOURCES) - values = {} - for key, value in sorted(SENSOR_TYPES.items()): - if key in resources: - values.setdefault(value[2], []).append(key) + session = async_get_clientsession(hass) + netdata = NetdataData(Netdata(host, hass.loop, session, port=port)) + await netdata.async_update() + + if netdata.api.metrics is None: + raise PlatformNotReady dev = [] - for chart in values: - rest_url = '{}{}&{}'.format(data_url, chart, _REALTIME) - rest = NetdataData(rest_url) - rest.update() - for sensor_type in values[chart]: - dev.append(NetdataSensor(rest, name, sensor_type)) + for entry, data in resources.items(): + sensor = entry + element = data[CONF_ELEMENT] + sensor_name = icon = None + try: + resource_data = netdata.api.metrics[sensor] + unit = '%' if resource_data['units'] == 'percentage' else \ + resource_data['units'] + if data is not None: + sensor_name = data.get(CONF_NAME) + icon = data.get(CONF_ICON) + except KeyError: + _LOGGER.error("Sensor is not available: %s", sensor) + continue + + dev.append(NetdataSensor( + netdata, name, sensor, sensor_name, element, icon, unit)) - add_devices(dev, True) + async_add_devices(dev, True) class NetdataSensor(Entity): """Implementation of a Netdata sensor.""" - def __init__(self, rest, name, sensor_type): + def __init__( + self, netdata, name, sensor, sensor_name, element, icon, unit): """Initialize the Netdata sensor.""" - self.rest = rest - self.type = sensor_type - self._name = '{} {}'.format(name, SENSOR_TYPES[self.type][0]) - self._precision = SENSOR_TYPES[self.type][4] - self._unit_of_measurement = SENSOR_TYPES[self.type][1] + self.netdata = netdata + self._state = None + self._sensor = sensor + self._element = element + self._sensor_name = self._sensor if sensor_name is None else \ + sensor_name + self._name = name + self._icon = icon + self._unit_of_measurement = unit @property def name(self): """Return the name of the sensor.""" - return self._name + return '{} {}'.format(self._name, self._sensor_name) @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit_of_measurement + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return self._icon + @property def state(self): """Return the state of the resources.""" - value = self.rest.data - - if value is not None: - netdata_id = SENSOR_TYPES[self.type][3] - if netdata_id in value: - return "{0:.{1}f}".format(value[netdata_id], self._precision) - return None + return self._state @property def available(self): """Could the resource be accessed during the last update call.""" - return self.rest.available + return self.netdata.available - def update(self): + async def async_update(self): """Get the latest data from Netdata REST API.""" - self.rest.update() + await self.netdata.async_update() + resource_data = self.netdata.api.metrics.get(self._sensor) + self._state = round( + resource_data['dimensions'][self._element]['value'], 2) -class NetdataData(object): +class NetdataData: """The class for handling the data retrieval.""" - def __init__(self, resource): + def __init__(self, api): """Initialize the data object.""" - self._resource = resource - self.data = None + self.api = api self.available = True - def update(self): + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self): """Get the latest data from the Netdata REST API.""" + from netdata.exceptions import NetdataError + try: - response = requests.get(self._resource, timeout=5) - det = response.json() - self.data = {k: v for k, v in zip(det['labels'], det['data'][0])} + await self.api.get_allmetrics() self.available = True - except requests.exceptions.ConnectionError: - _LOGGER.error("Connection error: %s", urlsplit(self._resource)[1]) - self.data = None + except NetdataError: + _LOGGER.error("Unable to retrieve data from Netdata") self.available = False diff --git a/homeassistant/components/sensor/netgear_lte.py b/homeassistant/components/sensor/netgear_lte.py new file mode 100644 index 00000000000000..dac1f81ad2361b --- /dev/null +++ b/homeassistant/components/sensor/netgear_lte.py @@ -0,0 +1,91 @@ +"""Netgear LTE sensors. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.netgear_lte/ +""" + +import voluptuous as vol +import attr + +from homeassistant.const import CONF_HOST, CONF_SENSORS +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity +import homeassistant.helpers.config_validation as cv + +from ..netgear_lte import DATA_KEY + +DEPENDENCIES = ['netgear_lte'] + +SENSOR_SMS = 'sms' +SENSOR_USAGE = 'usage' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST): cv.string, + vol.Required(CONF_SENSORS): vol.All( + cv.ensure_list, [vol.In([SENSOR_SMS, SENSOR_USAGE])]) +}) + + +async def async_setup_platform( + hass, config, async_add_devices, discovery_info): + """Set up Netgear LTE sensor devices.""" + modem_data = hass.data[DATA_KEY].get_modem_data(config) + + sensors = [] + for sensor_type in config[CONF_SENSORS]: + if sensor_type == SENSOR_SMS: + sensors.append(SMSSensor(modem_data, sensor_type)) + elif sensor_type == SENSOR_USAGE: + sensors.append(UsageSensor(modem_data, sensor_type)) + + async_add_devices(sensors, True) + + +@attr.s +class LTESensor(Entity): + """Data usage sensor entity.""" + + modem_data = attr.ib() + sensor_type = attr.ib() + + async def async_update(self): + """Update state.""" + await self.modem_data.async_update() + + @property + def unique_id(self): + """Return a unique ID like 'usage_5TG365AB0078V'.""" + return "{}_{}".format(self.sensor_type, self.modem_data.serial_number) + + +class SMSSensor(LTESensor): + """Unread SMS sensor entity.""" + + @property + def name(self): + """Return the name of the sensor.""" + return "Netgear LTE SMS" + + @property + def state(self): + """Return the state of the sensor.""" + return self.modem_data.unread_count + + +class UsageSensor(LTESensor): + """Data usage sensor entity.""" + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return "MiB" + + @property + def name(self): + """Return the name of the sensor.""" + return "Netgear LTE usage" + + @property + def state(self): + """Return the state of the sensor.""" + return round(self.modem_data.usage / 1024**2, 1) diff --git a/homeassistant/components/sensor/neurio_energy.py b/homeassistant/components/sensor/neurio_energy.py index 5e3bf55dc9d17d..fd8b8d2aeecee3 100644 --- a/homeassistant/components/sensor/neurio_energy.py +++ b/homeassistant/components/sensor/neurio_energy.py @@ -69,7 +69,7 @@ def update_active(): add_devices([NeurioEnergy(data, DAILY_NAME, DAILY_TYPE, update_daily)]) -class NeurioData(object): +class NeurioData: """Stores data retrieved from Neurio sensor.""" def __init__(self, api_key, api_secret, sensor_id): diff --git a/homeassistant/components/sensor/nsw_fuel_station.py b/homeassistant/components/sensor/nsw_fuel_station.py new file mode 100644 index 00000000000000..5f677d39888ea9 --- /dev/null +++ b/homeassistant/components/sensor/nsw_fuel_station.py @@ -0,0 +1,174 @@ +""" +Sensor platform to display the current fuel prices at a NSW fuel station. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.nsw_fuel_station/ +""" +import datetime +import logging +from typing import Optional + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.light import PLATFORM_SCHEMA +from homeassistant.const import ATTR_ATTRIBUTION +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['nsw-fuel-api-client==1.0.10'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_STATION_ID = 'station_id' +ATTR_STATION_NAME = 'station_name' + +CONF_STATION_ID = 'station_id' +CONF_FUEL_TYPES = 'fuel_types' +CONF_ALLOWED_FUEL_TYPES = ["E10", "U91", "E85", "P95", "P98", "DL", + "PDL", "B20", "LPG", "CNG", "EV"] +CONF_DEFAULT_FUEL_TYPES = ["E10", "U91"] +CONF_ATTRIBUTION = "Data provided by NSW Government FuelCheck" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_STATION_ID): cv.positive_int, + vol.Optional(CONF_FUEL_TYPES, default=CONF_DEFAULT_FUEL_TYPES): + vol.All(cv.ensure_list, [vol.In(CONF_ALLOWED_FUEL_TYPES)]), +}) + +MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(hours=1) + +NOTIFICATION_ID = 'nsw_fuel_station_notification' +NOTIFICATION_TITLE = 'NSW Fuel Station Sensor Setup' + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the NSW Fuel Station sensor.""" + from nsw_fuel import FuelCheckClient + + station_id = config[CONF_STATION_ID] + fuel_types = config[CONF_FUEL_TYPES] + + client = FuelCheckClient() + station_data = StationPriceData(client, station_id) + station_data.update() + + if station_data.error is not None: + message = ( + 'Error: {}. Check the logs for additional information.' + ).format(station_data.error) + + hass.components.persistent_notification.create( + message, + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return + + available_fuel_types = station_data.get_available_fuel_types() + + add_devices([ + StationPriceSensor(station_data, fuel_type) + for fuel_type in fuel_types + if fuel_type in available_fuel_types + ]) + + +class StationPriceData: + """An object to store and fetch the latest data for a given station.""" + + def __init__(self, client, station_id: int) -> None: + """Initialize the sensor.""" + self.station_id = station_id + self._client = client + self._data = None + self._reference_data = None + self.error = None + self._station_name = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + def update(self): + """Update the internal data using the API client.""" + from nsw_fuel import FuelCheckError + + if self._reference_data is None: + try: + self._reference_data = self._client.get_reference_data() + except FuelCheckError as exc: + self.error = str(exc) + _LOGGER.error( + 'Failed to fetch NSW Fuel station reference data. %s', exc) + return + + try: + self._data = self._client.get_fuel_prices_for_station( + self.station_id) + except FuelCheckError as exc: + self.error = str(exc) + _LOGGER.error( + 'Failed to fetch NSW Fuel station price data. %s', exc) + + def for_fuel_type(self, fuel_type: str): + """Return the price of the given fuel type.""" + if self._data is None: + return None + return next((price for price + in self._data if price.fuel_type == fuel_type), None) + + def get_available_fuel_types(self): + """Return the available fuel types for the station.""" + return [price.fuel_type for price in self._data] + + def get_station_name(self) -> str: + """Return the name of the station.""" + if self._station_name is None: + name = None + if self._reference_data is not None: + name = next((station.name for station + in self._reference_data.stations + if station.code == self.station_id), None) + + self._station_name = name or 'station {}'.format(self.station_id) + + return self._station_name + + +class StationPriceSensor(Entity): + """Implementation of a sensor that reports the fuel price for a station.""" + + def __init__(self, station_data: StationPriceData, fuel_type: str): + """Initialize the sensor.""" + self._station_data = station_data + self._fuel_type = fuel_type + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return '{} {}'.format( + self._station_data.get_station_name(), self._fuel_type) + + @property + def state(self) -> Optional[float]: + """Return the state of the sensor.""" + price_info = self._station_data.for_fuel_type(self._fuel_type) + if price_info: + return price_info.price + + return None + + @property + def device_state_attributes(self) -> dict: + """Return the state attributes of the device.""" + return { + ATTR_STATION_ID: self._station_data.station_id, + ATTR_STATION_NAME: self._station_data.get_station_name(), + ATTR_ATTRIBUTION: CONF_ATTRIBUTION + } + + @property + def unit_of_measurement(self) -> str: + """Return the units of measurement.""" + return '¢/L' + + def update(self): + """Update current conditions.""" + self._station_data.update() diff --git a/homeassistant/components/sensor/nut.py b/homeassistant/components/sensor/nut.py index b8917080efc5b6..7126bd89ef9f3e 100644 --- a/homeassistant/components/sensor/nut.py +++ b/homeassistant/components/sensor/nut.py @@ -14,6 +14,7 @@ from homeassistant.const import ( CONF_HOST, CONF_PORT, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, TEMP_CELSIUS, CONF_RESOURCES, CONF_ALIAS, ATTR_STATE, STATE_UNKNOWN) +from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -26,10 +27,12 @@ DEFAULT_PORT = 3493 KEY_STATUS = 'ups.status' +KEY_STATUS_DISPLAY = 'ups.status.display' MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=60) SENSOR_TYPES = { + 'ups.status.display': ['Status', '', 'mdi:information-outline'], 'ups.status': ['Status Data', '', 'mdi:information-outline'], 'ups.alarm': ['Alarms', '', 'mdi:alarm'], 'ups.time': ['Internal Time', '', 'mdi:calendar-clock'], @@ -104,6 +107,20 @@ ['Voltage Transfer Reason', '', 'mdi:information-outline'], 'input.voltage': ['Input Voltage', 'V', 'mdi:flash'], 'input.voltage.nominal': ['Nominal Input Voltage', 'V', 'mdi:flash'], + 'input.frequency': ['Input Line Frequency', 'hz', 'mdi:flash'], + 'input.frequency.nominal': + ['Nominal Input Line Frequency', 'hz', 'mdi:flash'], + 'input.frequency.status': + ['Input Frequency Status', '', 'mdi:information-outline'], + 'output.current': ['Output Current', 'A', 'mdi:flash'], + 'output.current.nominal': + ['Nominal Output Current', 'A', 'mdi:flash'], + 'output.voltage': ['Output Voltage', 'V', 'mdi:flash'], + 'output.voltage.nominal': + ['Nominal Output Voltage', 'V', 'mdi:flash'], + 'output.frequency': ['Output Frequency', 'hz', 'mdi:flash'], + 'output.frequency.nominal': + ['Nominal Output Frequency', 'hz', 'mdi:flash'], } STATE_TYPES = { @@ -130,7 +147,7 @@ vol.Optional(CONF_ALIAS): cv.string, vol.Optional(CONF_USERNAME): cv.string, vol.Optional(CONF_PASSWORD): cv.string, - vol.Required(CONF_RESOURCES, default=[]): + vol.Required(CONF_RESOURCES): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), }) @@ -148,7 +165,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): if data.status is None: _LOGGER.error("NUT Sensor has no data, unable to setup") - return False + raise PlatformNotReady _LOGGER.debug('NUT Sensors Available: %s', data.status) @@ -157,7 +174,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None): for resource in config[CONF_RESOURCES]: sensor_type = resource.lower() - if sensor_type in data.status: + # Display status is a special case that falls back to the status value + # of the UPS instead. + if sensor_type in data.status or (sensor_type == KEY_STATUS_DISPLAY + and KEY_STATUS in data.status): entities.append(NUTSensor(name, data, sensor_type)) else: _LOGGER.warning( @@ -169,7 +189,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): except data.pynuterror as err: _LOGGER.error("Failure while testing NUT status retrieval. " "Cannot continue setup: %s", err) - return False + raise PlatformNotReady add_entities(entities, True) @@ -209,20 +229,19 @@ def unit_of_measurement(self): def device_state_attributes(self): """Return the sensor attributes.""" attr = dict() - attr[ATTR_STATE] = self.opp_state() + attr[ATTR_STATE] = self.display_state() return attr - def opp_state(self): - """Return UPS operating state.""" + def display_state(self): + """Return UPS display state.""" if self._data.status is None: return STATE_TYPES['OFF'] - else: - try: - return " ".join( - STATE_TYPES[state] - for state in self._data.status[KEY_STATUS].split()) - except KeyError: - return STATE_UNKNOWN + try: + return " ".join( + STATE_TYPES[state] + for state in self._data.status[KEY_STATUS].split()) + except KeyError: + return STATE_UNKNOWN def update(self): """Get the latest status and use it to update our sensor state.""" @@ -230,13 +249,17 @@ def update(self): self._state = None return - if self.type not in self._data.status: + # In case of the display status sensor, keep a human-readable form + # as the sensor state. + if self.type == KEY_STATUS_DISPLAY: + self._state = self.display_state() + elif self.type not in self._data.status: self._state = None else: self._state = self._data.status[self.type] -class PyNUTData(object): +class PyNUTData: """Stores the data retrieved from NUT. For each entity to use, acts as the single point responsible for fetching @@ -288,5 +311,5 @@ def _get_status(self): @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self, **kwargs): - """Fetch the latest status from APCUPSd.""" + """Fetch the latest status from NUT.""" self._status = self._get_status() diff --git a/homeassistant/components/sensor/nzbget.py b/homeassistant/components/sensor/nzbget.py index b140d02af04587..a6fee5a69e8f38 100644 --- a/homeassistant/components/sensor/nzbget.py +++ b/homeassistant/components/sensor/nzbget.py @@ -50,7 +50,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the NZBGet sensors.""" host = config.get(CONF_HOST) @@ -139,7 +138,7 @@ def update(self): self._state = value -class NZBGetAPI(object): +class NZBGetAPI: """Simple JSON-RPC wrapper for NZBGet's API.""" def __init__(self, api_url, username=None, password=None): @@ -174,8 +173,4 @@ def post(self, method, params=None): @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Update cached response.""" - try: - self.status = self.post('status')['result'] - except requests.exceptions.ConnectionError: - # failed to update status - exception already logged in self.post - raise + self.status = self.post('status')['result'] diff --git a/homeassistant/components/sensor/octoprint.py b/homeassistant/components/sensor/octoprint.py index 8a800e8616cc73..9e62846e4d3e8c 100644 --- a/homeassistant/components/sensor/octoprint.py +++ b/homeassistant/components/sensor/octoprint.py @@ -38,7 +38,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the available OctoPrint sensors.""" octoprint_api = hass.data[DOMAIN]["api"] @@ -108,13 +107,12 @@ def name(self): def state(self): """Return the state of the sensor.""" sensor_unit = self.unit_of_measurement - if sensor_unit == TEMP_CELSIUS or sensor_unit == "%": + if sensor_unit in (TEMP_CELSIUS, "%"): # API sometimes returns null and not 0 if self._state is None: self._state = 0 return round(self._state, 2) - else: - return self._state + return self._state @property def unit_of_measurement(self): diff --git a/homeassistant/components/sensor/ohmconnect.py b/homeassistant/components/sensor/ohmconnect.py index ff465b3617c2e3..d323a21a521096 100644 --- a/homeassistant/components/sensor/ohmconnect.py +++ b/homeassistant/components/sensor/ohmconnect.py @@ -31,7 +31,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the OhmConnect sensor.""" name = config.get(CONF_NAME) diff --git a/homeassistant/components/sensor/onewire.py b/homeassistant/components/sensor/onewire.py index 8a07d3484d51b6..95ad5f1713d460 100644 --- a/homeassistant/components/sensor/onewire.py +++ b/homeassistant/components/sensor/onewire.py @@ -28,7 +28,8 @@ '22': {'temperature': 'temperature'}, '26': {'temperature': 'temperature', 'humidity': 'humidity', - 'pressure': 'B1-R1-A/pressure'}, + 'pressure': 'B1-R1-A/pressure', + 'illuminance': 'S3-R1-A/illuminance'}, '28': {'temperature': 'temperature'}, '3B': {'temperature': 'temperature'}, '42': {'temperature': 'temperature'}} @@ -37,6 +38,7 @@ 'temperature': ['temperature', TEMP_CELSIUS], 'humidity': ['humidity', '%'], 'pressure': ['pressure', 'mb'], + 'illuminance': ['illuminance', 'lux'], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -45,7 +47,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the one wire Sensors.""" base_dir = config.get(CONF_MOUNT_DIR) diff --git a/homeassistant/components/sensor/openexchangerates.py b/homeassistant/components/sensor/openexchangerates.py index 741ffa2842d2fa..5e8231bb124904 100644 --- a/homeassistant/components/sensor/openexchangerates.py +++ b/homeassistant/components/sensor/openexchangerates.py @@ -93,7 +93,7 @@ def update(self): self._state = round(value[str(self._quote)], 4) -class OpenexchangeratesData(object): +class OpenexchangeratesData: """Get data from Openexchangerates.org.""" def __init__(self, resource, parameters, quote): diff --git a/homeassistant/components/sensor/openhardwaremonitor.py b/homeassistant/components/sensor/openhardwaremonitor.py index 1b5867836bcfda..1b345c752ffffb 100644 --- a/homeassistant/components/sensor/openhardwaremonitor.py +++ b/homeassistant/components/sensor/openhardwaremonitor.py @@ -101,14 +101,13 @@ def update(self): self.attributes = _attributes return - else: - array = array[path_number][OHM_CHILDREN] - _attributes.update({ - 'level_%s' % path_index: values[OHM_NAME] - }) + array = array[path_number][OHM_CHILDREN] + _attributes.update({ + 'level_%s' % path_index: values[OHM_NAME] + }) -class OpenHardwareMonitorData(object): +class OpenHardwareMonitorData: """Class used to pull data from OHM and create sensors.""" def __init__(self, config, hass): diff --git a/homeassistant/components/sensor/opensky.py b/homeassistant/components/sensor/opensky.py index bd071ace57854c..9178b46c488f6f 100644 --- a/homeassistant/components/sensor/opensky.py +++ b/homeassistant/components/sensor/opensky.py @@ -13,22 +13,27 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS, - ATTR_ATTRIBUTION, ATTR_LATITUDE, ATTR_LONGITUDE, - LENGTH_KILOMETERS, LENGTH_METERS) + CONF_NAME, CONF_LATITUDE, CONF_LONGITUDE, + CONF_RADIUS, ATTR_ATTRIBUTION, ATTR_LATITUDE, + ATTR_LONGITUDE, LENGTH_KILOMETERS, LENGTH_METERS) from homeassistant.helpers.entity import Entity from homeassistant.util import distance as util_distance from homeassistant.util import location as util_location _LOGGER = logging.getLogger(__name__) +CONF_ALTITUDE = 'altitude' + ATTR_CALLSIGN = 'callsign' +ATTR_ALTITUDE = 'altitude' ATTR_ON_GROUND = 'on_ground' ATTR_SENSOR = 'sensor' ATTR_STATES = 'states' DOMAIN = 'opensky' +DEFAULT_ALTITUDE = 0 + EVENT_OPENSKY_ENTRY = '{}_entry'.format(DOMAIN) EVENT_OPENSKY_EXIT = '{}_exit'.format(DOMAIN) SCAN_INTERVAL = timedelta(seconds=12) # opensky public limit is 10 seconds @@ -38,7 +43,7 @@ OPENSKY_API_URL = 'https://opensky-network.org/api/states/all' OPENSKY_API_FIELDS = [ 'icao24', ATTR_CALLSIGN, 'origin_country', 'time_position', - 'time_velocity', ATTR_LONGITUDE, ATTR_LATITUDE, 'altitude', + 'time_velocity', ATTR_LONGITUDE, ATTR_LATITUDE, ATTR_ALTITUDE, ATTR_ON_GROUND, 'velocity', 'heading', 'vertical_rate', 'sensors'] @@ -46,30 +51,31 @@ vol.Required(CONF_RADIUS): vol.Coerce(float), vol.Optional(CONF_NAME): cv.string, vol.Inclusive(CONF_LATITUDE, 'coordinates'): cv.latitude, - vol.Inclusive(CONF_LONGITUDE, 'coordinates'): cv.longitude + vol.Inclusive(CONF_LONGITUDE, 'coordinates'): cv.longitude, + vol.Optional(CONF_ALTITUDE, default=DEFAULT_ALTITUDE): vol.Coerce(float) }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Open Sky platform.""" latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) add_devices([OpenSkySensor( hass, config.get(CONF_NAME, DOMAIN), latitude, longitude, - config.get(CONF_RADIUS))], True) + config.get(CONF_RADIUS), config.get(CONF_ALTITUDE))], True) class OpenSkySensor(Entity): """Open Sky Network Sensor.""" - def __init__(self, hass, name, latitude, longitude, radius): + def __init__(self, hass, name, latitude, longitude, radius, altitude): """Initialize the sensor.""" self._session = requests.Session() self._latitude = latitude self._longitude = longitude self._radius = util_distance.convert( radius, LENGTH_KILOMETERS, LENGTH_METERS) + self._altitude = altitude self._state = 0 self._hass = hass self._name = name @@ -85,11 +91,18 @@ def state(self): """Return the state of the sensor.""" return self._state - def _handle_boundary(self, callsigns, event): + def _handle_boundary(self, flights, event, metadata): """Handle flights crossing region boundary.""" - for callsign in callsigns: + for flight in flights: + if flight in metadata: + altitude = metadata[flight].get(ATTR_ALTITUDE) + else: + # Assume Flight has landed if missing. + altitude = 0 + data = { - ATTR_CALLSIGN: callsign, + ATTR_CALLSIGN: flight, + ATTR_ALTITUDE: altitude, ATTR_SENSOR: self._name, } self._hass.bus.fire(event, data) @@ -97,30 +110,37 @@ def _handle_boundary(self, callsigns, event): def update(self): """Update device state.""" currently_tracked = set() + flight_metadata = {} states = self._session.get(OPENSKY_API_URL).json().get(ATTR_STATES) for state in states: - data = dict(zip(OPENSKY_API_FIELDS, state)) + flight = dict(zip(OPENSKY_API_FIELDS, state)) + callsign = flight[ATTR_CALLSIGN].strip() + if callsign != '': + flight_metadata[callsign] = flight + else: + continue missing_location = ( - data.get(ATTR_LONGITUDE) is None or - data.get(ATTR_LATITUDE) is None) + flight.get(ATTR_LONGITUDE) is None or + flight.get(ATTR_LATITUDE) is None) if missing_location: continue - if data.get(ATTR_ON_GROUND): + if flight.get(ATTR_ON_GROUND): continue distance = util_location.distance( self._latitude, self._longitude, - data.get(ATTR_LATITUDE), data.get(ATTR_LONGITUDE)) + flight.get(ATTR_LATITUDE), flight.get(ATTR_LONGITUDE)) if distance is None or distance > self._radius: continue - callsign = data[ATTR_CALLSIGN].strip() - if callsign == '': + altitude = flight.get(ATTR_ALTITUDE) + if altitude > self._altitude and self._altitude != 0: continue currently_tracked.add(callsign) if self._previously_tracked is not None: entries = currently_tracked - self._previously_tracked exits = self._previously_tracked - currently_tracked - self._handle_boundary(entries, EVENT_OPENSKY_ENTRY) - self._handle_boundary(exits, EVENT_OPENSKY_EXIT) + self._handle_boundary(entries, EVENT_OPENSKY_ENTRY, + flight_metadata) + self._handle_boundary(exits, EVENT_OPENSKY_EXIT, flight_metadata) self._state = len(currently_tracked) self._previously_tracked = currently_tracked diff --git a/homeassistant/components/sensor/openuv.py b/homeassistant/components/sensor/openuv.py new file mode 100644 index 00000000000000..b30c2908c40b7c --- /dev/null +++ b/homeassistant/components/sensor/openuv.py @@ -0,0 +1,121 @@ +""" +This platform provides sensors for OpenUV data. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.openuv/ +""" +import logging + +from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.components.openuv import ( + DATA_UV, DOMAIN, SENSORS, TOPIC_UPDATE, TYPE_CURRENT_OZONE_LEVEL, + TYPE_CURRENT_UV_INDEX, TYPE_MAX_UV_INDEX, TYPE_SAFE_EXPOSURE_TIME_1, + TYPE_SAFE_EXPOSURE_TIME_2, TYPE_SAFE_EXPOSURE_TIME_3, + TYPE_SAFE_EXPOSURE_TIME_4, TYPE_SAFE_EXPOSURE_TIME_5, + TYPE_SAFE_EXPOSURE_TIME_6, OpenUvEntity) +from homeassistant.util.dt import as_local, parse_datetime + +DEPENDENCIES = ['openuv'] +_LOGGER = logging.getLogger(__name__) + +ATTR_MAX_UV_TIME = 'time' + +EXPOSURE_TYPE_MAP = { + TYPE_SAFE_EXPOSURE_TIME_1: 'st1', + TYPE_SAFE_EXPOSURE_TIME_2: 'st2', + TYPE_SAFE_EXPOSURE_TIME_3: 'st3', + TYPE_SAFE_EXPOSURE_TIME_4: 'st4', + TYPE_SAFE_EXPOSURE_TIME_5: 'st5', + TYPE_SAFE_EXPOSURE_TIME_6: 'st6' +} + + +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): + """Set up the OpenUV binary sensor platform.""" + if discovery_info is None: + return + + openuv = hass.data[DOMAIN] + + sensors = [] + for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]: + name, icon, unit = SENSORS[sensor_type] + sensors.append(OpenUvSensor(openuv, sensor_type, name, icon, unit)) + + async_add_devices(sensors, True) + + +class OpenUvSensor(OpenUvEntity): + """Define a binary sensor for OpenUV.""" + + def __init__(self, openuv, sensor_type, name, icon, unit): + """Initialize the sensor.""" + super().__init__(openuv) + + self._icon = icon + self._latitude = openuv.client.latitude + self._longitude = openuv.client.longitude + self._name = name + self._sensor_type = sensor_type + self._state = None + self._unit = unit + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + def should_poll(self): + """Disable polling.""" + return False + + @property + def state(self): + """Return the status of the sensor.""" + return self._state + + @property + def unique_id(self) -> str: + """Return a unique, HASS-friendly identifier for this entity.""" + return '{0}_{1}_{2}'.format( + self._latitude, self._longitude, self._sensor_type) + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit + + @callback + def _update_data(self): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect(self.hass, TOPIC_UPDATE, self._update_data) + + async def async_update(self): + """Update the state.""" + data = self.openuv.data[DATA_UV]['result'] + if self._sensor_type == TYPE_CURRENT_OZONE_LEVEL: + self._state = data['ozone'] + elif self._sensor_type == TYPE_CURRENT_UV_INDEX: + self._state = data['uv'] + elif self._sensor_type == TYPE_MAX_UV_INDEX: + self._state = data['uv_max'] + self._attrs.update({ + ATTR_MAX_UV_TIME: as_local( + parse_datetime(data['uv_max_time'])) + }) + elif self._sensor_type in (TYPE_SAFE_EXPOSURE_TIME_1, + TYPE_SAFE_EXPOSURE_TIME_2, + TYPE_SAFE_EXPOSURE_TIME_3, + TYPE_SAFE_EXPOSURE_TIME_4, + TYPE_SAFE_EXPOSURE_TIME_5, + TYPE_SAFE_EXPOSURE_TIME_6): + self._state = data['safe_exposure_time'][EXPOSURE_TYPE_MAP[ + self._sensor_type]] diff --git a/homeassistant/components/sensor/openweathermap.py b/homeassistant/components/sensor/openweathermap.py index 96db4430d326b0..ba7fc4f90951af 100644 --- a/homeassistant/components/sensor/openweathermap.py +++ b/homeassistant/components/sensor/openweathermap.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['pyowm==2.8.0'] +REQUIREMENTS = ['pyowm==2.9.0'] _LOGGER = logging.getLogger(__name__) @@ -179,7 +179,7 @@ def update(self): self._state = fc_data.get_weathers()[0].get_detailed_status() -class WeatherData(object): +class WeatherData: """Get the latest data from OpenWeatherMap.""" def __init__(self, owm, forecast, latitude, longitude): diff --git a/homeassistant/components/sensor/pi_hole.py b/homeassistant/components/sensor/pi_hole.py index 027c12569a609a..363ada725ba8db 100644 --- a/homeassistant/components/sensor/pi_hole.py +++ b/homeassistant/components/sensor/pi_hole.py @@ -1,23 +1,26 @@ """ -Support for getting statistical data from a Pi-Hole system. +Support for getting statistical data from a Pi-hole system. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.pi_hole/ """ -import logging -import json from datetime import timedelta +import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_NAME, CONF_HOST, CONF_SSL, CONF_VERIFY_SSL, CONF_MONITORED_CONDITIONS) + CONF_HOST, CONF_MONITORED_CONDITIONS, CONF_NAME, CONF_SSL, CONF_VERIFY_SSL) +from homeassistant.exceptions import PlatformNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + +REQUIREMENTS = ['hole==0.3.0'] _LOGGER = logging.getLogger(__name__) -_ENDPOINT = '/api.php' ATTR_BLOCKED_DOMAINS = 'domains_blocked' ATTR_PERCENTAGE_TODAY = 'percentage_today' @@ -32,25 +35,27 @@ DEFAULT_SSL = False DEFAULT_VERIFY_SSL = True -SCAN_INTERVAL = timedelta(minutes=5) +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) MONITORED_CONDITIONS = { - 'dns_queries_today': ['DNS Queries Today', - 'queries', 'mdi:comment-question-outline'], - 'ads_blocked_today': ['Ads Blocked Today', - 'ads', 'mdi:close-octagon-outline'], - 'ads_percentage_today': ['Ads Percentage Blocked Today', - '%', 'mdi:close-octagon-outline'], - 'domains_being_blocked': ['Domains Blocked', - 'domains', 'mdi:block-helper'], - 'queries_cached': ['DNS Queries Cached', - 'queries', 'mdi:comment-question-outline'], - 'queries_forwarded': ['DNS Queries Forwarded', - 'queries', 'mdi:comment-question-outline'], - 'unique_clients': ['DNS Unique Clients', - 'clients', 'mdi:account-outline'], - 'unique_domains': ['DNS Unique Domains', - 'domains', 'mdi:domain'], + 'ads_blocked_today': + ['Ads Blocked Today', 'ads', 'mdi:close-octagon-outline'], + 'ads_percentage_today': + ['Ads Percentage Blocked Today', '%', 'mdi:close-octagon-outline'], + 'clients_ever_seen': + ['Seen Clients', 'clients', 'mdi:account-outline'], + 'dns_queries_today': + ['DNS Queries Today', 'queries', 'mdi:comment-question-outline'], + 'domains_being_blocked': + ['Domains Blocked', 'domains', 'mdi:block-helper'], + 'queries_cached': + ['DNS Queries Cached', 'queries', 'mdi:comment-question-outline'], + 'queries_forwarded': + ['DNS Queries Forwarded', 'queries', 'mdi:comment-question-outline'], + 'unique_clients': + ['DNS Unique Clients', 'clients', 'mdi:account-outline'], + 'unique_domains': + ['DNS Unique Domains', 'domains', 'mdi:domain'], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -60,105 +65,110 @@ vol.Optional(CONF_LOCATION, default=DEFAULT_LOCATION): cv.string, vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, vol.Optional(CONF_MONITORED_CONDITIONS, - default=list(MONITORED_CONDITIONS)): + default=['ads_blocked_today']): vol.All(cv.ensure_list, [vol.In(MONITORED_CONDITIONS)]), }) -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Pi-Hole sensor.""" +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): + """Set up the Pi-hole sensor.""" + from hole import Hole + name = config.get(CONF_NAME) host = config.get(CONF_HOST) - use_ssl = config.get(CONF_SSL) + use_tls = config.get(CONF_SSL) location = config.get(CONF_LOCATION) - verify_ssl = config.get(CONF_VERIFY_SSL) + verify_tls = config.get(CONF_VERIFY_SSL) + + session = async_get_clientsession(hass) + pi_hole = PiHoleData(Hole( + host, hass.loop, session, location=location, tls=use_tls, + verify_tls=verify_tls)) - api = PiHoleAPI('{}/{}'.format(host, location), use_ssl, verify_ssl) + await pi_hole.async_update() - sensors = [PiHoleSensor(hass, api, name, condition) + if pi_hole.api.data is None: + raise PlatformNotReady + + sensors = [PiHoleSensor(pi_hole, name, condition) for condition in config[CONF_MONITORED_CONDITIONS]] - add_devices(sensors, True) + async_add_devices(sensors, True) class PiHoleSensor(Entity): - """Representation of a Pi-Hole sensor.""" + """Representation of a Pi-hole sensor.""" - def __init__(self, hass, api, name, variable): - """Initialize a Pi-Hole sensor.""" - self._hass = hass - self._api = api + def __init__(self, pi_hole, name, condition): + """Initialize a Pi-hole sensor.""" + self.pi_hole = pi_hole self._name = name - self._var_id = variable + self._condition = condition - variable_info = MONITORED_CONDITIONS[variable] - self._var_name = variable_info[0] - self._var_units = variable_info[1] - self._var_icon = variable_info[2] + variable_info = MONITORED_CONDITIONS[condition] + self._condition_name = variable_info[0] + self._unit_of_measurement = variable_info[1] + self._icon = variable_info[2] + self.data = {} @property def name(self): """Return the name of the sensor.""" - return "{} {}".format(self._name, self._var_name) + return "{} {}".format(self._name, self._condition_name) @property def icon(self): """Icon to use in the frontend, if any.""" - return self._var_icon + return self._icon @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" - return self._var_units + return self._unit_of_measurement - # pylint: disable=no-member @property def state(self): """Return the state of the device.""" try: - return round(self._api.data[self._var_id], 2) + return round(self.data[self._condition], 2) except TypeError: - return self._api.data[self._var_id] + return self.data[self._condition] - # pylint: disable=no-member @property def device_state_attributes(self): """Return the state attributes of the Pi-Hole.""" return { - ATTR_BLOCKED_DOMAINS: self._api.data['domains_being_blocked'], + ATTR_BLOCKED_DOMAINS: self.data['domains_being_blocked'], } @property def available(self): """Could the device be accessed during the last update call.""" - return self._api.available + return self.pi_hole.available - def update(self): - """Get the latest data from the Pi-Hole API.""" - self._api.update() + async def async_update(self): + """Get the latest data from the Pi-hole API.""" + await self.pi_hole.async_update() + self.data = self.pi_hole.api.data -class PiHoleAPI(object): +class PiHoleData: """Get the latest data and update the states.""" - def __init__(self, host, use_ssl, verify_ssl): + def __init__(self, api): """Initialize the data object.""" - from homeassistant.components.sensor.rest import RestData - - uri_scheme = 'https://' if use_ssl else 'http://' - resource = "{}{}{}".format(uri_scheme, host, _ENDPOINT) - - self._rest = RestData('GET', resource, None, None, None, verify_ssl) - self.data = None + self.api = api self.available = True - self.update() - def update(self): - """Get the latest data from the Pi-Hole.""" + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self): + """Get the latest data from the Pi-hole.""" + from hole.exceptions import HoleError + try: - self._rest.update() - self.data = json.loads(self._rest.data) + await self.api.get_data() self.available = True - except TypeError: - _LOGGER.error("Unable to fetch data from Pi-Hole") + except HoleError: + _LOGGER.error("Unable to fetch data from Pi-hole") self.available = False diff --git a/homeassistant/components/sensor/pilight.py b/homeassistant/components/sensor/pilight.py index 596887998ecd55..c30f1575049731 100644 --- a/homeassistant/components/sensor/pilight.py +++ b/homeassistant/components/sensor/pilight.py @@ -12,7 +12,7 @@ CONF_NAME, STATE_UNKNOWN, CONF_UNIT_OF_MEASUREMENT, CONF_PAYLOAD) from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity -import homeassistant.components.pilight as pilight +from homeassistant.components import pilight import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -30,7 +30,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up Pilight Sensor.""" add_devices([PilightSensor( diff --git a/homeassistant/components/sensor/plex.py b/homeassistant/components/sensor/plex.py index b61e1bce0da05b..5aa156a0ac6df7 100644 --- a/homeassistant/components/sensor/plex.py +++ b/homeassistant/components/sensor/plex.py @@ -41,7 +41,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Plex sensor.""" name = config.get(CONF_NAME) diff --git a/homeassistant/components/sensor/pollen.py b/homeassistant/components/sensor/pollen.py index 1ef5a27cf3dffc..27750c9ac61b04 100644 --- a/homeassistant/components/sensor/pollen.py +++ b/homeassistant/components/sensor/pollen.py @@ -13,17 +13,17 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - ATTR_ATTRIBUTION, ATTR_STATE, CONF_MONITORED_CONDITIONS -) + ATTR_ATTRIBUTION, ATTR_STATE, CONF_MONITORED_CONDITIONS) +from homeassistant.helpers import aiohttp_client from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle, slugify +from homeassistant.util import Throttle -REQUIREMENTS = ['pypollencom==1.1.2'] +REQUIREMENTS = ['pypollencom==2.1.0'] _LOGGER = logging.getLogger(__name__) -ATTR_ALLERGEN_GENUS = 'primary_allergen_genus' -ATTR_ALLERGEN_NAME = 'primary_allergen_name' -ATTR_ALLERGEN_TYPE = 'primary_allergen_type' +ATTR_ALLERGEN_GENUS = 'allergen_genus' +ATTR_ALLERGEN_NAME = 'allergen_name' +ATTR_ALLERGEN_TYPE = 'allergen_type' ATTR_CITY = 'city' ATTR_OUTLOOK = 'outlook' ATTR_RATING = 'rating' @@ -34,53 +34,30 @@ CONF_ZIP_CODE = 'zip_code' DEFAULT_ATTRIBUTION = 'Data provided by IQVIA™' - -MIN_TIME_UPDATE_AVERAGES = timedelta(hours=12) -MIN_TIME_UPDATE_INDICES = timedelta(minutes=10) - -CONDITIONS = { - 'allergy_average_forecasted': ( - 'Allergy Index: Forecasted Average', - 'AllergyAverageSensor', - 'allergy_average_data', - {'data_attr': 'extended_data'}, - 'mdi:flower' - ), - 'allergy_average_historical': ( - 'Allergy Index: Historical Average', - 'AllergyAverageSensor', - 'allergy_average_data', - {'data_attr': 'historic_data'}, - 'mdi:flower' - ), - 'allergy_index_today': ( - 'Allergy Index: Today', - 'AllergyIndexSensor', - 'allergy_index_data', - {'key': 'Today'}, - 'mdi:flower' - ), - 'allergy_index_tomorrow': ( - 'Allergy Index: Tomorrow', - 'AllergyIndexSensor', - 'allergy_index_data', - {'key': 'Tomorrow'}, - 'mdi:flower' - ), - 'allergy_index_yesterday': ( - 'Allergy Index: Yesterday', - 'AllergyIndexSensor', - 'allergy_index_data', - {'key': 'Yesterday'}, - 'mdi:flower' - ), - 'disease_average_forecasted': ( - 'Cold & Flu: Forecasted Average', - 'AllergyAverageSensor', - 'disease_average_data', - {'data_attr': 'extended_data'}, - 'mdi:snowflake' - ) +DEFAULT_SCAN_INTERVAL = timedelta(minutes=30) + +TYPE_ALLERGY_FORECAST = 'allergy_average_forecasted' +TYPE_ALLERGY_HISTORIC = 'allergy_average_historical' +TYPE_ALLERGY_INDEX = 'allergy_index' +TYPE_ALLERGY_OUTLOOK = 'allergy_outlook' +TYPE_ALLERGY_TODAY = 'allergy_index_today' +TYPE_ALLERGY_TOMORROW = 'allergy_index_tomorrow' +TYPE_ALLERGY_YESTERDAY = 'allergy_index_yesterday' +TYPE_DISEASE_FORECAST = 'disease_average_forecasted' + +SENSORS = { + TYPE_ALLERGY_FORECAST: ( + 'Allergy Index: Forecasted Average', None, 'mdi:flower', 'index'), + TYPE_ALLERGY_HISTORIC: ( + 'Allergy Index: Historical Average', None, 'mdi:flower', 'index'), + TYPE_ALLERGY_TODAY: ( + 'Allergy Index: Today', TYPE_ALLERGY_INDEX, 'mdi:flower', 'index'), + TYPE_ALLERGY_TOMORROW: ( + 'Allergy Index: Tomorrow', TYPE_ALLERGY_INDEX, 'mdi:flower', 'index'), + TYPE_ALLERGY_YESTERDAY: ( + 'Allergy Index: Yesterday', TYPE_ALLERGY_INDEX, 'mdi:flower', 'index'), + TYPE_DISEASE_FORECAST: ( + 'Cold & Flu: Forecasted Average', None, 'mdi:snowflake', 'index') } RATING_MAPPING = [{ @@ -105,69 +82,69 @@ 'maximum': 12 }] +TREND_FLAT = 'Flat' +TREND_INCREASING = 'Increasing' +TREND_SUBSIDING = 'Subsiding' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ZIP_CODE): str, - vol.Required(CONF_MONITORED_CONDITIONS): - vol.All(cv.ensure_list, [vol.In(CONDITIONS)]), + vol.Required(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): + vol.All(cv.ensure_list, [vol.In(SENSORS)]) }) -def setup_platform(hass, config, add_devices, discovery_info=None): +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): """Configure the platform and add the sensors.""" from pypollencom import Client - _LOGGER.debug('Configuration data: %s', config) + websession = aiohttp_client.async_get_clientsession(hass) - client = Client(config[CONF_ZIP_CODE]) - datas = { - 'allergy_average_data': AllergyAveragesData(client), - 'allergy_index_data': AllergyIndexData(client), - 'disease_average_data': DiseaseData(client) - } - classes = { - 'AllergyAverageSensor': AllergyAverageSensor, - 'AllergyIndexSensor': AllergyIndexSensor - } + data = PollenComData( + Client(config[CONF_ZIP_CODE], websession), + config[CONF_MONITORED_CONDITIONS]) - for data in datas.values(): - data.update() + await data.async_update() sensors = [] - for condition in config[CONF_MONITORED_CONDITIONS]: - name, sensor_class, data_key, params, icon = CONDITIONS[condition] - sensors.append(classes[sensor_class]( - datas[data_key], - params, - name, - icon, - config[CONF_ZIP_CODE] - )) + for kind in config[CONF_MONITORED_CONDITIONS]: + name, category, icon, unit = SENSORS[kind] + sensors.append( + PollencomSensor( + data, config[CONF_ZIP_CODE], kind, category, name, icon, unit)) - add_devices(sensors, True) + async_add_devices(sensors, True) -def calculate_trend(list_of_nums): - """Calculate the most common rating as a trend.""" +def calculate_average_rating(indices): + """Calculate the human-friendly historical allergy average.""" ratings = list( - r['label'] for n in list_of_nums - for r in RATING_MAPPING + r['label'] for n in indices for r in RATING_MAPPING if r['minimum'] <= n <= r['maximum']) return max(set(ratings), key=ratings.count) -class BaseSensor(Entity): - """Define a base class for all of our sensors.""" +class PollencomSensor(Entity): + """Define a Pollen.com sensor.""" - def __init__(self, data, data_params, name, icon, unique_id): + def __init__(self, pollencom, zip_code, kind, category, name, icon, unit): """Initialize the sensor.""" self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._category = category self._icon = icon self._name = name - self._data_params = data_params self._state = None - self._unit = None - self._unique_id = unique_id - self.data = data + self._type = kind + self._unit = unit + self._zip_code = zip_code + self.pollencom = pollencom + + @property + def available(self): + """Return True if entity is available.""" + return bool( + self.pollencom.data.get(self._type) + or self.pollencom.data.get(self._category)) @property def device_state_attributes(self): @@ -192,187 +169,164 @@ def state(self): @property def unique_id(self): """Return a unique, HASS-friendly identifier for this entity.""" - return '{0}_{1}'.format(self._unique_id, slugify(self._name)) + return '{0}_{1}'.format(self._zip_code, self._type) @property def unit_of_measurement(self): """Return the unit the value is expressed in.""" return self._unit + async def async_update(self): + """Update the sensor.""" + await self.pollencom.async_update() + if not self.pollencom.data: + return -class AllergyAverageSensor(BaseSensor): - """Define a sensor to show allergy average information.""" - - def update(self): - """Update the status of the sensor.""" - self.data.update() + if self._category: + data = self.pollencom.data[self._category].get('Location') + else: + data = self.pollencom.data[self._type].get('Location') - try: - data_attr = getattr(self.data, self._data_params['data_attr']) - indices = [p['Index'] for p in data_attr['Location']['periods']] - self._attrs[ATTR_TREND] = calculate_trend(indices) - except KeyError: - _LOGGER.error("Pollen.com API didn't return any data") + if not data: return - try: - self._attrs[ATTR_CITY] = data_attr['Location']['City'].title() - self._attrs[ATTR_STATE] = data_attr['Location']['State'] - self._attrs[ATTR_ZIP_CODE] = data_attr['Location']['ZIP'] - except KeyError: - _LOGGER.debug('Location data not included in API response') - self._attrs[ATTR_CITY] = None - self._attrs[ATTR_STATE] = None - self._attrs[ATTR_ZIP_CODE] = None - + indices = [p['Index'] for p in data['periods']] average = round(mean(indices), 1) [rating] = [ i['label'] for i in RATING_MAPPING if i['minimum'] <= average <= i['maximum'] ] - self._attrs[ATTR_RATING] = rating - - self._state = average - self._unit = 'index' - - -class AllergyIndexSensor(BaseSensor): - """Define a sensor to show allergy index information.""" - - def update(self): - """Update the status of the sensor.""" - self.data.update() - - try: - location_data = self.data.current_data['Location'] - [period] = [ - p for p in location_data['periods'] - if p['Type'] == self._data_params['key'] - ] + slope = (data['periods'][-1]['Index'] - data['periods'][-2]['Index']) + trend = TREND_FLAT + if slope > 0: + trend = TREND_INCREASING + elif slope < 0: + trend = TREND_SUBSIDING + + if self._type == TYPE_ALLERGY_FORECAST: + outlook = self.pollencom.data[TYPE_ALLERGY_OUTLOOK] + + self._attrs.update({ + ATTR_CITY: data['City'].title(), + ATTR_OUTLOOK: outlook['Outlook'], + ATTR_RATING: rating, + ATTR_SEASON: outlook['Season'].title(), + ATTR_STATE: data['State'], + ATTR_TREND: outlook['Trend'].title(), + ATTR_ZIP_CODE: data['ZIP'] + }) + self._state = average + elif self._type == TYPE_ALLERGY_HISTORIC: + self._attrs.update({ + ATTR_CITY: data['City'].title(), + ATTR_RATING: calculate_average_rating(indices), + ATTR_STATE: data['State'], + ATTR_TREND: trend, + ATTR_ZIP_CODE: data['ZIP'] + }) + self._state = average + elif self._type in (TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW, + TYPE_ALLERGY_YESTERDAY): + key = self._type.split('_')[-1].title() + [period] = [p for p in data['periods'] if p['Type'] == key] [rating] = [ i['label'] for i in RATING_MAPPING if i['minimum'] <= period['Index'] <= i['maximum'] ] - for i in range(3): - index = i + 1 - try: - data = period['Triggers'][i] - self._attrs['{0}_{1}'.format( - ATTR_ALLERGEN_GENUS, index)] = data['Genus'] - self._attrs['{0}_{1}'.format( - ATTR_ALLERGEN_NAME, index)] = data['Name'] - self._attrs['{0}_{1}'.format( - ATTR_ALLERGEN_TYPE, index)] = data['PlantType'] - except IndexError: - self._attrs['{0}_{1}'.format( - ATTR_ALLERGEN_GENUS, index)] = None - self._attrs['{0}_{1}'.format( - ATTR_ALLERGEN_NAME, index)] = None - self._attrs['{0}_{1}'.format( - ATTR_ALLERGEN_TYPE, index)] = None - - self._attrs[ATTR_RATING] = rating - - except KeyError: - _LOGGER.error("Pollen.com API didn't return any data") - return - - try: - self._attrs[ATTR_CITY] = location_data['City'].title() - self._attrs[ATTR_STATE] = location_data['State'] - self._attrs[ATTR_ZIP_CODE] = location_data['ZIP'] - except KeyError: - _LOGGER.debug('Location data not included in API response') - self._attrs[ATTR_CITY] = None - self._attrs[ATTR_STATE] = None - self._attrs[ATTR_ZIP_CODE] = None - - try: - self._attrs[ATTR_OUTLOOK] = self.data.outlook_data['Outlook'] - except KeyError: - _LOGGER.debug('Outlook data not included in API response') - self._attrs[ATTR_OUTLOOK] = None - - try: - self._attrs[ATTR_SEASON] = self.data.outlook_data['Season'] - except KeyError: - _LOGGER.debug('Season data not included in API response') - self._attrs[ATTR_SEASON] = None - - try: - self._attrs[ATTR_TREND] = self.data.outlook_data['Trend'].title() - except KeyError: - _LOGGER.debug('Trend data not included in API response') - self._attrs[ATTR_TREND] = None - - self._state = period['Index'] - self._unit = 'index' - - -class DataBase(object): - """Define a generic data object.""" - - def __init__(self, client): + for idx, attrs in enumerate(period['Triggers']): + index = idx + 1 + self._attrs.update({ + '{0}_{1}'.format(ATTR_ALLERGEN_GENUS, index): + attrs['Genus'], + '{0}_{1}'.format(ATTR_ALLERGEN_NAME, index): + attrs['Name'], + '{0}_{1}'.format(ATTR_ALLERGEN_TYPE, index): + attrs['PlantType'], + }) + + self._attrs.update({ + ATTR_CITY: data['City'].title(), + ATTR_RATING: rating, + ATTR_STATE: data['State'], + ATTR_ZIP_CODE: data['ZIP'] + }) + self._state = period['Index'] + elif self._type == TYPE_DISEASE_FORECAST: + self._attrs.update({ + ATTR_CITY: data['City'].title(), + ATTR_RATING: rating, + ATTR_STATE: data['State'], + ATTR_TREND: trend, + ATTR_ZIP_CODE: data['ZIP'] + }) + self._state = average + + +class PollenComData: + """Define a data object to retrieve info from Pollen.com.""" + + def __init__(self, client, sensor_types): """Initialize.""" self._client = client + self._sensor_types = sensor_types + self.data = {} - def _get_client_data(self, module, operation): - """Get data from a particular point in the API.""" - from pypollencom.exceptions import HTTPError - - data = {} - try: - data = getattr(getattr(self._client, module), operation)() - _LOGGER.debug('Received "%s_%s" data: %s', module, operation, data) - except HTTPError as exc: - _LOGGER.error('An error occurred while retrieving data') - _LOGGER.debug(exc) - - return data - - -class AllergyAveragesData(DataBase): - """Define an object to averages on future and historical allergy data.""" - - def __init__(self, client): - """Initialize.""" - super().__init__(client) - self.extended_data = None - self.historic_data = None - - @Throttle(MIN_TIME_UPDATE_AVERAGES) - def update(self): - """Update with new data.""" - self.extended_data = self._get_client_data('allergens', 'extended') - self.historic_data = self._get_client_data('allergens', 'historic') - - -class AllergyIndexData(DataBase): - """Define an object to retrieve current allergy index info.""" + @Throttle(DEFAULT_SCAN_INTERVAL) + async def async_update(self): + """Update Pollen.com data.""" + from pypollencom.errors import InvalidZipError, PollenComError - def __init__(self, client): - """Initialize.""" - super().__init__(client) - self.current_data = None - self.outlook_data = None - - @Throttle(MIN_TIME_UPDATE_INDICES) - def update(self): - """Update with new index data.""" - self.current_data = self._get_client_data('allergens', 'current') - self.outlook_data = self._get_client_data('allergens', 'outlook') + # Pollen.com requires a bit more complicated error handling, given that + # it sometimes has parts (but not the whole thing) go down: + # + # 1. If `InvalidZipError` is thrown, quit everything immediately. + # 2. If an individual request throws any other error, try the others. + try: + if TYPE_ALLERGY_FORECAST in self._sensor_types: + try: + data = await self._client.allergens.extended() + self.data[TYPE_ALLERGY_FORECAST] = data + except PollenComError as err: + _LOGGER.error('Unable to get allergy forecast: %s', err) + self.data[TYPE_ALLERGY_FORECAST] = {} -class DiseaseData(DataBase): - """Define an object to retrieve current disease index info.""" + try: + data = await self._client.allergens.outlook() + self.data[TYPE_ALLERGY_OUTLOOK] = data + except PollenComError as err: + _LOGGER.error('Unable to get allergy outlook: %s', err) + self.data[TYPE_ALLERGY_OUTLOOK] = {} - def __init__(self, client): - """Initialize.""" - super().__init__(client) - self.extended_data = None + if TYPE_ALLERGY_HISTORIC in self._sensor_types: + try: + data = await self._client.allergens.historic() + self.data[TYPE_ALLERGY_HISTORIC] = data + except PollenComError as err: + _LOGGER.error('Unable to get allergy history: %s', err) + self.data[TYPE_ALLERGY_HISTORIC] = {} + + if any(s in self._sensor_types + for s in [TYPE_ALLERGY_TODAY, TYPE_ALLERGY_TOMORROW, + TYPE_ALLERGY_YESTERDAY]): + try: + data = await self._client.allergens.current() + self.data[TYPE_ALLERGY_INDEX] = data + except PollenComError as err: + _LOGGER.error('Unable to get current allergies: %s', err) + self.data[TYPE_ALLERGY_TODAY] = {} - @Throttle(MIN_TIME_UPDATE_INDICES) - def update(self): - """Update with new cold/flu data.""" - self.extended_data = self._get_client_data('disease', 'extended') + if TYPE_DISEASE_FORECAST in self._sensor_types: + try: + data = await self._client.disease.extended() + self.data[TYPE_DISEASE_FORECAST] = data + except PollenComError as err: + _LOGGER.error('Unable to get disease forecast: %s', err) + self.data[TYPE_DISEASE_FORECAST] = {} + + _LOGGER.debug('New data retrieved: %s', self.data) + except InvalidZipError: + _LOGGER.error( + 'Cannot retrieve data for ZIP code: %s', self._client.zip_code) + self.data = {} diff --git a/homeassistant/components/sensor/postnl.py b/homeassistant/components/sensor/postnl.py index c38f58b791611c..9b35c1fdc7e3cc 100644 --- a/homeassistant/components/sensor/postnl.py +++ b/homeassistant/components/sensor/postnl.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['postnl_api==1.0.1'] +REQUIREMENTS = ['postnl_api==1.0.2'] _LOGGER = logging.getLogger(__name__) @@ -35,7 +35,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the PostNL sensor platform.""" from postnl_api import PostNL_API, UnauthorizedException @@ -77,7 +76,7 @@ def state(self): @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" - return 'package(s)' + return 'packages' @property def device_state_attributes(self): diff --git a/homeassistant/components/sensor/pvoutput.py b/homeassistant/components/sensor/pvoutput.py index 26c3e27bba51ba..d4307d50228691 100644 --- a/homeassistant/components/sensor/pvoutput.py +++ b/homeassistant/components/sensor/pvoutput.py @@ -64,7 +64,6 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices([PvoutputSensor(rest, name)], True) -# pylint: disable=no-member class PvoutputSensor(Entity): """Representation of a PVOutput sensor.""" diff --git a/homeassistant/components/sensor/pyload.py b/homeassistant/components/sensor/pyload.py index 9e1c0875169197..4aa121e0895c76 100644 --- a/homeassistant/components/sensor/pyload.py +++ b/homeassistant/components/sensor/pyload.py @@ -43,7 +43,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the pyLoad sensors.""" host = config.get(CONF_HOST) @@ -125,7 +124,7 @@ def update(self): self._state = value -class PyLoadAPI(object): +class PyLoadAPI: """Simple wrapper for pyLoad's API.""" def __init__(self, api_url, username=None, password=None): @@ -163,8 +162,4 @@ def post(self, method, params=None): @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Update cached response.""" - try: - self.status = self.post('speed') - except requests.exceptions.ConnectionError: - # Failed to update status - exception already logged in self.post - raise + self.status = self.post('speed') diff --git a/homeassistant/components/sensor/qnap.py b/homeassistant/components/sensor/qnap.py index b3ca054f88fe1e..8b25eb3de31af2 100644 --- a/homeassistant/components/sensor/qnap.py +++ b/homeassistant/components/sensor/qnap.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity from homeassistant.const import ( - CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, CONF_SSL, + CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, CONF_SSL, ATTR_NAME, CONF_VERIFY_SSL, CONF_TIMEOUT, CONF_MONITORED_CONDITIONS, TEMP_CELSIUS) from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv @@ -29,7 +29,6 @@ ATTR_MAX_SPEED = 'Max Speed' ATTR_MEMORY_SIZE = 'Memory Size' ATTR_MODEL = 'Model' -ATTR_NAME = 'Name' ATTR_PACKETS_TX = 'Packets (TX)' ATTR_PACKETS_RX = 'Packets (RX)' ATTR_PACKETS_ERR = 'Packets (Err)' @@ -103,7 +102,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the QNAP NAS sensor.""" api = QNAPStatsAPI(config) @@ -166,7 +164,7 @@ def round_nicely(number): return round(number) -class QNAPStatsAPI(object): +class QNAPStatsAPI: """Class to interface with the API.""" def __init__(self, config): @@ -194,7 +192,7 @@ def update(self): self.data["smart_drive_health"] = self._api.get_smart_disk_health() self.data["volumes"] = self._api.get_volumes() self.data["bandwidth"] = self._api.get_bandwidth() - except: # noqa: E722 # pylint: disable=bare-except + except: # noqa: E722 pylint: disable=bare-except _LOGGER.exception("Failed to fetch QNAP stats from the NAS") @@ -243,7 +241,7 @@ def state(self): """Return the state of the sensor.""" if self.var_id == 'cpu_temp': return self._api.data['system_stats']['cpu']['temp_c'] - elif self.var_id == 'cpu_usage': + if self.var_id == 'cpu_usage': return self._api.data['system_stats']['cpu']['usage_percent'] diff --git a/homeassistant/components/sensor/rainmachine.py b/homeassistant/components/sensor/rainmachine.py new file mode 100644 index 00000000000000..f747a26df397b4 --- /dev/null +++ b/homeassistant/components/sensor/rainmachine.py @@ -0,0 +1,89 @@ +""" +This platform provides support for sensor data from RainMachine. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.rainmachine/ +""" +import logging + +from homeassistant.components.rainmachine import ( + DATA_RAINMACHINE, SENSOR_UPDATE_TOPIC, SENSORS, RainMachineEntity) +from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +DEPENDENCIES = ['rainmachine'] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): + """Set up the RainMachine Switch platform.""" + if discovery_info is None: + return + + rainmachine = hass.data[DATA_RAINMACHINE] + + sensors = [] + for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]: + name, icon, unit = SENSORS[sensor_type] + sensors.append( + RainMachineSensor(rainmachine, sensor_type, name, icon, unit)) + + async_add_devices(sensors, True) + + +class RainMachineSensor(RainMachineEntity): + """A sensor implementation for raincloud device.""" + + def __init__(self, rainmachine, sensor_type, name, icon, unit): + """Initialize.""" + super().__init__(rainmachine) + + self._icon = icon + self._name = name + self._sensor_type = sensor_type + self._state = None + self._unit = unit + + @property + def icon(self) -> str: + """Return the icon.""" + return self._icon + + @property + def should_poll(self): + """Disable polling.""" + return False + + @property + def state(self) -> str: + """Return the name of the entity.""" + return self._state + + @property + def unique_id(self) -> str: + """Return a unique, HASS-friendly identifier for this entity.""" + return '{0}_{1}'.format( + self.rainmachine.device_mac.replace(':', ''), self._sensor_type) + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit + + @callback + def _update_data(self): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, SENSOR_UPDATE_TOPIC, self._update_data) + + async def async_update(self): + """Update the sensor's state.""" + self._state = self.rainmachine.restrictions['global'][ + 'freezeProtectTemp'] diff --git a/homeassistant/components/sensor/random.py b/homeassistant/components/sensor/random.py index e57bbcc3955bfd..c3ff08a5781b86 100644 --- a/homeassistant/components/sensor/random.py +++ b/homeassistant/components/sensor/random.py @@ -4,7 +4,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.random/ """ -import asyncio import logging import voluptuous as vol @@ -34,8 +33,8 @@ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): """Set up the Random number sensor.""" name = config.get(CONF_NAME) minimum = config.get(CONF_MINIMUM) @@ -84,8 +83,7 @@ def device_state_attributes(self): ATTR_MINIMUM: self._minimum, } - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Get a new number and updates the states.""" from random import randrange self._state = randrange(self._minimum, self._maximum + 1) diff --git a/homeassistant/components/sensor/rest.py b/homeassistant/components/sensor/rest.py index 74bfaa38f02b98..8db48719a37cc6 100644 --- a/homeassistant/components/sensor/rest.py +++ b/homeassistant/components/sensor/rest.py @@ -158,7 +158,7 @@ def device_state_attributes(self): return self._attributes -class RestData(object): +class RestData: """Class for handling the data retrieval.""" def __init__(self, method, resource, auth, headers, data, verify_ssl): @@ -176,6 +176,7 @@ def update(self): self._request, timeout=10, verify=self._verify_ssl) self.data = response.text - except requests.exceptions.RequestException: - _LOGGER.error("Error fetching data: %s", self._request) + except requests.exceptions.RequestException as ex: + _LOGGER.error("Error fetching data: %s from %s failed with %s", + self._request, self._request.url, ex) self.data = None diff --git a/homeassistant/components/sensor/rfxtrx.py b/homeassistant/components/sensor/rfxtrx.py index 4a555905d50066..b410e7e860a853 100644 --- a/homeassistant/components/sensor/rfxtrx.py +++ b/homeassistant/components/sensor/rfxtrx.py @@ -8,12 +8,12 @@ import voluptuous as vol -import homeassistant.components.rfxtrx as rfxtrx +from homeassistant.components import rfxtrx from homeassistant.components.rfxtrx import ( - ATTR_DATA_TYPE, ATTR_FIRE_EVENT, ATTR_NAME, CONF_AUTOMATIC_ADD, - CONF_DATA_TYPE, CONF_DEVICES, CONF_FIRE_EVENT, DATA_TYPES) + ATTR_DATA_TYPE, ATTR_FIRE_EVENT, CONF_AUTOMATIC_ADD, CONF_DATA_TYPE, + CONF_DEVICES, CONF_FIRE_EVENT, DATA_TYPES) from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ENTITY_ID, CONF_NAME +from homeassistant.const import ATTR_ENTITY_ID, ATTR_NAME, CONF_NAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import slugify diff --git a/homeassistant/components/sensor/rmvtransport.py b/homeassistant/components/sensor/rmvtransport.py new file mode 100644 index 00000000000000..3d7fd2aa3b70a9 --- /dev/null +++ b/homeassistant/components/sensor/rmvtransport.py @@ -0,0 +1,202 @@ +""" +Support for real-time departure information for Rhein-Main public transport. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.rmvtransport/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import (CONF_NAME, ATTR_ATTRIBUTION) + +REQUIREMENTS = ['PyRMVtransport==0.0.7'] + +_LOGGER = logging.getLogger(__name__) + +CONF_NEXT_DEPARTURE = 'next_departure' + +CONF_STATION = 'station' +CONF_DESTINATIONS = 'destinations' +CONF_DIRECTIONS = 'directions' +CONF_LINES = 'lines' +CONF_PRODUCTS = 'products' +CONF_TIME_OFFSET = 'time_offset' +CONF_MAX_JOURNEYS = 'max_journeys' + +DEFAULT_NAME = 'RMV Journey' + +VALID_PRODUCTS = ['U-Bahn', 'Tram', 'Bus', 'S', 'RB', 'RE', 'EC', 'IC', 'ICE'] + +ICONS = { + 'U-Bahn': 'mdi:subway', + 'Tram': 'mdi:tram', + 'Bus': 'mdi:bus', + 'S': 'mdi:train', + 'RB': 'mdi:train', + 'RE': 'mdi:train', + 'EC': 'mdi:train', + 'IC': 'mdi:train', + 'ICE': 'mdi:train', + 'SEV': 'mdi:checkbox-blank-circle-outline', + None: 'mdi:clock' +} +ATTRIBUTION = "Data provided by opendata.rmv.de" + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_NEXT_DEPARTURE): [{ + vol.Required(CONF_STATION): cv.string, + vol.Optional(CONF_DESTINATIONS, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_DIRECTIONS, default=[]): + vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_LINES, default=[]): + vol.All(cv.ensure_list, [cv.positive_int, cv.string]), + vol.Optional(CONF_PRODUCTS, default=VALID_PRODUCTS): + vol.All(cv.ensure_list, [vol.In(VALID_PRODUCTS)]), + vol.Optional(CONF_TIME_OFFSET, default=0): cv.positive_int, + vol.Optional(CONF_MAX_JOURNEYS, default=5): cv.positive_int, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string}] +}) + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up the RMV departure sensor.""" + sensors = [] + for next_departure in config.get(CONF_NEXT_DEPARTURE): + sensors.append( + RMVDepartureSensor( + next_departure[CONF_STATION], + next_departure.get(CONF_DESTINATIONS), + next_departure.get(CONF_DIRECTIONS), + next_departure.get(CONF_LINES), + next_departure.get(CONF_PRODUCTS), + next_departure.get(CONF_TIME_OFFSET), + next_departure.get(CONF_MAX_JOURNEYS), + next_departure.get(CONF_NAME))) + add_entities(sensors, True) + + +class RMVDepartureSensor(Entity): + """Implementation of an RMV departure sensor.""" + + def __init__(self, station, destinations, directions, + lines, products, time_offset, max_journeys, name): + """Initialize the sensor.""" + self._station = station + self._name = name + self._state = None + self.data = RMVDepartureData(station, destinations, directions, lines, + products, time_offset, max_journeys) + self._icon = ICONS[None] + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def available(self): + """Return True if entity is available.""" + return self._state is not None + + @property + def state(self): + """Return the next departure time.""" + return self._state + + @property + def state_attributes(self): + """Return the state attributes.""" + try: + return { + 'next_departures': [val for val in self.data.departures[1:]], + 'direction': self.data.departures[0].get('direction'), + 'line': self.data.departures[0].get('line'), + 'minutes': self.data.departures[0].get('minutes'), + 'departure_time': + self.data.departures[0].get('departure_time'), + 'product': self.data.departures[0].get('product'), + } + except IndexError: + return {} + + @property + def icon(self): + """Icon to use in the frontend, if any.""" + return self._icon + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return "min" + + def update(self): + """Get the latest data and update the state.""" + self.data.update() + if not self.data.departures: + self._state = None + self._icon = ICONS[None] + return + if self._name == DEFAULT_NAME: + self._name = self.data.station + self._station = self.data.station + self._state = self.data.departures[0].get('minutes') + self._icon = ICONS[self.data.departures[0].get('product')] + + +class RMVDepartureData: + """Pull data from the opendata.rmv.de web page.""" + + def __init__(self, station_id, destinations, directions, + lines, products, time_offset, max_journeys): + """Initialize the sensor.""" + import RMVtransport + self.station = None + self._station_id = station_id + self._destinations = destinations + self._directions = directions + self._lines = lines + self._products = products + self._time_offset = time_offset + self._max_journeys = max_journeys + self.rmv = RMVtransport.RMVtransport() + self.departures = [] + + def update(self): + """Update the connection data.""" + try: + _data = self.rmv.get_departures(self._station_id, + products=self._products, + maxJourneys=50) + except ValueError: + self.departures = [] + _LOGGER.warning("Returned data not understood") + return + self.station = _data.get('station') + _deps = [] + for journey in _data['journeys']: + # find the first departure meeting the criteria + _nextdep = {ATTR_ATTRIBUTION: ATTRIBUTION} + if self._destinations: + dest_found = False + for dest in self._destinations: + if dest in journey['stops']: + dest_found = True + _nextdep['destination'] = dest + if not dest_found: + continue + elif self._lines and journey['number'] not in self._lines: + continue + elif journey['minutes'] < self._time_offset: + continue + for attr in ['direction', 'departure_time', 'product', 'minutes']: + _nextdep[attr] = journey.get(attr, '') + _nextdep['line'] = journey.get('number', '') + _deps.append(_nextdep) + if len(_deps) > self._max_journeys: + break + self.departures = _deps diff --git a/homeassistant/components/sensor/sabnzbd.py b/homeassistant/components/sensor/sabnzbd.py index 194ff71222a207..185f83c9405406 100644 --- a/homeassistant/components/sensor/sabnzbd.py +++ b/homeassistant/components/sensor/sabnzbd.py @@ -4,216 +4,75 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.sabnzbd/ """ -import asyncio import logging -from datetime import timedelta -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - CONF_HOST, CONF_API_KEY, CONF_NAME, CONF_PORT, CONF_MONITORED_VARIABLES, - CONF_SSL) +from homeassistant.components.sabnzbd import DATA_SABNZBD, \ + SIGNAL_SABNZBD_UPDATED, SENSOR_TYPES +from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle -from homeassistant.util.json import load_json, save_json -import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pysabnzbd==1.0.1'] +DEPENDENCIES = ['sabnzbd'] -_CONFIGURING = {} _LOGGER = logging.getLogger(__name__) -CONFIG_FILE = 'sabnzbd.conf' -DEFAULT_NAME = 'SABnzbd' -DEFAULT_PORT = 8080 -DEFAULT_SSL = False - -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1) - -SENSOR_TYPES = { - 'current_status': ['Status', None], - 'speed': ['Speed', 'MB/s'], - 'queue_size': ['Queue', 'MB'], - 'queue_remaining': ['Left', 'MB'], - 'disk_size': ['Disk', 'GB'], - 'disk_free': ['Disk Free', 'GB'], - 'queue_count': ['Queue Count', None], -} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_MONITORED_VARIABLES, default=['current_status']): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, - vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, -}) - - -@asyncio.coroutine -def async_check_sabnzbd(sab_api, base_url, api_key): - """Check if we can reach SABnzbd.""" - from pysabnzbd import SabnzbdApiException - sab_api = sab_api(base_url, api_key) - - try: - yield from sab_api.check_available() - except SabnzbdApiException: - _LOGGER.error("Connection to SABnzbd API failed") - return False - return True - - -def setup_sabnzbd(base_url, apikey, name, config, - async_add_devices, sab_api): - """Set up polling from SABnzbd and sensors.""" - sab_api = sab_api(base_url, apikey) - monitored = config.get(CONF_MONITORED_VARIABLES) - async_add_devices([SabnzbdSensor(variable, sab_api, name) - for variable in monitored]) - - -@Throttle(MIN_TIME_BETWEEN_UPDATES) -async def async_update_queue(sab_api): - """ - Throttled function to update SABnzbd queue. - - This ensures that the queue info only gets updated once for all sensors - """ - await sab_api.refresh_data() - -def request_configuration(host, name, hass, config, async_add_devices, - sab_api): - """Request configuration steps from the user.""" - configurator = hass.components.configurator - # We got an error if this method is called while we are configuring - if host in _CONFIGURING: - configurator.notify_errors(_CONFIGURING[host], - 'Failed to register, please try again.') - - return - - @asyncio.coroutine - def async_configuration_callback(data): - """Handle configuration changes.""" - api_key = data.get('api_key') - if (yield from async_check_sabnzbd(sab_api, host, api_key)): - setup_sabnzbd(host, api_key, name, config, - async_add_devices, sab_api) - - def success(): - """Set up was successful.""" - conf = load_json(hass.config.path(CONFIG_FILE)) - conf[host] = {'api_key': api_key} - save_json(hass.config.path(CONFIG_FILE), conf) - req_config = _CONFIGURING.pop(host) - configurator.async_request_done(req_config) - - hass.async_add_job(success) - - _CONFIGURING[host] = configurator.async_request_config( - DEFAULT_NAME, - async_configuration_callback, - description='Enter the API Key', - submit_caption='Confirm', - fields=[{'id': 'api_key', 'name': 'API Key', 'type': ''}] - ) - - -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): - """Set up the SABnzbd platform.""" - from pysabnzbd import SabnzbdApi - - if discovery_info is not None: - host = discovery_info.get(CONF_HOST) - port = discovery_info.get(CONF_PORT) - name = DEFAULT_NAME - use_ssl = discovery_info.get('properties', {}).get('https', '0') == '1' - else: - host = config.get(CONF_HOST) - port = config.get(CONF_PORT) - name = config.get(CONF_NAME, DEFAULT_NAME) - use_ssl = config.get(CONF_SSL) - - api_key = config.get(CONF_API_KEY) - - uri_scheme = 'https://' if use_ssl else 'http://' - base_url = "{}{}:{}/".format(uri_scheme, host, port) - - if not api_key: - conf = load_json(hass.config.path(CONFIG_FILE)) - if conf.get(base_url, {}).get('api_key'): - api_key = conf[base_url]['api_key'] - - if not (yield from async_check_sabnzbd(SabnzbdApi, base_url, api_key)): - request_configuration(base_url, name, hass, config, - async_add_devices, SabnzbdApi) +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the SABnzbd sensors.""" + if discovery_info is None: return - setup_sabnzbd(base_url, api_key, name, config, - async_add_devices, SabnzbdApi) + sab_api_data = hass.data[DATA_SABNZBD] + sensors = sab_api_data.sensors + client_name = sab_api_data.name + async_add_devices([SabnzbdSensor(sensor, sab_api_data, client_name) + for sensor in sensors]) class SabnzbdSensor(Entity): """Representation of an SABnzbd sensor.""" - def __init__(self, sensor_type, sabnzbd_api, client_name): + def __init__(self, sensor_type, sabnzbd_api_data, client_name): """Initialize the sensor.""" + self._client_name = client_name + self._field_name = SENSOR_TYPES[sensor_type][2] self._name = SENSOR_TYPES[sensor_type][0] - self.sabnzbd_api = sabnzbd_api - self.type = sensor_type - self.client_name = client_name + self._sabnzbd_api = sabnzbd_api_data self._state = None + self._type = sensor_type self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + async def async_added_to_hass(self): + """Call when entity about to be added to hass.""" + async_dispatcher_connect(self.hass, SIGNAL_SABNZBD_UPDATED, + self.update_state) + @property def name(self): """Return the name of the sensor.""" - return '{} {}'.format(self.client_name, self._name) + return '{} {}'.format(self._client_name, self._name) @property def state(self): """Return the state of the sensor.""" return self._state + def should_poll(self): + """Don't poll. Will be updated by dispatcher signal.""" + return False + @property def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement - @asyncio.coroutine - def async_refresh_sabnzbd_data(self): - """Call the throttled SABnzbd refresh method.""" - from pysabnzbd import SabnzbdApiException - try: - yield from async_update_queue(self.sabnzbd_api) - except SabnzbdApiException: - _LOGGER.exception("Connection to SABnzbd API failed") - - @asyncio.coroutine - def async_update(self): + def update_state(self, args): """Get the latest data and updates the states.""" - yield from self.async_refresh_sabnzbd_data() - - if self.sabnzbd_api.queue: - if self.type == 'current_status': - self._state = self.sabnzbd_api.queue.get('status') - elif self.type == 'speed': - mb_spd = float(self.sabnzbd_api.queue.get('kbpersec')) / 1024 - self._state = round(mb_spd, 1) - elif self.type == 'queue_size': - self._state = self.sabnzbd_api.queue.get('mb') - elif self.type == 'queue_remaining': - self._state = self.sabnzbd_api.queue.get('mbleft') - elif self.type == 'disk_size': - self._state = self.sabnzbd_api.queue.get('diskspacetotal1') - elif self.type == 'disk_free': - self._state = self.sabnzbd_api.queue.get('diskspace1') - elif self.type == 'queue_count': - self._state = self.sabnzbd_api.queue.get('noofslots_total') - else: - self._state = 'Unknown' + self._state = self._sabnzbd_api.get_queue_field(self._field_name) + + if self._type == 'speed': + self._state = round(float(self._state) / 1024, 1) + elif 'size' in self._type: + self._state = round(float(self._state), 2) + + self.schedule_update_ha_state() diff --git a/homeassistant/components/sensor/scrape.py b/homeassistant/components/sensor/scrape.py index 0065f3e0927473..e7aace8ec6d24a 100644 --- a/homeassistant/components/sensor/scrape.py +++ b/homeassistant/components/sensor/scrape.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['beautifulsoup4==4.6.0'] +REQUIREMENTS = ['beautifulsoup4==4.6.1'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/season.py b/homeassistant/components/sensor/season.py index b04b7727e4074c..f06f6a896e7357 100644 --- a/homeassistant/components/sensor/season.py +++ b/homeassistant/components/sensor/season.py @@ -12,7 +12,7 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import CONF_TYPE from homeassistant.helpers.entity import Entity -import homeassistant.util as util +from homeassistant import util REQUIREMENTS = ['ephem==3.7.6.0'] diff --git a/homeassistant/components/sensor/sense.py b/homeassistant/components/sensor/sense.py index 5eee9053db52d6..89e0d15bf488e7 100644 --- a/homeassistant/components/sensor/sense.py +++ b/homeassistant/components/sensor/sense.py @@ -16,7 +16,7 @@ from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['sense_energy==0.3.1'] +REQUIREMENTS = ['sense_energy==0.4.1'] _LOGGER = logging.getLogger(__name__) @@ -27,7 +27,7 @@ ACTIVE_TYPE = 'active' -class SensorConfig(object): +class SensorConfig: """Data structure holding sensor config.""" def __init__(self, name, sensor_type): @@ -139,7 +139,12 @@ def icon(self): def update(self): """Get the latest data, update state.""" - self.update_sensor() + from sense_energy import SenseAPITimeoutException + try: + self.update_sensor() + except SenseAPITimeoutException: + _LOGGER.error("Timeout retrieving data") + return if self._sensor_type == ACTIVE_TYPE: if self._is_production: diff --git a/homeassistant/components/sensor/sensehat.py b/homeassistant/components/sensor/sensehat.py index a50f4cdfd2c694..f0e566f718f613 100644 --- a/homeassistant/components/sensor/sensehat.py +++ b/homeassistant/components/sensor/sensehat.py @@ -109,7 +109,7 @@ def update(self): self._state = self.data.pressure -class SenseHatData(object): +class SenseHatData: """Get the latest data and update.""" def __init__(self, is_hat_attached): diff --git a/homeassistant/components/sensor/shodan.py b/homeassistant/components/sensor/shodan.py index 720158e10298bc..dfc49ce6639879 100644 --- a/homeassistant/components/sensor/shodan.py +++ b/homeassistant/components/sensor/shodan.py @@ -14,7 +14,7 @@ from homeassistant.const import ATTR_ATTRIBUTION, CONF_API_KEY, CONF_NAME from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['shodan==1.7.7'] +REQUIREMENTS = ['shodan==1.9.0'] _LOGGER = logging.getLogger(__name__) @@ -96,7 +96,7 @@ def update(self): self._state = self.data.details['total'] -class ShodanData(object): +class ShodanData: """Get the latest data and update the states.""" def __init__(self, api, query): diff --git a/homeassistant/components/sensor/sht31.py b/homeassistant/components/sensor/sht31.py index e1a7f3c9e5f6f3..2aeff8e73d8271 100644 --- a/homeassistant/components/sensor/sht31.py +++ b/homeassistant/components/sensor/sht31.py @@ -75,7 +75,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices(devs) -class SHTClient(object): +class SHTClient: """Get the latest data from the SHT sensor.""" def __init__(self, adafruit_sht): diff --git a/homeassistant/components/sensor/sigfox.py b/homeassistant/components/sensor/sigfox.py index ef47132eefc5a3..408435a966701e 100644 --- a/homeassistant/components/sensor/sigfox.py +++ b/homeassistant/components/sensor/sigfox.py @@ -55,7 +55,7 @@ def epoch_to_datetime(epoch_time): return datetime.datetime.fromtimestamp(epoch_time).isoformat() -class SigfoxAPI(object): +class SigfoxAPI: """Class for interacting with the SigFox API.""" def __init__(self, api_login, api_password): @@ -66,7 +66,7 @@ def __init__(self, api_login, api_password): self._devices = self.get_devices(device_types) def check_credentials(self): - """"Check API credentials are valid.""" + """Check API credentials are valid.""" url = urljoin(API_URL, 'devicetypes') response = requests.get(url, auth=self._auth, timeout=10) if response.status_code != 200: diff --git a/homeassistant/components/sensor/simulated.py b/homeassistant/components/sensor/simulated.py index 7091146e3aca72..419ca7c13fb175 100644 --- a/homeassistant/components/sensor/simulated.py +++ b/homeassistant/components/sensor/simulated.py @@ -4,51 +4,53 @@ For more details about this platform, refer to the documentation at https://home-assistant.io/components/sensor.simulated/ """ -import asyncio -import datetime as datetime +import logging import math from random import Random -import logging +from datetime import datetime import voluptuous as vol -import homeassistant.util.dt as dt_util -from homeassistant.helpers.entity import Entity -import homeassistant.helpers.config_validation as cv -from homeassistant.const import CONF_NAME from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.const import CONF_NAME +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = datetime.timedelta(seconds=30) -ICON = 'mdi:chart-line' -CONF_UNIT = 'unit' CONF_AMP = 'amplitude' +CONF_FWHM = 'spread' CONF_MEAN = 'mean' CONF_PERIOD = 'period' CONF_PHASE = 'phase' -CONF_FWHM = 'spread' CONF_SEED = 'seed' +CONF_UNIT = 'unit' +CONF_RELATIVE_TO_EPOCH = 'relative_to_epoch' -DEFAULT_NAME = 'simulated' -DEFAULT_UNIT = 'value' DEFAULT_AMP = 1 +DEFAULT_FWHM = 0 DEFAULT_MEAN = 0 +DEFAULT_NAME = 'simulated' DEFAULT_PERIOD = 60 DEFAULT_PHASE = 0 -DEFAULT_FWHM = 0 DEFAULT_SEED = 999 +DEFAULT_UNIT = 'value' +DEFAULT_RELATIVE_TO_EPOCH = True +ICON = 'mdi:chart-line' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_UNIT, default=DEFAULT_UNIT): cv.string, vol.Optional(CONF_AMP, default=DEFAULT_AMP): vol.Coerce(float), + vol.Optional(CONF_FWHM, default=DEFAULT_FWHM): vol.Coerce(float), vol.Optional(CONF_MEAN, default=DEFAULT_MEAN): vol.Coerce(float), + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PERIOD, default=DEFAULT_PERIOD): cv.positive_int, vol.Optional(CONF_PHASE, default=DEFAULT_PHASE): vol.Coerce(float), - vol.Optional(CONF_FWHM, default=DEFAULT_FWHM): vol.Coerce(float), vol.Optional(CONF_SEED, default=DEFAULT_SEED): cv.positive_int, + vol.Optional(CONF_UNIT, default=DEFAULT_UNIT): cv.string, + vol.Optional(CONF_RELATIVE_TO_EPOCH, default=DEFAULT_RELATIVE_TO_EPOCH): + cv.boolean, }) @@ -62,17 +64,18 @@ def setup_platform(hass, config, add_devices, discovery_info=None): phase = config.get(CONF_PHASE) fwhm = config.get(CONF_FWHM) seed = config.get(CONF_SEED) + relative_to_epoch = config.get(CONF_RELATIVE_TO_EPOCH) - sensor = SimulatedSensor( - name, unit, amp, mean, period, phase, fwhm, seed - ) + sensor = SimulatedSensor(name, unit, amp, mean, period, phase, fwhm, seed, + relative_to_epoch) add_devices([sensor], True) class SimulatedSensor(Entity): """Class for simulated sensor.""" - def __init__(self, name, unit, amp, mean, period, phase, fwhm, seed): + def __init__(self, name, unit, amp, mean, period, phase, fwhm, seed, + relative_to_epoch): """Init the class.""" self._name = name self._unit = unit @@ -83,11 +86,15 @@ def __init__(self, name, unit, amp, mean, period, phase, fwhm, seed): self._fwhm = fwhm self._seed = seed self._random = Random(seed) # A local seeded Random - self._start_time = dt_util.utcnow() + self._start_time = ( + datetime(1970, 1, 1, tzinfo=dt_util.UTC) if relative_to_epoch + else dt_util.utcnow() + ) + self._relative_to_epoch = relative_to_epoch self._state = None def time_delta(self): - """"Return the time delta.""" + """Return the time delta.""" dt0 = self._start_time dt1 = dt_util.utcnow() return dt1 - dt0 @@ -105,10 +112,9 @@ def signal_calc(self): else: periodic = amp * (math.sin((2*math.pi*time_delta/period) + phase)) noise = self._random.gauss(mu=0, sigma=fwhm) - return mean + periodic + noise + return round(mean + periodic + noise, 3) - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Update the sensor.""" self._state = self.signal_calc() @@ -142,5 +148,6 @@ def device_state_attributes(self): 'phase': self._phase, 'spread': self._fwhm, 'seed': self._seed, + 'relative_to_epoch': self._relative_to_epoch, } return attr diff --git a/homeassistant/components/sensor/skybeacon.py b/homeassistant/components/sensor/skybeacon.py index eabc33312b24a8..2731587ed710e3 100644 --- a/homeassistant/components/sensor/skybeacon.py +++ b/homeassistant/components/sensor/skybeacon.py @@ -10,39 +10,37 @@ import voluptuous as vol -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_NAME, CONF_MAC, TEMP_CELSIUS, STATE_UNKNOWN, EVENT_HOMEASSISTANT_STOP) + CONF_MAC, CONF_NAME, EVENT_HOMEASSISTANT_STOP, STATE_UNKNOWN, TEMP_CELSIUS) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity -# REQUIREMENTS = ['pygatt==3.1.1'] +REQUIREMENTS = ['pygatt==3.2.0'] _LOGGER = logging.getLogger(__name__) -CONNECT_LOCK = threading.Lock() - ATTR_DEVICE = 'device' ATTR_MODEL = 'model' +BLE_TEMP_HANDLE = 0x24 +BLE_TEMP_UUID = '0000ff92-0000-1000-8000-00805f9b34fb' + +CONNECT_LOCK = threading.Lock() +CONNECT_TIMEOUT = 30 + +DEFAULT_NAME = 'Skybeacon' + +SKIP_HANDLE_LOOKUP = True + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_MAC): cv.string, - vol.Optional(CONF_NAME, default=""): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -BLE_TEMP_UUID = '0000ff92-0000-1000-8000-00805f9b34fb' -BLE_TEMP_HANDLE = 0x24 -SKIP_HANDLE_LOOKUP = True -CONNECT_TIMEOUT = 30 - -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Skybeacon sensor.""" - _LOGGER.warning("This platform has been disabled due to having a " - "requirement depending on enum34.") - return - # pylint: disable=unreachable name = config.get(CONF_NAME) mac = config.get(CONF_MAC) _LOGGER.debug("Setting up...") @@ -140,7 +138,7 @@ def __init__(self, hass, mac, name): def run(self): """Thread that keeps connection alive.""" - # pylint: disable=import-error, no-name-in-module, no-member + # pylint: disable=import-error import pygatt from pygatt.backends import Characteristic from pygatt.exceptions import ( @@ -150,7 +148,7 @@ def run(self): adapter = pygatt.backends.GATTToolBackend() while True: try: - _LOGGER.info("Connecting to %s", self.name) + _LOGGER.debug("Connecting to %s", self.name) # We need concurrent connect, so lets not reset the device adapter.start(reset_on_start=False) # Seems only one connection can be initiated at a time diff --git a/homeassistant/components/sensor/sma.py b/homeassistant/components/sensor/sma.py index 3451789424b08c..2be46da0bdb01c 100644 --- a/homeassistant/components/sensor/sma.py +++ b/homeassistant/components/sensor/sma.py @@ -198,5 +198,4 @@ def async_update_values(self, key_values): update = True self._state = new_state - return self.async_update_ha_state() if update else None \ - # pylint: disable=protected-access + return self.async_update_ha_state() if update else None diff --git a/homeassistant/components/sensor/smappee.py b/homeassistant/components/sensor/smappee.py index 5b84962144d5b0..783c2aad4693ae 100644 --- a/homeassistant/components/sensor/smappee.py +++ b/homeassistant/components/sensor/smappee.py @@ -189,8 +189,10 @@ def update(self): data = self._smappee.sensor_consumption[self._location_id]\ .get(int(sensor_id)) if data: - consumption = data.get('records')[-1] - _LOGGER.debug("%s (%s) %s", - sensor_name, sensor_id, consumption) - value = consumption.get(self._smappe_name) - self._state = value + tempdata = data.get('records') + if tempdata: + consumption = tempdata[-1] + _LOGGER.debug("%s (%s) %s", + sensor_name, sensor_id, consumption) + value = consumption.get(self._smappe_name) + self._state = value diff --git a/homeassistant/components/sensor/snmp.py b/homeassistant/components/sensor/snmp.py index 95bf207acf8e39..e6119ab80b6d31 100644 --- a/homeassistant/components/sensor/snmp.py +++ b/homeassistant/components/sensor/snmp.py @@ -16,7 +16,7 @@ CONF_HOST, CONF_NAME, CONF_PORT, CONF_UNIT_OF_MEASUREMENT, STATE_UNKNOWN, CONF_VALUE_TEMPLATE) -REQUIREMENTS = ['pysnmp==4.4.4'] +REQUIREMENTS = ['pysnmp==4.4.5'] _LOGGER = logging.getLogger(__name__) @@ -83,11 +83,10 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if errindication and not accept_errors: _LOGGER.error("Please check the details in the configuration file") return False - else: - data = SnmpData( - host, port, community, baseoid, version, accept_errors, - default_value) - add_devices([SnmpSensor(data, name, unit, value_template)], True) + data = SnmpData( + host, port, community, baseoid, version, accept_errors, + default_value) + add_devices([SnmpSensor(data, name, unit, value_template)], True) class SnmpSensor(Entity): @@ -131,7 +130,7 @@ def update(self): self._state = value -class SnmpData(object): +class SnmpData: """Get the latest data and update the states.""" def __init__(self, host, port, community, baseoid, version, accept_errors, diff --git a/homeassistant/components/sensor/sonarr.py b/homeassistant/components/sensor/sonarr.py index 090addb5b6ee5a..c2fd6c8066330f 100644 --- a/homeassistant/components/sensor/sonarr.py +++ b/homeassistant/components/sensor/sonarr.py @@ -158,8 +158,12 @@ def device_state_attributes(self): ) elif self.type == 'series': for show in self.data: - attributes[show['title']] = '{}/{} Episodes'.format( - show['episodeFileCount'], show['episodeCount']) + if 'episodeFileCount' not in show \ + or 'episodeCount' not in show: + attributes[show['title']] = 'N/A' + else: + attributes[show['title']] = '{}/{} Episodes'.format( + show['episodeFileCount'], show['episodeCount']) elif self.type == 'status': attributes = self.data return attributes diff --git a/homeassistant/components/sensor/speedtest.py b/homeassistant/components/sensor/speedtest.py index 5b03be036d54a5..8c1ffc03786216 100644 --- a/homeassistant/components/sensor/speedtest.py +++ b/homeassistant/components/sensor/speedtest.py @@ -17,7 +17,7 @@ from homeassistant.helpers.restore_state import async_get_last_state import homeassistant.util.dt as dt_util -REQUIREMENTS = ['speedtest-cli==2.0.0'] +REQUIREMENTS = ['speedtest-cli==2.0.2'] _LOGGER = logging.getLogger(__name__) @@ -148,7 +148,7 @@ def async_added_to_hass(self): self._state = state.state -class SpeedtestData(object): +class SpeedtestData: """Get the latest data from speedtest.net.""" def __init__(self, hass, config): diff --git a/homeassistant/components/sensor/spotcrime.py b/homeassistant/components/sensor/spotcrime.py index 08177c9a7b9200..daa520f2ede077 100644 --- a/homeassistant/components/sensor/spotcrime.py +++ b/homeassistant/components/sensor/spotcrime.py @@ -44,7 +44,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Crime Reports platform.""" latitude = config.get(CONF_LATITUDE, hass.config.latitude) diff --git a/homeassistant/components/sensor/sql.py b/homeassistant/components/sensor/sql.py index b7ece1bdb87baa..83f5478867fe30 100644 --- a/homeassistant/components/sensor/sql.py +++ b/homeassistant/components/sensor/sql.py @@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['sqlalchemy==1.2.7'] +REQUIREMENTS = ['sqlalchemy==1.2.10'] CONF_COLUMN_NAME = 'column' CONF_QUERIES = 'queries' diff --git a/homeassistant/components/sensor/startca.py b/homeassistant/components/sensor/startca.py index aefbc2d46268ca..374e14c5ac2ea2 100644 --- a/homeassistant/components/sensor/startca.py +++ b/homeassistant/components/sensor/startca.py @@ -118,7 +118,7 @@ def async_update(self): self._state = round(self.startcadata.data[self.type], 2) -class StartcaData(object): +class StartcaData: """Get data from Start.ca API.""" def __init__(self, loop, websession, api_key, bandwidth_cap): diff --git a/homeassistant/components/sensor/statistics.py b/homeassistant/components/sensor/statistics.py index 7b2ae537d4b1ec..353330909105c5 100644 --- a/homeassistant/components/sensor/statistics.py +++ b/homeassistant/components/sensor/statistics.py @@ -97,7 +97,6 @@ def __init__(self, hass, entity_id, name, sampling_size, max_age): hass.async_add_job(self._initialize_from_database) @callback - # pylint: disable=invalid-name def async_stats_sensor_state_listener(entity, old_state, new_state): """Handle the sensor state changes.""" self._unit_of_measurement = new_state.attributes.get( @@ -156,7 +155,7 @@ def device_state_attributes(self): ATTR_CHANGE: self.change, ATTR_AVERAGE_CHANGE: self.average_change, } - # Only return min/max age if we have a age span + # Only return min/max age if we have an age span if self._max_age: state.update({ ATTR_MAX_AGE: self.max_age, diff --git a/homeassistant/components/sensor/steam_online.py b/homeassistant/components/sensor/steam_online.py index 88cb786e66d8d9..7521b74cd28e43 100644 --- a/homeassistant/components/sensor/steam_online.py +++ b/homeassistant/components/sensor/steam_online.py @@ -36,7 +36,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Steam platform.""" import steam as steamod @@ -77,7 +76,6 @@ def state(self): """Return the state of the sensor.""" return self._state - # pylint: disable=no-member def update(self): """Update device state.""" try: diff --git a/homeassistant/components/sensor/strings.moon.json b/homeassistant/components/sensor/strings.moon.json new file mode 100644 index 00000000000000..97d96623d88439 --- /dev/null +++ b/homeassistant/components/sensor/strings.moon.json @@ -0,0 +1,12 @@ +{ + "state": { + "new_moon": "New moon", + "waxing_crescent": "Waxing crescent", + "first_quarter": "First quarter", + "waxing_gibbous": "Waxing gibbous", + "full_moon": "Full moon", + "waning_gibbous": "Waning gibbous", + "last_quarter": "Last quarter", + "waning_crescent": "Waning crescent" + } +} diff --git a/homeassistant/components/sensor/supervisord.py b/homeassistant/components/sensor/supervisord.py index fd0c6292de2bb4..5a302462bbf188 100644 --- a/homeassistant/components/sensor/supervisord.py +++ b/homeassistant/components/sensor/supervisord.py @@ -26,7 +26,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Supervisord platform.""" url = config.get(CONF_URL) diff --git a/homeassistant/components/sensor/swiss_hydrological_data.py b/homeassistant/components/sensor/swiss_hydrological_data.py index 63d500e2373bfa..b4536b48c9ecf3 100644 --- a/homeassistant/components/sensor/swiss_hydrological_data.py +++ b/homeassistant/components/sensor/swiss_hydrological_data.py @@ -145,7 +145,7 @@ def update(self): self._state = self.data.measurings['03']['current'] -class HydrologicalData(object): +class HydrologicalData: """The Class for handling the data retrieval.""" def __init__(self, station): diff --git a/homeassistant/components/sensor/swiss_public_transport.py b/homeassistant/components/sensor/swiss_public_transport.py index a489adf6776d3c..72c6aa2e1a3dfd 100644 --- a/homeassistant/components/sensor/swiss_public_transport.py +++ b/homeassistant/components/sensor/swiss_public_transport.py @@ -4,7 +4,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.swiss_public_transport/ """ -import asyncio from datetime import timedelta import logging @@ -17,7 +16,7 @@ from homeassistant.helpers.entity import Entity import homeassistant.util.dt as dt_util -REQUIREMENTS = ['python_opendata_transport==0.0.3'] +REQUIREMENTS = ['python_opendata_transport==0.1.3'] _LOGGER = logging.getLogger(__name__) @@ -48,8 +47,8 @@ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): """Set up the Swiss public transport sensor.""" from opendata_transport import OpendataTransport, exceptions @@ -61,7 +60,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): opendata = OpendataTransport(start, destination, hass.loop, session) try: - yield from opendata.async_get_data() + await opendata.async_get_data() except exceptions.OpendataTransportError: _LOGGER.error( "Check at http://transport.opendata.ch/examples/stationboard.html " @@ -122,12 +121,11 @@ def icon(self): """Icon to use in the frontend, if any.""" return ICON - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Get the latest data from opendata.ch and update the states.""" from opendata_transport.exceptions import OpendataTransportError try: - yield from self._opendata.async_get_data() + await self._opendata.async_get_data() except OpendataTransportError: _LOGGER.error("Unable to retrieve data from transport.opendata.ch") diff --git a/homeassistant/components/sensor/synologydsm.py b/homeassistant/components/sensor/synologydsm.py index a0198169b6d3d4..d431805ab191d2 100644 --- a/homeassistant/components/sensor/synologydsm.py +++ b/homeassistant/components/sensor/synologydsm.py @@ -12,18 +12,20 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, TEMP_CELSIUS, - CONF_MONITORED_CONDITIONS, EVENT_HOMEASSISTANT_START, CONF_DISKS) + CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PORT, CONF_SSL, + ATTR_ATTRIBUTION, TEMP_CELSIUS, CONF_MONITORED_CONDITIONS, + EVENT_HOMEASSISTANT_START, CONF_DISKS) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['python-synology==0.1.0'] +REQUIREMENTS = ['python-synology==0.2.0'] _LOGGER = logging.getLogger(__name__) +CONF_ATTRIBUTION = 'Data provided by Synology' CONF_VOLUMES = 'volumes' DEFAULT_NAME = 'Synology DSM' -DEFAULT_PORT = 5000 +DEFAULT_PORT = 5001 MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=15) @@ -74,6 +76,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, + vol.Optional(CONF_SSL, default=True): cv.boolean, vol.Required(CONF_USERNAME): cv.string, vol.Required(CONF_PASSWORD): cv.string, vol.Optional(CONF_MONITORED_CONDITIONS): @@ -95,10 +98,11 @@ def run_setup(event): port = config.get(CONF_PORT) username = config.get(CONF_USERNAME) password = config.get(CONF_PASSWORD) + use_ssl = config.get(CONF_SSL) unit = hass.config.units.temperature_unit monitored_conditions = config.get(CONF_MONITORED_CONDITIONS) - api = SynoApi(host, port, username, password, unit) + api = SynoApi(host, port, username, password, unit, use_ssl) sensors = [SynoNasUtilSensor( api, variable, _UTILISATION_MON_COND[variable]) @@ -125,17 +129,18 @@ def run_setup(event): hass.bus.listen_once(EVENT_HOMEASSISTANT_START, run_setup) -class SynoApi(object): +class SynoApi: """Class to interface with Synology DSM API.""" - def __init__(self, host, port, username, password, temp_unit): + def __init__(self, host, port, username, password, temp_unit, use_ssl): """Initialize the API wrapper class.""" from SynologyDSM import SynologyDSM self.temp_unit = temp_unit try: - self._api = SynologyDSM(host, port, username, password) - except: # noqa: E722 # pylint: disable=bare-except + self._api = SynologyDSM(host, port, username, password, + use_https=use_ssl) + except: # noqa: E722 pylint: disable=bare-except _LOGGER.error("Error setting up Synology DSM") # Will be updated when update() gets called. @@ -185,6 +190,13 @@ def update(self): if self._api is not None: self._api.update() + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, + } + class SynoNasUtilSensor(SynoNasSensor): """Representation a Synology Utilisation Sensor.""" @@ -202,7 +214,7 @@ def state(self): if self.var_id in network_sensors: return round(attr / 1024.0, 1) - elif self.var_id in memory_sensors: + if self.var_id in memory_sensors: return round(attr / 1024.0 / 1024.0, 1) else: return getattr(self._api.utilisation, self.var_id) diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index 0b85de8e4f23dd..1883ee89d4e2f6 100644 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -10,13 +10,12 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - CONF_RESOURCES, STATE_OFF, STATE_ON, STATE_UNKNOWN, CONF_TYPE) +from homeassistant.const import CONF_RESOURCES, STATE_OFF, STATE_ON, CONF_TYPE from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['psutil==5.4.5'] +REQUIREMENTS = ['psutil==5.4.6'] _LOGGER = logging.getLogger(__name__) @@ -68,7 +67,6 @@ } -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the system monitor sensors.""" dev = [] @@ -157,19 +155,19 @@ def update(self): counter = counters[self.argument][IO_COUNTER[self.type]] self._state = round(counter / 1024**2, 1) else: - self._state = STATE_UNKNOWN + self._state = None elif self.type == 'packets_out' or self.type == 'packets_in': counters = psutil.net_io_counters(pernic=True) if self.argument in counters: self._state = counters[self.argument][IO_COUNTER[self.type]] else: - self._state = STATE_UNKNOWN + self._state = None elif self.type == 'ipv4_address' or self.type == 'ipv6_address': addresses = psutil.net_if_addrs() if self.argument in addresses: self._state = addresses[self.argument][IF_ADDRS[self.type]][1] else: - self._state = STATE_UNKNOWN + self._state = None elif self.type == 'last_boot': self._state = dt_util.as_local( dt_util.utc_from_timestamp(psutil.boot_time()) diff --git a/homeassistant/components/sensor/sytadin.py b/homeassistant/components/sensor/sytadin.py index 9a85eb255751cc..ff8e7d7ddfeec2 100644 --- a/homeassistant/components/sensor/sytadin.py +++ b/homeassistant/components/sensor/sytadin.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['beautifulsoup4==4.6.0'] +REQUIREMENTS = ['beautifulsoup4==4.6.1'] _LOGGER = logging.getLogger(__name__) @@ -35,9 +35,9 @@ OPTION_CONGESTION = 'congestion' SENSOR_TYPES = { - OPTION_TRAFFIC_JAM: ['Traffic Jam', LENGTH_KILOMETERS], - OPTION_MEAN_VELOCITY: ['Mean Velocity', LENGTH_KILOMETERS+'/h'], OPTION_CONGESTION: ['Congestion', ''], + OPTION_MEAN_VELOCITY: ['Mean Velocity', LENGTH_KILOMETERS+'/h'], + OPTION_TRAFFIC_JAM: ['Traffic Jam', LENGTH_KILOMETERS], } MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5) @@ -113,7 +113,7 @@ def update(self): self._state = self.data.congestion -class SytadinData(object): +class SytadinData: """The class for handling the data retrieval.""" def __init__(self, resource): diff --git a/homeassistant/components/sensor/tado.py b/homeassistant/components/sensor/tado.py index 7acdc1a20bd8c0..aa6314b8c5bb98 100644 --- a/homeassistant/components/sensor/tado.py +++ b/homeassistant/components/sensor/tado.py @@ -6,16 +6,14 @@ """ import logging -from homeassistant.const import TEMP_CELSIUS +from homeassistant.components.tado import DATA_TADO +from homeassistant.const import ATTR_ID, ATTR_NAME, TEMP_CELSIUS from homeassistant.helpers.entity import Entity -from homeassistant.components.tado import (DATA_TADO) -from homeassistant.const import (ATTR_ID) _LOGGER = logging.getLogger(__name__) ATTR_DATA_ID = 'data_id' ATTR_DEVICE = 'device' -ATTR_NAME = 'name' ATTR_ZONE = 'zone' CLIMATE_SENSOR_TYPES = ['temperature', 'humidity', 'power', @@ -39,14 +37,11 @@ def setup_platform(hass, config, add_devices, discovery_info=None): if zone['type'] == 'HEATING': for variable in CLIMATE_SENSOR_TYPES: sensor_items.append(create_zone_sensor( - tado, zone, zone['name'], zone['id'], - variable)) + tado, zone, zone['name'], zone['id'], variable)) elif zone['type'] == 'HOT_WATER': for variable in HOT_WATER_SENSOR_TYPES: sensor_items.append(create_zone_sensor( - tado, zone, zone['name'], zone['id'], - variable - )) + tado, zone, zone['name'], zone['id'], variable)) me_data = tado.get_me() sensor_items.append(create_device_sensor( @@ -127,9 +122,9 @@ def unit_of_measurement(self): """Return the unit of measurement.""" if self.zone_variable == "temperature": return self.hass.config.units.temperature_unit - elif self.zone_variable == "humidity": + if self.zone_variable == "humidity": return '%' - elif self.zone_variable == "heating": + if self.zone_variable == "heating": return '%' @property @@ -137,7 +132,7 @@ def icon(self): """Icon for the sensor.""" if self.zone_variable == "temperature": return 'mdi:thermometer' - elif self.zone_variable == "humidity": + if self.zone_variable == "humidity": return 'mdi:water-percent' def update(self): @@ -152,7 +147,6 @@ def update(self): unit = TEMP_CELSIUS - # pylint: disable=R0912 if self.zone_variable == 'temperature': if 'sensorDataPoints' in data: sensor_data = data['sensorDataPoints'] diff --git a/homeassistant/components/sensor/tahoma.py b/homeassistant/components/sensor/tahoma.py index aedecfe61e564c..6c6c296652a5c7 100644 --- a/homeassistant/components/sensor/tahoma.py +++ b/homeassistant/components/sensor/tahoma.py @@ -46,11 +46,11 @@ def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" if self.tahoma_device.type == 'Temperature Sensor': return None - elif self.tahoma_device.type == 'io:SomfyContactIOSystemSensor': + if self.tahoma_device.type == 'io:SomfyContactIOSystemSensor': return None - elif self.tahoma_device.type == 'io:LightIOSystemSensor': + if self.tahoma_device.type == 'io:LightIOSystemSensor': return 'lx' - elif self.tahoma_device.type == 'Humidity Sensor': + if self.tahoma_device.type == 'Humidity Sensor': return '%' def update(self): diff --git a/homeassistant/components/sensor/ted5000.py b/homeassistant/components/sensor/ted5000.py index 55d520cf6ca67f..7298181796a7ad 100644 --- a/homeassistant/components/sensor/ted5000.py +++ b/homeassistant/components/sensor/ted5000.py @@ -32,7 +32,6 @@ }) -# pylint: disable=unused-variable def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Ted5000 sensor.""" host = config.get(CONF_HOST) @@ -89,7 +88,7 @@ def update(self): self._gateway.update() -class Ted5000Gateway(object): +class Ted5000Gateway: """The class for handling the data retrieval.""" def __init__(self, url): diff --git a/homeassistant/components/sensor/teksavvy.py b/homeassistant/components/sensor/teksavvy.py index 0bf1ef4caff61d..68a1cfc4fe17fa 100644 --- a/homeassistant/components/sensor/teksavvy.py +++ b/homeassistant/components/sensor/teksavvy.py @@ -119,7 +119,7 @@ def async_update(self): self._state = round(self.teksavvydata.data[self.type], 2) -class TekSavvyData(object): +class TekSavvyData: """Get data from TekSavvy API.""" def __init__(self, loop, websession, api_key, bandwidth_cap): diff --git a/homeassistant/components/sensor/tellduslive.py b/homeassistant/components/sensor/tellduslive.py index 048ca988e3d8c1..123c11021b4030 100644 --- a/homeassistant/components/sensor/tellduslive.py +++ b/homeassistant/components/sensor/tellduslive.py @@ -96,11 +96,11 @@ def state(self): """Return the state of the sensor.""" if not self.available: return None - elif self._type == SENSOR_TYPE_TEMPERATURE: + if self._type == SENSOR_TYPE_TEMPERATURE: return self._value_as_temperature - elif self._type == SENSOR_TYPE_HUMIDITY: + if self._type == SENSOR_TYPE_HUMIDITY: return self._value_as_humidity - elif self._type == SENSOR_TYPE_LUMINANCE: + if self._type == SENSOR_TYPE_LUMINANCE: return self._value_as_luminance return self._value diff --git a/homeassistant/components/sensor/tellstick.py b/homeassistant/components/sensor/tellstick.py index 8355add47e92a5..2fc67e57162ced 100644 --- a/homeassistant/components/sensor/tellstick.py +++ b/homeassistant/components/sensor/tellstick.py @@ -37,10 +37,9 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Tellstick sensors.""" - import tellcore.telldus as telldus + from tellcore import telldus import tellcore.constants as tellcore_constants sensor_value_descriptions = { diff --git a/homeassistant/components/sensor/temper.py b/homeassistant/components/sensor/temper.py index 973e07d9cf3435..f0a3e15834cf3e 100644 --- a/homeassistant/components/sensor/temper.py +++ b/homeassistant/components/sensor/temper.py @@ -33,7 +33,6 @@ def get_temper_devices(): return TemperHandler().get_devices() -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Temper sensors.""" temp_unit = hass.config.units.temperature_unit diff --git a/homeassistant/components/sensor/template.py b/homeassistant/components/sensor/template.py index 65f49998dbf9fa..23c7c13f0edec1 100644 --- a/homeassistant/components/sensor/template.py +++ b/homeassistant/components/sensor/template.py @@ -42,7 +42,6 @@ @asyncio.coroutine -# pylint: disable=unused-argument def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the template sensors.""" sensors = [] diff --git a/homeassistant/components/sensor/thethingsnetwork.py b/homeassistant/components/sensor/thethingsnetwork.py index 28a3b48892b6bd..0f27b65640441c 100644 --- a/homeassistant/components/sensor/thethingsnetwork.py +++ b/homeassistant/components/sensor/thethingsnetwork.py @@ -110,7 +110,7 @@ def async_update(self): self._state = self._ttn_data_storage.data -class TtnDataStorage(object): +class TtnDataStorage: """Get the latest data from The Things Network Data Storage.""" def __init__(self, hass, app_id, device_id, access_key, values): diff --git a/homeassistant/components/sensor/thinkingcleaner.py b/homeassistant/components/sensor/thinkingcleaner.py index 83cf799e3cdcd2..0b936d8c8c782e 100644 --- a/homeassistant/components/sensor/thinkingcleaner.py +++ b/homeassistant/components/sensor/thinkingcleaner.py @@ -7,7 +7,7 @@ import logging from datetime import timedelta -import homeassistant.util as util +from homeassistant import util from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/tibber.py b/homeassistant/components/sensor/tibber.py index 42568a6b9ada4b..c75c40dd929ca3 100644 --- a/homeassistant/components/sensor/tibber.py +++ b/homeassistant/components/sensor/tibber.py @@ -123,7 +123,7 @@ def unique_id(self): async def _fetch_data(self): try: await self._tibber_home.update_info() - await self._tibber_home.update_price_info() + await self._tibber_home.update_price_info() except (asyncio.TimeoutError, aiohttp.ClientError): return data = self._tibber_home.info['viewer']['home'] diff --git a/homeassistant/components/sensor/time_date.py b/homeassistant/components/sensor/time_date.py index bfdf0c3c3aad3c..0668b5bdbce246 100644 --- a/homeassistant/components/sensor/time_date.py +++ b/homeassistant/components/sensor/time_date.py @@ -81,7 +81,7 @@ def icon(self): """Icon to use in the frontend, if any.""" if 'date' in self.type and 'time' in self.type: return 'mdi:calendar-clock' - elif 'date' in self.type: + if 'date' in self.type: return 'mdi:calendar' return 'mdi:clock' @@ -92,7 +92,7 @@ def get_next_interval(self, now=None): if self.type == 'date': now = dt_util.start_of_local_day(dt_util.as_local(now)) return now + timedelta(seconds=86400) - elif self.type == 'beat': + if self.type == 'beat': interval = 86.4 else: interval = 60 diff --git a/homeassistant/components/sensor/toon.py b/homeassistant/components/sensor/toon.py index cecce0d270f83d..a8875f6904c7b8 100644 --- a/homeassistant/components/sensor/toon.py +++ b/homeassistant/components/sensor/toon.py @@ -5,7 +5,7 @@ https://home-assistant.io/components/sensor.toon/ """ import logging -import datetime as datetime +import datetime from homeassistant.helpers.entity import Entity import homeassistant.components.toon as toon_main diff --git a/homeassistant/components/sensor/torque.py b/homeassistant/components/sensor/torque.py index 98fad475d52a2e..4ed1b5907cf424 100644 --- a/homeassistant/components/sensor/torque.py +++ b/homeassistant/components/sensor/torque.py @@ -46,7 +46,6 @@ def convert_pid(value): return int(value, 16) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Torque platform.""" vehicle = config.get(CONF_NAME) diff --git a/homeassistant/components/sensor/transmission.py b/homeassistant/components/sensor/transmission.py index 678d9afb81d87f..3e74b4549137de 100644 --- a/homeassistant/components/sensor/transmission.py +++ b/homeassistant/components/sensor/transmission.py @@ -4,23 +4,23 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.transmission/ """ -import logging from datetime import timedelta +import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_USERNAME, CONF_NAME, CONF_PORT, - CONF_MONITORED_VARIABLES, STATE_IDLE) + CONF_HOST, CONF_MONITORED_VARIABLES, CONF_NAME, CONF_PASSWORD, CONF_PORT, + CONF_USERNAME, STATE_IDLE) +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle +from homeassistant.exceptions import PlatformNotReady REQUIREMENTS = ['transmissionrpc==0.11'] _LOGGER = logging.getLogger(__name__) -_THROTTLED_REFRESH = None DEFAULT_NAME = 'Transmission' DEFAULT_PORT = 9091 @@ -29,12 +29,16 @@ 'active_torrents': ['Active Torrents', None], 'current_status': ['Status', None], 'download_speed': ['Down Speed', 'MB/s'], + 'paused_torrents': ['Paused Torrents', None], + 'total_torrents': ['Total Torrents', None], 'upload_speed': ['Up Speed', 'MB/s'], } +SCAN_INTERVAL = timedelta(minutes=2) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_MONITORED_VARIABLES, default=[]): + vol.Optional(CONF_MONITORED_VARIABLES, default=['torrents']): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PASSWORD): cv.string, @@ -43,7 +47,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Transmission sensors.""" import transmissionrpc @@ -56,39 +59,37 @@ def setup_platform(hass, config, add_devices, discovery_info=None): port = config.get(CONF_PORT) try: - transmission_api = transmissionrpc.Client( + transmission = transmissionrpc.Client( host, port=port, user=username, password=password) - transmission_api.session_stats() + transmission_api = TransmissionData(transmission) except TransmissionError as error: - _LOGGER.error( - "Connection to Transmission API failed on %s:%s with message %s", - host, port, error.original - ) - return False + if str(error).find("401: Unauthorized"): + _LOGGER.error("Credentials for Transmission client are not valid") + return - # pylint: disable=global-statement - global _THROTTLED_REFRESH - _THROTTLED_REFRESH = Throttle(timedelta(seconds=1))( - transmission_api.session_stats) + _LOGGER.warning( + "Unable to connect to Transmission client: %s:%s", host, port) + raise PlatformNotReady dev = [] for variable in config[CONF_MONITORED_VARIABLES]: dev.append(TransmissionSensor(variable, transmission_api, name)) - add_devices(dev) + add_devices(dev, True) class TransmissionSensor(Entity): """Representation of a Transmission sensor.""" - def __init__(self, sensor_type, transmission_client, client_name): + def __init__(self, sensor_type, transmission_api, client_name): """Initialize the sensor.""" self._name = SENSOR_TYPES[sensor_type][0] - self.tm_client = transmission_client - self.type = sensor_type - self.client_name = client_name self._state = None + self._transmission_api = transmission_api self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._data = None + self.client_name = client_name + self.type = sensor_type @property def name(self): @@ -105,25 +106,20 @@ def unit_of_measurement(self): """Return the unit of measurement of this entity, if any.""" return self._unit_of_measurement - # pylint: disable=no-self-use - def refresh_transmission_data(self): - """Call the throttled Transmission refresh method.""" - from transmissionrpc.error import TransmissionError - - if _THROTTLED_REFRESH is not None: - try: - _THROTTLED_REFRESH() - except TransmissionError: - _LOGGER.error("Connection to Transmission API failed") + @property + def available(self): + """Could the device be accessed during the last update call.""" + return self._transmission_api.available def update(self): """Get the latest data from Transmission and updates the state.""" - self.refresh_transmission_data() + self._transmission_api.update() + self._data = self._transmission_api.data if self.type == 'current_status': - if self.tm_client.session: - upload = self.tm_client.session.uploadSpeed - download = self.tm_client.session.downloadSpeed + if self._data: + upload = self._data.uploadSpeed + download = self._data.downloadSpeed if upload > 0 and download > 0: self._state = 'Up/Down' elif upload > 0 and download == 0: @@ -135,14 +131,40 @@ def update(self): else: self._state = None - if self.tm_client.session: + if self._data: if self.type == 'download_speed': - mb_spd = float(self.tm_client.session.downloadSpeed) + mb_spd = float(self._data.downloadSpeed) mb_spd = mb_spd / 1024 / 1024 self._state = round(mb_spd, 2 if mb_spd < 0.1 else 1) elif self.type == 'upload_speed': - mb_spd = float(self.tm_client.session.uploadSpeed) + mb_spd = float(self._data.uploadSpeed) mb_spd = mb_spd / 1024 / 1024 self._state = round(mb_spd, 2 if mb_spd < 0.1 else 1) elif self.type == 'active_torrents': - self._state = self.tm_client.session.activeTorrentCount + self._state = self._data.activeTorrentCount + elif self.type == 'paused_torrents': + self._state = self._data.pausedTorrentCount + elif self.type == 'total_torrents': + self._state = self._data.torrentCount + + +class TransmissionData: + """Get the latest data and update the states.""" + + def __init__(self, api): + """Initialize the Transmission data object.""" + self.data = None + self.available = True + self._api = api + + @Throttle(SCAN_INTERVAL) + def update(self): + """Get the latest data from Transmission instance.""" + from transmissionrpc.error import TransmissionError + + try: + self.data = self._api.session_stats() + self.available = True + except TransmissionError: + self.available = False + _LOGGER.error("Unable to connect to Transmission client") diff --git a/homeassistant/components/sensor/twitch.py b/homeassistant/components/sensor/twitch.py index b3e227aea72247..250911b49b1096 100644 --- a/homeassistant/components/sensor/twitch.py +++ b/homeassistant/components/sensor/twitch.py @@ -31,7 +31,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Twitch platform.""" channels = config.get(CONF_CHANNELS, []) diff --git a/homeassistant/components/sensor/uber.py b/homeassistant/components/sensor/uber.py index e80fe7d2d8255a..cd476a1a2265ee 100644 --- a/homeassistant/components/sensor/uber.py +++ b/homeassistant/components/sensor/uber.py @@ -175,7 +175,7 @@ def update(self): self._state = 0 -class UberEstimate(object): +class UberEstimate: """The class for handling the time and price estimate.""" def __init__(self, session, start_latitude, start_longitude, diff --git a/homeassistant/components/sensor/ups.py b/homeassistant/components/sensor/ups.py index c51ae67475fe70..a864df384ad011 100644 --- a/homeassistant/components/sensor/ups.py +++ b/homeassistant/components/sensor/ups.py @@ -38,7 +38,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the UPS platform.""" import upsmychoice diff --git a/homeassistant/components/sensor/uptime.py b/homeassistant/components/sensor/uptime.py index 91746af71f11b7..7e893899815176 100644 --- a/homeassistant/components/sensor/uptime.py +++ b/homeassistant/components/sensor/uptime.py @@ -1,25 +1,25 @@ """ -Component to retrieve uptime for Home Assistant. +Platform to retrieve uptime for Home Assistant. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.uptime/ """ -import asyncio import logging import voluptuous as vol -import homeassistant.helpers.config_validation as cv -import homeassistant.util.dt as dt_util from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ( - CONF_NAME, CONF_UNIT_OF_MEASUREMENT) +from homeassistant.const import CONF_NAME, CONF_UNIT_OF_MEASUREMENT +import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity +import homeassistant.util.dt as dt_util _LOGGER = logging.getLogger(__name__) DEFAULT_NAME = 'Uptime' +ICON = 'mdi:clock' + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_UNIT_OF_MEASUREMENT, default='days'): @@ -27,22 +27,22 @@ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): """Set up the uptime sensor platform.""" name = config.get(CONF_NAME) units = config.get(CONF_UNIT_OF_MEASUREMENT) + async_add_devices([UptimeSensor(name, units)], True) class UptimeSensor(Entity): """Representation of an uptime sensor.""" - def __init__(self, name, units): + def __init__(self, name, unit): """Initialize the uptime sensor.""" self._name = name - self._icon = 'mdi:clock' - self._units = units + self._unit = unit self.initial = dt_util.now() self._state = None @@ -54,27 +54,28 @@ def name(self): @property def icon(self): """Icon to display in the front end.""" - return self._icon + return ICON @property def unit_of_measurement(self): """Return the unit of measurement the value is expressed in.""" - return self._units + return self._unit @property def state(self): """Return the state of the sensor.""" return self._state - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Update the state of the sensor.""" delta = dt_util.now() - self.initial div_factor = 3600 + if self.unit_of_measurement == 'days': div_factor *= 24 elif self.unit_of_measurement == 'minutes': div_factor /= 60 + delta = delta.total_seconds() / div_factor self._state = round(delta, 2) _LOGGER.debug("New value: %s", delta) diff --git a/homeassistant/components/sensor/vera.py b/homeassistant/components/sensor/vera.py index eb8ccae768e204..eaef3dcf7f7379 100644 --- a/homeassistant/components/sensor/vera.py +++ b/homeassistant/components/sensor/vera.py @@ -25,8 +25,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Vera controller devices.""" add_devices( - VeraSensor(device, hass.data[VERA_CONTROLLER]) - for device in hass.data[VERA_DEVICES]['sensor']) + [VeraSensor(device, hass.data[VERA_CONTROLLER]) + for device in hass.data[VERA_DEVICES]['sensor']], True) class VeraSensor(VeraDevice, Entity): @@ -51,13 +51,13 @@ def unit_of_measurement(self): import pyvera as veraApi if self.vera_device.category == veraApi.CATEGORY_TEMPERATURE_SENSOR: return self._temperature_units - elif self.vera_device.category == veraApi.CATEGORY_LIGHT_SENSOR: + if self.vera_device.category == veraApi.CATEGORY_LIGHT_SENSOR: return 'lx' - elif self.vera_device.category == veraApi.CATEGORY_UV_SENSOR: + if self.vera_device.category == veraApi.CATEGORY_UV_SENSOR: return 'level' - elif self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR: + if self.vera_device.category == veraApi.CATEGORY_HUMIDITY_SENSOR: return '%' - elif self.vera_device.category == veraApi.CATEGORY_POWER_METER: + if self.vera_device.category == veraApi.CATEGORY_POWER_METER: return 'watts' def update(self): diff --git a/homeassistant/components/sensor/verisure.py b/homeassistant/components/sensor/verisure.py index 5ab999ccabfea7..187a9bd7935c2e 100644 --- a/homeassistant/components/sensor/verisure.py +++ b/homeassistant/components/sensor/verisure.py @@ -74,6 +74,7 @@ def unit_of_measurement(self): """Return the unit of measurement of this entity.""" return TEMP_CELSIUS + # pylint: disable=no-self-use def update(self): """Update the sensor.""" hub.update_overview() @@ -112,6 +113,7 @@ def unit_of_measurement(self): """Return the unit of measurement of this entity.""" return '%' + # pylint: disable=no-self-use def update(self): """Update the sensor.""" hub.update_overview() @@ -150,6 +152,7 @@ def unit_of_measurement(self): """Return the unit of measurement of this entity.""" return 'Mice' + # pylint: disable=no-self-use def update(self): """Update the sensor.""" hub.update_overview() diff --git a/homeassistant/components/sensor/version.py b/homeassistant/components/sensor/version.py index c19d2743563499..db61d05978393f 100644 --- a/homeassistant/components/sensor/version.py +++ b/homeassistant/components/sensor/version.py @@ -4,7 +4,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.version/ """ -import asyncio import logging import voluptuous as vol @@ -23,8 +22,8 @@ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): """Set up the Version sensor platform.""" name = config.get(CONF_NAME) diff --git a/homeassistant/components/sensor/volvooncall.py b/homeassistant/components/sensor/volvooncall.py index 343bcdf20333f4..78e8a7e76c63ad 100644 --- a/homeassistant/components/sensor/volvooncall.py +++ b/homeassistant/components/sensor/volvooncall.py @@ -43,7 +43,7 @@ def state(self): if 'mil' in self.unit_of_measurement: return round(val, 2) return round(val, 1) - elif self._attribute == 'distance_to_empty': + if self._attribute == 'distance_to_empty': return int(floor(val)) return int(round(val)) diff --git a/homeassistant/components/sensor/waterfurnace.py b/homeassistant/components/sensor/waterfurnace.py index 24c45ec1ff3ef2..76c5d2f648ed60 100644 --- a/homeassistant/components/sensor/waterfurnace.py +++ b/homeassistant/components/sensor/waterfurnace.py @@ -16,7 +16,7 @@ from homeassistant.util import slugify -class WFSensorConfig(object): +class WFSensorConfig: """Water Furnace Sensor configuration.""" def __init__(self, friendly_name, field, icon="mdi:gauge", diff --git a/homeassistant/components/sensor/waze_travel_time.py b/homeassistant/components/sensor/waze_travel_time.py index 47589f33530b01..023da72299b901 100644 --- a/homeassistant/components/sensor/waze_travel_time.py +++ b/homeassistant/components/sensor/waze_travel_time.py @@ -7,27 +7,34 @@ from datetime import timedelta import logging -import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME, CONF_REGION +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_NAME, CONF_REGION, EVENT_HOMEASSISTANT_START, + ATTR_LATITUDE, ATTR_LONGITUDE) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers import location from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['WazeRouteCalculator==0.5'] +REQUIREMENTS = ['WazeRouteCalculator==0.6'] _LOGGER = logging.getLogger(__name__) +ATTR_DURATION = 'duration' ATTR_DISTANCE = 'distance' ATTR_ROUTE = 'route' CONF_ATTRIBUTION = "Data provided by the Waze.com" CONF_DESTINATION = 'destination' CONF_ORIGIN = 'origin' +CONF_INCL_FILTER = 'incl_filter' +CONF_EXCL_FILTER = 'excl_filter' +CONF_REALTIME = 'realtime' DEFAULT_NAME = 'Waze Travel Time' +DEFAULT_REALTIME = True ICON = 'mdi:car' @@ -35,11 +42,16 @@ SCAN_INTERVAL = timedelta(minutes=5) +TRACKABLE_DOMAINS = ['device_tracker', 'sensor', 'zone'] + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ORIGIN): cv.string, vol.Required(CONF_DESTINATION): cv.string, vol.Required(CONF_REGION): vol.In(REGIONS), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_INCL_FILTER): cv.string, + vol.Optional(CONF_EXCL_FILTER): cv.string, + vol.Optional(CONF_REALTIME, default=DEFAULT_REALTIME): cv.boolean, }) @@ -49,24 +61,49 @@ def setup_platform(hass, config, add_devices, discovery_info=None): name = config.get(CONF_NAME) origin = config.get(CONF_ORIGIN) region = config.get(CONF_REGION) + incl_filter = config.get(CONF_INCL_FILTER) + excl_filter = config.get(CONF_EXCL_FILTER) + realtime = config.get(CONF_REALTIME) + + sensor = WazeTravelTime(name, origin, destination, region, + incl_filter, excl_filter, realtime) + + add_devices([sensor]) - try: - waze_data = WazeRouteData(origin, destination, region) - except requests.exceptions.HTTPError as error: - _LOGGER.error("%s", error) - return + # Wait until start event is sent to load this component. + hass.bus.listen_once(EVENT_HOMEASSISTANT_START, sensor.update) - add_devices([WazeTravelTime(waze_data, name)], True) + +def _get_location_from_attributes(state): + """Get the lat/long string from an states attributes.""" + attr = state.attributes + return '{},{}'.format(attr.get(ATTR_LATITUDE), attr.get(ATTR_LONGITUDE)) class WazeTravelTime(Entity): """Representation of a Waze travel time sensor.""" - def __init__(self, waze_data, name): + def __init__(self, name, origin, destination, region, + incl_filter, excl_filter, realtime): """Initialize the Waze travel time sensor.""" self._name = name + self._region = region + self._incl_filter = incl_filter + self._excl_filter = excl_filter + self._realtime = realtime self._state = None - self.waze_data = waze_data + self._origin_entity_id = None + self._destination_entity_id = None + + if origin.split('.', 1)[0] in TRACKABLE_DOMAINS: + self._origin_entity_id = origin + else: + self._origin = origin + + if destination.split('.', 1)[0] in TRACKABLE_DOMAINS: + self._destination_entity_id = destination + else: + self._destination = destination @property def name(self): @@ -76,7 +113,12 @@ def name(self): @property def state(self): """Return the state of the sensor.""" - return round(self._state['duration']) + if self._state is None: + return None + + if 'duration' in self._state: + return round(self._state['duration']) + return None @property def unit_of_measurement(self): @@ -91,46 +133,96 @@ def icon(self): @property def device_state_attributes(self): """Return the state attributes of the last update.""" - return { - ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - ATTR_DISTANCE: round(self._state['distance']), - ATTR_ROUTE: self._state['route'], - } - - def update(self): - """Fetch new state data for the sensor.""" - try: - self.waze_data.update() - self._state = self.waze_data.data - except KeyError: - _LOGGER.error("Error retrieving data from server") - - -class WazeRouteData(object): - """Get data from Waze.""" - - def __init__(self, origin, destination, region): - """Initialize the data object.""" - self._destination = destination - self._origin = origin - self._region = region - self.data = {} + if self._state is None: + return None + + res = {ATTR_ATTRIBUTION: CONF_ATTRIBUTION} + if 'duration' in self._state: + res[ATTR_DURATION] = self._state['duration'] + if 'distance' in self._state: + res[ATTR_DISTANCE] = self._state['distance'] + if 'route' in self._state: + res[ATTR_ROUTE] = self._state['route'] + return res + + def _get_location_from_entity(self, entity_id): + """Get the location from the entity_id.""" + state = self.hass.states.get(entity_id) + + if state is None: + _LOGGER.error("Unable to find entity %s", entity_id) + return None + + # Check if the entity has location attributes (zone) + if location.has_location(state): + return _get_location_from_attributes(state) + + # Check if device is in a zone (device_tracker) + zone_state = self.hass.states.get('zone.{}'.format(state.state)) + if location.has_location(zone_state): + _LOGGER.debug( + "%s is in %s, getting zone location", + entity_id, zone_state.entity_id + ) + return _get_location_from_attributes(zone_state) + + # If zone was not found in state then use the state as the location + if entity_id.startswith('sensor.'): + return state.state + + # When everything fails just return nothing + return None + + def _resolve_zone(self, friendly_name): + """Get a lat/long from a zones friendly_name.""" + states = self.hass.states.all() + for state in states: + if state.domain == 'zone' and state.name == friendly_name: + return _get_location_from_attributes(state) + + return friendly_name @Throttle(SCAN_INTERVAL) def update(self): - """Fetch latest data from Waze.""" + """Fetch new state data for the sensor.""" import WazeRouteCalculator - _LOGGER.debug("Update in progress...") - try: - params = WazeRouteCalculator.WazeRouteCalculator( - self._origin, self._destination, self._region, None) - results = params.calc_all_routes_info() - best_route = next(iter(results)) - (duration, distance) = results[best_route] - best_route_str = bytes(best_route, 'ISO-8859-1').decode('UTF-8') - self.data['duration'] = duration - self.data['distance'] = distance - self.data['route'] = best_route_str - except WazeRouteCalculator.WRCError as exp: - _LOGGER.error("Error on retrieving data: %s", exp) - return + + if self._origin_entity_id is not None: + self._origin = self._get_location_from_entity( + self._origin_entity_id) + + if self._destination_entity_id is not None: + self._destination = self._get_location_from_entity( + self._destination_entity_id) + + self._destination = self._resolve_zone(self._destination) + self._origin = self._resolve_zone(self._origin) + + if self._destination is not None and self._origin is not None: + try: + params = WazeRouteCalculator.WazeRouteCalculator( + self._origin, self._destination, self._region) + routes = params.calc_all_routes_info(real_time=self._realtime) + + if self._incl_filter is not None: + routes = {k: v for k, v in routes.items() if + self._incl_filter.lower() in k.lower()} + + if self._excl_filter is not None: + routes = {k: v for k, v in routes.items() if + self._excl_filter.lower() not in k.lower()} + + route = sorted(routes, key=(lambda key: routes[key][0]))[0] + duration, distance = routes[route] + route = bytes(route, 'ISO-8859-1').decode('UTF-8') + self._state = { + 'duration': duration, + 'distance': distance, + 'route': route, + } + except WazeRouteCalculator.WRCError as exp: + _LOGGER.error("Error on retrieving data: %s", exp) + return + except KeyError: + _LOGGER.error("Error retrieving data from server") + return diff --git a/homeassistant/components/sensor/wirelesstag.py b/homeassistant/components/sensor/wirelesstag.py new file mode 100644 index 00000000000000..ad2115e9bd30c5 --- /dev/null +++ b/homeassistant/components/sensor/wirelesstag.py @@ -0,0 +1,176 @@ +""" +Sensor support for Wirelss Sensor Tags platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.wirelesstag/ +""" + +import logging +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.const import ( + CONF_MONITORED_CONDITIONS) +from homeassistant.components.wirelesstag import ( + DOMAIN as WIRELESSTAG_DOMAIN, + WIRELESSTAG_TYPE_13BIT, WIRELESSTAG_TYPE_WATER, + WIRELESSTAG_TYPE_ALSPRO, + WIRELESSTAG_TYPE_WEMO_DEVICE, + SIGNAL_TAG_UPDATE, + WirelessTagBaseSensor) +import homeassistant.helpers.config_validation as cv +from homeassistant.const import TEMP_CELSIUS + +DEPENDENCIES = ['wirelesstag'] + +_LOGGER = logging.getLogger(__name__) + +SENSOR_TEMPERATURE = 'temperature' +SENSOR_HUMIDITY = 'humidity' +SENSOR_MOISTURE = 'moisture' +SENSOR_LIGHT = 'light' + +SENSOR_TYPES = { + SENSOR_TEMPERATURE: { + 'unit': TEMP_CELSIUS, + 'attr': 'temperature' + }, + SENSOR_HUMIDITY: { + 'unit': '%', + 'attr': 'humidity' + }, + SENSOR_MOISTURE: { + 'unit': '%', + 'attr': 'moisture' + }, + SENSOR_LIGHT: { + 'unit': 'lux', + 'attr': 'light' + } +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_MONITORED_CONDITIONS, default=[]): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Setup the sensor platform.""" + platform = hass.data.get(WIRELESSTAG_DOMAIN) + sensors = [] + tags = platform.tags + for tag in tags.values(): + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + if sensor_type in WirelessTagSensor.allowed_sensors(tag): + sensors.append(WirelessTagSensor( + platform, tag, sensor_type, hass.config)) + + add_devices(sensors, True) + + +class WirelessTagSensor(WirelessTagBaseSensor): + """Representation of a Sensor.""" + + @classmethod + def allowed_sensors(cls, tag): + """Return array of allowed sensor types for tag.""" + all_sensors = SENSOR_TYPES.keys() + sensors_per_tag_type = { + WIRELESSTAG_TYPE_13BIT: [ + SENSOR_TEMPERATURE, + SENSOR_HUMIDITY], + WIRELESSTAG_TYPE_WATER: [ + SENSOR_TEMPERATURE, + SENSOR_MOISTURE], + WIRELESSTAG_TYPE_ALSPRO: [ + SENSOR_TEMPERATURE, + SENSOR_HUMIDITY, + SENSOR_LIGHT], + WIRELESSTAG_TYPE_WEMO_DEVICE: [] + } + + tag_type = tag.tag_type + return ( + sensors_per_tag_type[tag_type] if tag_type in sensors_per_tag_type + else all_sensors) + + def __init__(self, api, tag, sensor_type, config): + """Constructor with platform(api), tag and hass sensor type.""" + super().__init__(api, tag) + + self._sensor_type = sensor_type + self._tag_attr = SENSOR_TYPES[self._sensor_type]['attr'] + self._unit_of_measurement = SENSOR_TYPES[self._sensor_type]['unit'] + self._name = self._tag.name + + # I want to see entity_id as: + # sensor.wirelesstag_bedroom_temperature + # and not as sensor.bedroom for temperature and + # sensor.bedroom_2 for humidity + self._entity_id = '{}.{}_{}_{}'.format('sensor', WIRELESSTAG_DOMAIN, + self.underscored_name, + self._sensor_type) + + async def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, + SIGNAL_TAG_UPDATE.format(self.tag_id), + self._update_tag_info_callback) + + @property + def entity_id(self): + """Overriden version.""" + return self._entity_id + + @property + def underscored_name(self): + """Provide name savvy to be used in entity_id name of self.""" + return self.name.lower().replace(" ", "_") + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def device_class(self): + """Return the class of the sensor.""" + return self._sensor_type + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._unit_of_measurement + + @property + def principal_value(self): + """Return sensor current value.""" + return getattr(self._tag, self._tag_attr, False) + + @callback + def _update_tag_info_callback(self, event): + """Handle push notification sent by tag manager.""" + if event.data.get('id') != self.tag_id: + return + + _LOGGER.info("Entity to update state: %s event data: %s", + self, event.data) + new_value = self.principal_value + try: + if self._sensor_type == SENSOR_TEMPERATURE: + new_value = event.data.get('temp') + elif (self._sensor_type == SENSOR_HUMIDITY or + self._sensor_type == SENSOR_MOISTURE): + new_value = event.data.get('cap') + elif self._sensor_type == SENSOR_LIGHT: + new_value = event.data.get('lux') + except Exception as error: # pylint: disable=broad-except + _LOGGER.info("Unable to update value of entity: \ + %s error: %s event: %s", self, error, event) + + self._state = self.decorate_value(new_value) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/sensor/worldclock.py b/homeassistant/components/sensor/worldclock.py index 839b5776b3c352..1240480d4a3b4c 100644 --- a/homeassistant/components/sensor/worldclock.py +++ b/homeassistant/components/sensor/worldclock.py @@ -4,7 +4,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.worldclock/ """ -import asyncio import logging import voluptuous as vol @@ -29,8 +28,8 @@ }) -@asyncio.coroutine -def async_setup_platform(hass, config, async_add_devices, discovery_info=None): +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): """Set up the World clock sensor.""" name = config.get(CONF_NAME) time_zone = dt_util.get_time_zone(config.get(CONF_TIME_ZONE)) @@ -62,8 +61,7 @@ def icon(self): """Icon to use in the frontend, if any.""" return ICON - @asyncio.coroutine - def async_update(self): + async def async_update(self): """Get the time and updates the states.""" self._state = dt_util.now(time_zone=self._time_zone).strftime( TIME_STR_FORMAT) diff --git a/homeassistant/components/sensor/worldtidesinfo.py b/homeassistant/components/sensor/worldtidesinfo.py index 8884d790eed036..597a971e208f3b 100644 --- a/homeassistant/components/sensor/worldtidesinfo.py +++ b/homeassistant/components/sensor/worldtidesinfo.py @@ -31,7 +31,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the WorldTidesInfo sensor.""" name = config.get(CONF_NAME) @@ -86,7 +85,7 @@ def state(self): tidetime = time.strftime('%I:%M %p', time.localtime( self.data['extremes'][0]['dt'])) return "High tide at %s" % (tidetime) - elif "Low" in str(self.data['extremes'][0]['type']): + if "Low" in str(self.data['extremes'][0]['type']): tidetime = time.strftime('%I:%M %p', time.localtime( self.data['extremes'][0]['dt'])) return "Low tide at %s" % (tidetime) diff --git a/homeassistant/components/sensor/worxlandroid.py b/homeassistant/components/sensor/worxlandroid.py index ddf506bf4eb7ea..8963bb135e029f 100644 --- a/homeassistant/components/sensor/worxlandroid.py +++ b/homeassistant/components/sensor/worxlandroid.py @@ -28,7 +28,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, vol.Required(CONF_PIN): - vol.All(vol.Coerce(int), vol.Range(min=1000, max=9999)), + vol.All(vol.Coerce(str), vol.Match(r'\d{4}')), vol.Optional(CONF_ALLOW_UNREACHABLE, default=True): cv.boolean, vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int, }) @@ -152,11 +152,11 @@ def get_state(self, obj): if state_obj[14] == 1: return 'manual-stop' - elif state_obj[5] == 1 and state_obj[13] == 0: + if state_obj[5] == 1 and state_obj[13] == 0: return 'charging' - elif state_obj[5] == 1 and state_obj[13] == 1: + if state_obj[5] == 1 and state_obj[13] == 1: return 'charging-complete' - elif state_obj[15] == 1: + if state_obj[15] == 1: return 'going-home' return 'mowing' diff --git a/homeassistant/components/sensor/wsdot.py b/homeassistant/components/sensor/wsdot.py index fecff2607162c0..0cd5ba44349825 100644 --- a/homeassistant/components/sensor/wsdot.py +++ b/homeassistant/components/sensor/wsdot.py @@ -13,24 +13,27 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_API_KEY, CONF_NAME, ATTR_ATTRIBUTION, CONF_ID - ) + CONF_API_KEY, CONF_NAME, ATTR_ATTRIBUTION, CONF_ID, ATTR_NAME) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) -CONF_TRAVEL_TIMES = 'travel_time' - -# API codes for travel time details ATTR_ACCESS_CODE = 'AccessCode' -ATTR_TRAVEL_TIME_ID = 'TravelTimeID' -ATTR_CURRENT_TIME = 'CurrentTime' ATTR_AVG_TIME = 'AverageTime' -ATTR_NAME = 'Name' -ATTR_TIME_UPDATED = 'TimeUpdated' +ATTR_CURRENT_TIME = 'CurrentTime' ATTR_DESCRIPTION = 'Description' -ATTRIBUTION = "Data provided by WSDOT" +ATTR_TIME_UPDATED = 'TimeUpdated' +ATTR_TRAVEL_TIME_ID = 'TravelTimeID' + +CONF_ATTRIBUTION = "Data provided by WSDOT" + +CONF_TRAVEL_TIMES = 'travel_time' + +ICON = 'mdi:car' + +RESOURCE = 'http://www.wsdot.wa.gov/Traffic/api/TravelTimes/' \ + 'TravelTimesREST.svc/GetTravelTimeAsJson' SCAN_INTERVAL = timedelta(minutes=3) @@ -43,16 +46,14 @@ def setup_platform(hass, config, add_devices, discovery_info=None): - """Get the WSDOT sensor.""" + """Set up the WSDOT sensor.""" sensors = [] for travel_time in config.get(CONF_TRAVEL_TIMES): - name = (travel_time.get(CONF_NAME) or - travel_time.get(CONF_ID)) + name = (travel_time.get(CONF_NAME) or travel_time.get(CONF_ID)) sensors.append( WashingtonStateTravelTimeSensor( - name, - config.get(CONF_API_KEY), - travel_time.get(CONF_ID))) + name, config.get(CONF_API_KEY), travel_time.get(CONF_ID))) + add_devices(sensors, True) @@ -65,8 +66,6 @@ class WashingtonStateTransportSensor(Entity): can read them and make them available. """ - ICON = 'mdi:car' - def __init__(self, name, access_code): """Initialize the sensor.""" self._data = {} @@ -87,16 +86,12 @@ def state(self): @property def icon(self): """Icon to use in the frontend, if any.""" - return self.ICON + return ICON class WashingtonStateTravelTimeSensor(WashingtonStateTransportSensor): """Travel time sensor from WSDOT.""" - RESOURCE = ('http://www.wsdot.wa.gov/Traffic/api/TravelTimes/' - 'TravelTimesREST.svc/GetTravelTimeAsJson') - ICON = 'mdi:car' - def __init__(self, name, access_code, travel_time_id): """Construct a travel time sensor.""" self._travel_time_id = travel_time_id @@ -104,10 +99,12 @@ def __init__(self, name, access_code, travel_time_id): def update(self): """Get the latest data from WSDOT.""" - params = {ATTR_ACCESS_CODE: self._access_code, - ATTR_TRAVEL_TIME_ID: self._travel_time_id} + params = { + ATTR_ACCESS_CODE: self._access_code, + ATTR_TRAVEL_TIME_ID: self._travel_time_id, + } - response = requests.get(self.RESOURCE, params, timeout=10) + response = requests.get(RESOURCE, params, timeout=10) if response.status_code != 200: _LOGGER.warning("Invalid response from WSDOT API") else: @@ -118,7 +115,7 @@ def update(self): def device_state_attributes(self): """Return other details about the sensor state.""" if self._data is not None: - attrs = {ATTR_ATTRIBUTION: ATTRIBUTION} + attrs = {ATTR_ATTRIBUTION: CONF_ATTRIBUTION} for key in [ATTR_AVG_TIME, ATTR_NAME, ATTR_DESCRIPTION, ATTR_TRAVEL_TIME_ID]: attrs[key] = self._data.get(key) @@ -129,7 +126,7 @@ def device_state_attributes(self): @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return "min" + return 'min' def _parse_wsdot_timestamp(timestamp): @@ -139,5 +136,5 @@ def _parse_wsdot_timestamp(timestamp): # ex: Date(1485040200000-0800) milliseconds, tzone = re.search( r'Date\((\d+)([+-]\d\d)\d\d\)', timestamp).groups() - return datetime.fromtimestamp(int(milliseconds) / 1000, - tz=timezone(timedelta(hours=int(tzone)))) + return datetime.fromtimestamp( + int(milliseconds) / 1000, tz=timezone(timedelta(hours=int(tzone)))) diff --git a/homeassistant/components/sensor/wunderground.py b/homeassistant/components/sensor/wunderground.py index 7f2df4bcda925a..24ae2d0068ffb3 100644 --- a/homeassistant/components/sensor/wunderground.py +++ b/homeassistant/components/sensor/wunderground.py @@ -40,7 +40,7 @@ # Helper classes for declaring sensor configurations -class WUSensorConfig(object): +class WUSensorConfig: """WU Sensor Configuration. defines basic HA properties of the weather sensor and @@ -764,7 +764,7 @@ def unique_id(self) -> str: return self._unique_id -class WUndergroundData(object): +class WUndergroundData: """Get data from WUnderground.""" def __init__(self, hass, api_key, pws_id, lang, latitude, longitude): diff --git a/homeassistant/components/sensor/xbox_live.py b/homeassistant/components/sensor/xbox_live.py index 0c7b8b48f624ce..250c74ee4933c4 100644 --- a/homeassistant/components/sensor/xbox_live.py +++ b/homeassistant/components/sensor/xbox_live.py @@ -27,7 +27,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Xbox platform.""" from xboxapi import xbox_api diff --git a/homeassistant/components/sensor/xiaomi_aqara.py b/homeassistant/components/sensor/xiaomi_aqara.py index 3192d0d2f60f6b..32139b21976480 100644 --- a/homeassistant/components/sensor/xiaomi_aqara.py +++ b/homeassistant/components/sensor/xiaomi_aqara.py @@ -91,9 +91,9 @@ def parse_data(self, data, raw_data): value = max(value - 300, 0) if self._data_key == 'temperature' and (value < -50 or value > 60): return False - elif self._data_key == 'humidity' and (value <= 0 or value > 100): + if self._data_key == 'humidity' and (value <= 0 or value > 100): return False - elif self._data_key == 'pressure' and value == 0: + if self._data_key == 'pressure' and value == 0: return False self._state = round(value, 1) return True diff --git a/homeassistant/components/sensor/xiaomi_miio.py b/homeassistant/components/sensor/xiaomi_miio.py index 066dc384007f5b..63d93d31cf37a4 100644 --- a/homeassistant/components/sensor/xiaomi_miio.py +++ b/homeassistant/components/sensor/xiaomi_miio.py @@ -25,18 +25,21 @@ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) -REQUIREMENTS = ['python-miio==0.3.9', 'construct==2.9.41'] +REQUIREMENTS = ['python-miio==0.4.0', 'construct==2.9.41'] ATTR_POWER = 'power' ATTR_CHARGING = 'charging' ATTR_BATTERY_LEVEL = 'battery_level' -ATTR_TIME_STATE = 'time_state' +ATTR_DISPLAY_CLOCK = 'display_clock' +ATTR_NIGHT_MODE = 'night_mode' +ATTR_NIGHT_TIME_BEGIN = 'night_time_begin' +ATTR_NIGHT_TIME_END = 'night_time_end' +ATTR_SENSOR_STATE = 'sensor_state' ATTR_MODEL = 'model' SUCCESS = ['ok'] -# pylint: disable=unused-argument async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the sensor from config.""" @@ -86,7 +89,11 @@ def __init__(self, name, device, model, unique_id): ATTR_POWER: None, ATTR_BATTERY_LEVEL: None, ATTR_CHARGING: None, - ATTR_TIME_STATE: None, + ATTR_DISPLAY_CLOCK: None, + ATTR_NIGHT_MODE: None, + ATTR_NIGHT_TIME_BEGIN: None, + ATTR_NIGHT_TIME_END: None, + ATTR_SENSOR_STATE: None, ATTR_MODEL: self._model, } @@ -144,7 +151,11 @@ async def async_update(self): ATTR_POWER: state.power, ATTR_CHARGING: state.usb_power, ATTR_BATTERY_LEVEL: state.battery, - ATTR_TIME_STATE: state.time_state, + ATTR_DISPLAY_CLOCK: state.display_clock, + ATTR_NIGHT_MODE: state.night_mode, + ATTR_NIGHT_TIME_BEGIN: state.night_time_begin, + ATTR_NIGHT_TIME_END: state.night_time_end, + ATTR_SENSOR_STATE: state.sensor_state, }) except DeviceException as ex: diff --git a/homeassistant/components/sensor/yahoo_finance.py b/homeassistant/components/sensor/yahoo_finance.py index 8c2cfd9923fd0d..82cb7f845dc29a 100644 --- a/homeassistant/components/sensor/yahoo_finance.py +++ b/homeassistant/components/sensor/yahoo_finance.py @@ -104,7 +104,7 @@ def update(self): self._state = self.data.state -class YahooFinanceData(object): +class YahooFinanceData: """Get data from Yahoo Finance.""" def __init__(self, symbol): diff --git a/homeassistant/components/sensor/yr.py b/homeassistant/components/sensor/yr.py index 88c23771bd4c39..fcddf41af970f7 100644 --- a/homeassistant/components/sensor/yr.py +++ b/homeassistant/components/sensor/yr.py @@ -117,7 +117,7 @@ def state(self): return self._state @property - def should_poll(self): # pylint: disable=no-self-use + def should_poll(self): """No polling needed.""" return False @@ -142,7 +142,7 @@ def unit_of_measurement(self): return self._unit_of_measurement -class YrData(object): +class YrData: """Get the latest data and updates the states.""" def __init__(self, hass, coordinates, forecast, devices): diff --git a/homeassistant/components/sensor/yweather.py b/homeassistant/components/sensor/yweather.py index db66419e54ab1d..b2279e107da11b 100644 --- a/homeassistant/components/sensor/yweather.py +++ b/homeassistant/components/sensor/yweather.py @@ -174,7 +174,7 @@ def update(self): float(self._data.yahoo.Atmosphere['visibility'])/1.61, 2) -class YahooWeatherData(object): +class YahooWeatherData: """Handle Yahoo! API object and limit updates.""" def __init__(self, woeid, temp_unit): diff --git a/homeassistant/components/sensor/zabbix.py b/homeassistant/components/sensor/zabbix.py index baeed3915577b2..21a3030b79b44f 100644 --- a/homeassistant/components/sensor/zabbix.py +++ b/homeassistant/components/sensor/zabbix.py @@ -8,7 +8,7 @@ import voluptuous as vol -import homeassistant.components.zabbix as zabbix +from homeassistant.components import zabbix import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import CONF_NAME diff --git a/homeassistant/components/sensor/zamg.py b/homeassistant/components/sensor/zamg.py index df5ff5e8d372f0..e8e5fdec4d8ef4 100644 --- a/homeassistant/components/sensor/zamg.py +++ b/homeassistant/components/sensor/zamg.py @@ -133,7 +133,7 @@ def update(self): self.probe.update() -class ZamgData(object): +class ZamgData: """The class for handling the data retrieval.""" API_URL = 'http://www.zamg.ac.at/ogd/' diff --git a/homeassistant/components/sensor/zha.py b/homeassistant/components/sensor/zha.py index d856ed1a17ef58..53e0e8d0329a52 100644 --- a/homeassistant/components/sensor/zha.py +++ b/homeassistant/components/sensor/zha.py @@ -25,20 +25,32 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): return sensor = yield from make_sensor(discovery_info) - async_add_devices([sensor]) + async_add_devices([sensor], update_before_add=True) @asyncio.coroutine def make_sensor(discovery_info): """Create ZHA sensors factory.""" from zigpy.zcl.clusters.measurement import ( - RelativeHumidity, TemperatureMeasurement + RelativeHumidity, TemperatureMeasurement, PressureMeasurement, + IlluminanceMeasurement ) + from zigpy.zcl.clusters.smartenergy import Metering + from zigpy.zcl.clusters.homeautomation import ElectricalMeasurement in_clusters = discovery_info['in_clusters'] if RelativeHumidity.cluster_id in in_clusters: sensor = RelativeHumiditySensor(**discovery_info) elif TemperatureMeasurement.cluster_id in in_clusters: sensor = TemperatureSensor(**discovery_info) + elif PressureMeasurement.cluster_id in in_clusters: + sensor = PressureSensor(**discovery_info) + elif IlluminanceMeasurement.cluster_id in in_clusters: + sensor = IlluminanceMeasurementSensor(**discovery_info) + elif Metering.cluster_id in in_clusters: + sensor = MeteringSensor(**discovery_info) + elif ElectricalMeasurement.cluster_id in in_clusters: + sensor = ElectricalMeasurementSensor(**discovery_info) + return sensor else: sensor = Sensor(**discovery_info) @@ -59,6 +71,11 @@ class Sensor(zha.Entity): value_attribute = 0 min_reportable_change = 1 + @property + def should_poll(self) -> bool: + """State gets pushed from device.""" + return False + @property def state(self) -> str: """Return the state of the entity.""" @@ -73,6 +90,14 @@ def attribute_updated(self, attribute, value): self._state = value self.async_schedule_update_ha_state() + async def async_update(self): + """Retrieve latest state.""" + result = await zha.safe_read( + list(self._in_clusters.values())[0], + [self.value_attribute] + ) + self._state = result.get(self.value_attribute, self._state) + class TemperatureSensor(Sensor): """ZHA temperature sensor.""" @@ -87,11 +112,13 @@ def unit_of_measurement(self): @property def state(self): """Return the state of the entity.""" - if self._state == 'unknown': - return 'unknown' - celsius = round(float(self._state) / 100, 1) - return convert_temperature( - celsius, TEMP_CELSIUS, self.unit_of_measurement) + if self._state is None: + return None + celsius = self._state / 100 + return round(convert_temperature(celsius, + TEMP_CELSIUS, + self.unit_of_measurement), + 1) class RelativeHumiditySensor(Sensor): @@ -107,7 +134,96 @@ def unit_of_measurement(self): @property def state(self): """Return the state of the entity.""" - if self._state == 'unknown': - return 'unknown' + if self._state is None: + return None return round(float(self._state) / 100, 1) + + +class PressureSensor(Sensor): + """ZHA pressure sensor.""" + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return 'hPa' + + @property + def state(self): + """Return the state of the entity.""" + if self._state is None: + return None + + return round(float(self._state)) + + +class IlluminanceMeasurementSensor(Sensor): + """ZHA lux sensor.""" + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return 'lx' + + @property + def state(self): + """Return the state of the entity.""" + return self._state + + +class MeteringSensor(Sensor): + """ZHA Metering sensor.""" + + value_attribute = 1024 + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return 'W' + + @property + def state(self): + """Return the state of the entity.""" + if self._state is None: + return None + + return round(float(self._state)) + + +class ElectricalMeasurementSensor(Sensor): + """ZHA Electrical Measurement sensor.""" + + value_attribute = 1291 + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return 'W' + + @property + def force_update(self) -> bool: + """Force update this entity.""" + return True + + @property + def state(self): + """Return the state of the entity.""" + if self._state is None: + return None + + return round(float(self._state) / 10, 1) + + @property + def should_poll(self) -> bool: + """Poll state from device.""" + return True + + async def async_update(self): + """Retrieve latest state.""" + _LOGGER.debug("%s async_update", self.entity_id) + + result = await zha.safe_read( + self._endpoint.electrical_measurement, + ['active_power'], + allow_cache=False) + self._state = result.get('active_power', self._state) diff --git a/homeassistant/components/sensor/zoneminder.py b/homeassistant/components/sensor/zoneminder.py index 1189a53bb09815..60b6a018fc2bbe 100644 --- a/homeassistant/components/sensor/zoneminder.py +++ b/homeassistant/components/sensor/zoneminder.py @@ -12,7 +12,7 @@ from homeassistant.const import STATE_UNKNOWN from homeassistant.const import CONF_MONITORED_CONDITIONS from homeassistant.helpers.entity import Entity -import homeassistant.components.zoneminder as zoneminder +from homeassistant.components import zoneminder import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/sensor/zwave.py b/homeassistant/components/sensor/zwave.py index fe295d84d4991b..c6356efe1578fa 100644 --- a/homeassistant/components/sensor/zwave.py +++ b/homeassistant/components/sensor/zwave.py @@ -5,12 +5,10 @@ at https://home-assistant.io/components/sensor.zwave/ """ import logging -# Because we do not compile openzwave on CI -# pylint: disable=import-error from homeassistant.components.sensor import DOMAIN from homeassistant.components import zwave from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT -from homeassistant.components.zwave import async_setup_platform # noqa # pylint: disable=unused-import +from homeassistant.components.zwave import async_setup_platform # noqa pylint: disable=unused-import _LOGGER = logging.getLogger(__name__) @@ -66,7 +64,7 @@ def state(self): """Return the state of the sensor.""" if self._units in ('C', 'F'): return round(self._state, 1) - elif isinstance(self._state, float): + if isinstance(self._state, float): return round(self._state, 2) return self._state @@ -76,7 +74,7 @@ def unit_of_measurement(self): """Return the unit the value is expressed in.""" if self._units == 'C': return TEMP_CELSIUS - elif self._units == 'F': + if self._units == 'F': return TEMP_FAHRENHEIT return self._units diff --git a/homeassistant/components/services.yaml b/homeassistant/components/services.yaml index 746c3c7f4838fa..6b8bded59b83a8 100644 --- a/homeassistant/components/services.yaml +++ b/homeassistant/components/services.yaml @@ -175,6 +175,12 @@ ffmpeg: example: 'binary_sensor.ffmpeg_noise' logger: + set_default_level: + description: Set the default log level for components. + fields: + level: + description: Default severity level. Possible values are notset, debug, info, warn, warning, error, fatal, critical + example: 'debug' set_level: description: Set log level for components. @@ -556,3 +562,42 @@ xiaomi_aqara: device_id: description: Hardware address of the device to remove. example: 158d0000000000 + +shopping_list: + add_item: + description: Adds an item to the shopping list. + fields: + name: + description: The name of the item to add. + example: Beer + complete_item: + description: Marks an item as completed in the shopping list. It does not remove the item. + fields: + name: + description: The name of the item to mark as completed. + example: Beer + +nest: + set_mode: + description: > + Set the home/away mode for a Nest structure. + Set to away mode will also set Estimated Arrival Time if provided. + Set ETA will cause the thermostat to begin warming or cooling the home before the user arrives. + After ETA set other Automation can read ETA sensor as a signal to prepare the home for + the user's arrival. + fields: + home_mode: + description: home or away + example: home + structure: + description: Optional structure name. Default set all structures managed by Home Assistant. + example: My Home + eta: + description: Optional Estimated Arrival Time from now. + example: 0:10 + eta_window: + description: Optional ETA window. Default is 1 minute. + example: 0:5 + trip_id: + description: Optional identity of a trip. Using the same trip_ID will update the estimation. + example: trip_back_home diff --git a/homeassistant/components/shell_command.py b/homeassistant/components/shell_command.py index ca33666d1f3772..10a6c350b7c89e 100644 --- a/homeassistant/components/shell_command.py +++ b/homeassistant/components/shell_command.py @@ -68,8 +68,9 @@ def async_service_handler(service: ServiceCall) -> None: cmd, loop=hass.loop, stdin=None, - stdout=asyncio.subprocess.DEVNULL, - stderr=asyncio.subprocess.DEVNULL) + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) else: # Template used. Break into list and use create_subprocess_exec # (which uses shell=False) for security @@ -80,12 +81,19 @@ def async_service_handler(service: ServiceCall) -> None: *shlexed_cmd, loop=hass.loop, stdin=None, - stdout=asyncio.subprocess.DEVNULL, - stderr=asyncio.subprocess.DEVNULL) + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) process = yield from create_process - yield from process.communicate() - + stdout_data, stderr_data = yield from process.communicate() + + if stdout_data: + _LOGGER.debug("Stdout of command: `%s`, return code: %s:\n%s", + cmd, process.returncode, stdout_data) + if stderr_data: + _LOGGER.debug("Stderr of command: `%s`, return code: %s:\n%s", + cmd, process.returncode, stderr_data) if process.returncode != 0: _LOGGER.exception("Error running command: `%s`, return code: %s", cmd, process.returncode) diff --git a/homeassistant/components/shopping_list.py b/homeassistant/components/shopping_list.py index 0ca0fef6e0696b..f113561429a97e 100644 --- a/homeassistant/components/shopping_list.py +++ b/homeassistant/components/shopping_list.py @@ -14,6 +14,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.util.json import load_json, save_json +ATTR_NAME = 'name' + DOMAIN = 'shopping_list' DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) @@ -23,20 +25,57 @@ INTENT_LAST_ITEMS = 'HassShoppingListLastItems' ITEM_UPDATE_SCHEMA = vol.Schema({ 'complete': bool, - 'name': str, + ATTR_NAME: str, }) PERSISTENCE = '.shopping_list.json' +SERVICE_ADD_ITEM = 'add_item' +SERVICE_COMPLETE_ITEM = 'complete_item' + +SERVICE_ITEM_SCHEMA = vol.Schema({ + vol.Required(ATTR_NAME): vol.Any(None, cv.string) +}) + @asyncio.coroutine def async_setup(hass, config): """Initialize the shopping list.""" + @asyncio.coroutine + def add_item_service(call): + """Add an item with `name`.""" + data = hass.data[DOMAIN] + name = call.data.get(ATTR_NAME) + if name is not None: + data.async_add(name) + + @asyncio.coroutine + def complete_item_service(call): + """Mark the item provided via `name` as completed.""" + data = hass.data[DOMAIN] + name = call.data.get(ATTR_NAME) + if name is None: + return + try: + item = [item for item in data.items if item['name'] == name][0] + except IndexError: + _LOGGER.error("Removing of item failed: %s cannot be found", name) + else: + data.async_update(item['id'], {'name': name, 'complete': True}) + data = hass.data[DOMAIN] = ShoppingData(hass) yield from data.async_load() intent.async_register(hass, AddItemIntent()) intent.async_register(hass, ListTopItemsIntent()) + hass.services.async_register( + DOMAIN, SERVICE_ADD_ITEM, add_item_service, schema=SERVICE_ITEM_SCHEMA + ) + hass.services.async_register( + DOMAIN, SERVICE_COMPLETE_ITEM, complete_item_service, + schema=SERVICE_ITEM_SCHEMA + ) + hass.http.register_view(ShoppingListView) hass.http.register_view(CreateShoppingListItemView) hass.http.register_view(UpdateShoppingListItemView) diff --git a/homeassistant/components/sisyphus.py b/homeassistant/components/sisyphus.py new file mode 100644 index 00000000000000..dc9f9cc4c2573d --- /dev/null +++ b/homeassistant/components/sisyphus.py @@ -0,0 +1,84 @@ +""" +Support for controlling Sisyphus Kinetic Art Tables. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/sisyphus/ +""" +import asyncio +import logging + +import voluptuous as vol + +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + EVENT_HOMEASSISTANT_STOP +) +from homeassistant.helpers.aiohttp_client import async_get_clientsession +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import async_load_platform + +REQUIREMENTS = ['sisyphus-control==2.1'] + +_LOGGER = logging.getLogger(__name__) + +DATA_SISYPHUS = 'sisyphus' +DOMAIN = 'sisyphus' + +AUTODETECT_SCHEMA = vol.Schema({}) + +TABLE_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Required(CONF_HOST): cv.string, +}) + +TABLES_SCHEMA = vol.Schema([TABLE_SCHEMA]) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Any(AUTODETECT_SCHEMA, TABLES_SCHEMA), +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the sisyphus component.""" + from sisyphus_control import Table + tables = hass.data.setdefault(DATA_SISYPHUS, {}) + table_configs = config.get(DOMAIN) + session = async_get_clientsession(hass) + + async def add_table(host, name=None): + """Add platforms for a single table with the given hostname.""" + table = await Table.connect(host, session) + if name is None: + name = table.name + tables[name] = table + _LOGGER.debug("Connected to %s at %s", name, host) + + hass.async_add_job(async_load_platform( + hass, 'light', DOMAIN, { + CONF_NAME: name, + }, config + )) + hass.async_add_job(async_load_platform( + hass, 'media_player', DOMAIN, { + CONF_NAME: name, + CONF_HOST: host, + }, config + )) + + if isinstance(table_configs, dict): # AUTODETECT_SCHEMA + for ip_address in await Table.find_table_ips(session): + await add_table(ip_address) + else: # TABLES_SCHEMA + for conf in table_configs: + await add_table(conf[CONF_HOST], conf[CONF_NAME]) + + async def close_tables(*args): + """Close all table objects.""" + tasks = [table.close() for table in tables.values()] + if tasks: + await asyncio.wait(tasks) + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, close_tables) + + return True diff --git a/homeassistant/components/sleepiq.py b/homeassistant/components/sleepiq.py index 3b74b79b36b018..4d4ecf0160b564 100644 --- a/homeassistant/components/sleepiq.py +++ b/homeassistant/components/sleepiq.py @@ -51,7 +51,6 @@ def setup(hass, config): Will automatically load sensor components to support devices discovered on the account. """ - # pylint: disable=global-statement global DATA from sleepyq import Sleepyq @@ -74,7 +73,7 @@ def setup(hass, config): return True -class SleepIQData(object): +class SleepIQData: """Get the latest data from SleepIQ.""" def __init__(self, client): diff --git a/homeassistant/components/smappee.py b/homeassistant/components/smappee.py index b35cd8cf5a8ce1..7904f0a6cce4ad 100644 --- a/homeassistant/components/smappee.py +++ b/homeassistant/components/smappee.py @@ -16,7 +16,7 @@ from homeassistant.helpers.discovery import load_platform import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['smappy==0.2.15'] +REQUIREMENTS = ['smappy==0.2.16'] _LOGGER = logging.getLogger(__name__) @@ -71,7 +71,7 @@ def setup(hass, config): return True -class Smappee(object): +class Smappee: """Stores data retrieved from Smappee sensor.""" def __init__(self, client_id, client_secret, username, diff --git a/homeassistant/components/snips.py b/homeassistant/components/snips.py index 812906e7be9f5c..34290819106678 100644 --- a/homeassistant/components/snips.py +++ b/homeassistant/components/snips.py @@ -12,7 +12,7 @@ from homeassistant.core import callback from homeassistant.helpers import intent, config_validation as cv -import homeassistant.components.mqtt as mqtt +from homeassistant.components import mqtt DOMAIN = 'snips' DEPENDENCIES = ['mqtt'] @@ -131,6 +131,8 @@ async def message_received(topic, payload, qos): slots = {} for slot in request.get('slots', []): slots[slot['slotName']] = {'value': resolve_slot_values(slot)} + slots['site_id'] = {'value': request.get('siteId')} + slots['probability'] = {'value': request['intent']['probability']} try: intent_response = await intent.async_handle( diff --git a/homeassistant/components/sonos/.translations/ca.json b/homeassistant/components/sonos/.translations/ca.json new file mode 100644 index 00000000000000..9a745784b25fd2 --- /dev/null +++ b/homeassistant/components/sonos/.translations/ca.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "No s'han trobat dispositius Sonos a la xarxa.", + "single_instance_allowed": "Nom\u00e9s cal una \u00fanica configuraci\u00f3 de Sonos." + }, + "step": { + "confirm": { + "description": "Voleu configurar Sonos?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/cs.json b/homeassistant/components/sonos/.translations/cs.json new file mode 100644 index 00000000000000..c0b26284cdff39 --- /dev/null +++ b/homeassistant/components/sonos/.translations/cs.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "V s\u00edti nebyly nalezena \u017e\u00e1dn\u00e1 za\u0159\u00edzen\u00ed Sonos.", + "single_instance_allowed": "Je t\u0159eba jen jedna konfigurace Sonos." + }, + "step": { + "confirm": { + "description": "Chcete nastavit Sonos?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/de.json b/homeassistant/components/sonos/.translations/de.json new file mode 100644 index 00000000000000..d0587036d245b2 --- /dev/null +++ b/homeassistant/components/sonos/.translations/de.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Keine Sonos Ger\u00e4te im Netzwerk gefunden.", + "single_instance_allowed": "Nur eine einzige Konfiguration von Sonos ist notwendig." + }, + "step": { + "confirm": { + "description": "M\u00f6chten Sie Sonos konfigurieren?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/en.json b/homeassistant/components/sonos/.translations/en.json new file mode 100644 index 00000000000000..c7aae4302f6bea --- /dev/null +++ b/homeassistant/components/sonos/.translations/en.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "No Sonos devices found on the network.", + "single_instance_allowed": "Only a single configuration of Sonos is necessary." + }, + "step": { + "confirm": { + "description": "Do you want to setup Sonos?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/es-419.json b/homeassistant/components/sonos/.translations/es-419.json new file mode 100644 index 00000000000000..ff6924389d6104 --- /dev/null +++ b/homeassistant/components/sonos/.translations/es-419.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "No se encontraron dispositivos Sonos en la red.", + "single_instance_allowed": "S\u00f3lo se necesita una \u00fanica configuraci\u00f3n de Sonos." + }, + "step": { + "confirm": { + "description": "\u00bfDesea configurar Sonos?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/hu.json b/homeassistant/components/sonos/.translations/hu.json new file mode 100644 index 00000000000000..4726d57ad249a7 --- /dev/null +++ b/homeassistant/components/sonos/.translations/hu.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nem tal\u00e1lhat\u00f3k Sonos eszk\u00f6z\u00f6k a h\u00e1l\u00f3zaton." + }, + "step": { + "confirm": { + "description": "Be szeretn\u00e9d \u00e1ll\u00edtani a Sonos-t?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/it.json b/homeassistant/components/sonos/.translations/it.json new file mode 100644 index 00000000000000..e32557f1d95566 --- /dev/null +++ b/homeassistant/components/sonos/.translations/it.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Non sono presenti dispositivi Sonos in rete.", + "single_instance_allowed": "\u00c8 necessaria una sola configurazione di Sonos." + }, + "step": { + "confirm": { + "description": "Vuoi installare Sonos", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/ko.json b/homeassistant/components/sonos/.translations/ko.json new file mode 100644 index 00000000000000..89933f57425b9e --- /dev/null +++ b/homeassistant/components/sonos/.translations/ko.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Sonos \uc7a5\uce58\uac00 \ub124\ud2b8\uc6cc\ud06c\uc5d0\uc11c \ubc1c\uacac\ub418\uc9c0 \uc54a\uc558\uc2b5\ub2c8\ub2e4.", + "single_instance_allowed": "\ud558\ub098\uc758 Sonos \ub9cc \uad6c\uc131 \ud560 \uc218 \uc788\uc2b5\ub2c8\ub2e4." + }, + "step": { + "confirm": { + "description": "Sonos\ub97c \uc124\uc815 \ud558\uc2dc\uaca0\uc2b5\ub2c8\uae4c?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/lb.json b/homeassistant/components/sonos/.translations/lb.json new file mode 100644 index 00000000000000..26eaec4584d4dc --- /dev/null +++ b/homeassistant/components/sonos/.translations/lb.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Keng Sonos Apparater am Netzwierk fonnt.", + "single_instance_allowed": "N\u00ebmmen eng eenzeg Konfiguratioun vun Sonos ass n\u00e9ideg." + }, + "step": { + "confirm": { + "description": "Soll Sonos konfigur\u00e9iert ginn?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/nl.json b/homeassistant/components/sonos/.translations/nl.json new file mode 100644 index 00000000000000..de84482cc63c45 --- /dev/null +++ b/homeassistant/components/sonos/.translations/nl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Geen Sonos-apparaten gevonden op het netwerk.", + "single_instance_allowed": "Er is slechts \u00e9\u00e9n configuratie van Sonos nodig." + }, + "step": { + "confirm": { + "description": "Wilt u Sonos instellen?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/no.json b/homeassistant/components/sonos/.translations/no.json new file mode 100644 index 00000000000000..c837abad499db4 --- /dev/null +++ b/homeassistant/components/sonos/.translations/no.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Ingen Sonos enheter funnet p\u00e5 nettverket.", + "single_instance_allowed": "Kun en enkelt konfigurasjon av Sonos er n\u00f8dvendig." + }, + "step": { + "confirm": { + "description": "\u00d8nsker du \u00e5 sette opp Sonos?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/pl.json b/homeassistant/components/sonos/.translations/pl.json new file mode 100644 index 00000000000000..2a0c526b9a64ac --- /dev/null +++ b/homeassistant/components/sonos/.translations/pl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nie znaleziono w sieci urz\u0105dze\u0144 Sonos.", + "single_instance_allowed": "Wymagana jest tylko jedna konfiguracja Sonos." + }, + "step": { + "confirm": { + "description": "Chcesz skonfigurowa\u0107 Sonos?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/pt-BR.json b/homeassistant/components/sonos/.translations/pt-BR.json new file mode 100644 index 00000000000000..02d3e0c0fb9c9b --- /dev/null +++ b/homeassistant/components/sonos/.translations/pt-BR.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nenhum dispositivo Sonos encontrado na rede.", + "single_instance_allowed": "Apenas uma \u00fanica configura\u00e7\u00e3o do Sonos \u00e9 necess\u00e1ria." + }, + "step": { + "confirm": { + "description": "Voc\u00ea quer configurar o Sonos?", + "title": "" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/pt.json b/homeassistant/components/sonos/.translations/pt.json new file mode 100644 index 00000000000000..a2032c76a4a449 --- /dev/null +++ b/homeassistant/components/sonos/.translations/pt.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Nenhum dispositivo Sonos encontrado na rede.", + "single_instance_allowed": "Apenas uma \u00fanica configura\u00e7\u00e3o do Sonos \u00e9 necess\u00e1ria." + }, + "step": { + "confirm": { + "description": "Deseja configurar o Sonos?", + "title": "" + } + }, + "title": "" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/ru.json b/homeassistant/components/sonos/.translations/ru.json new file mode 100644 index 00000000000000..63b6bd87c20b47 --- /dev/null +++ b/homeassistant/components/sonos/.translations/ru.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430 Sonos \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b.", + "single_instance_allowed": "\u041d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u0430 \u0442\u043e\u043b\u044c\u043a\u043e \u043e\u0434\u043d\u0430 \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u044f Sonos." + }, + "step": { + "confirm": { + "description": "\u0412\u044b \u0445\u043e\u0442\u0438\u0442\u0435 \u043d\u0430\u0441\u0442\u0440\u043e\u0438\u0442\u044c Sonos?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/sl.json b/homeassistant/components/sonos/.translations/sl.json new file mode 100644 index 00000000000000..6773465bbbfd22 --- /dev/null +++ b/homeassistant/components/sonos/.translations/sl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "V omre\u017eju ni najdenih naprav Sonos.", + "single_instance_allowed": "Potrebna je samo ena konfiguracija Sonosa." + }, + "step": { + "confirm": { + "description": "Ali \u017eelite nastaviti Sonos?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/sv.json b/homeassistant/components/sonos/.translations/sv.json new file mode 100644 index 00000000000000..756fe8a74832d2 --- /dev/null +++ b/homeassistant/components/sonos/.translations/sv.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Inga Sonos-enheter hittades i n\u00e4tverket.", + "single_instance_allowed": "Endast en enda konfiguration av Sonos \u00e4r n\u00f6dv\u00e4ndig." + }, + "step": { + "confirm": { + "description": "Vill du konfigurera Sonos?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/vi.json b/homeassistant/components/sonos/.translations/vi.json new file mode 100644 index 00000000000000..ebeb1a8b07ce31 --- /dev/null +++ b/homeassistant/components/sonos/.translations/vi.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Kh\u00f4ng t\u00ecm th\u1ea5y thi\u1ebft b\u1ecb Sonos n\u00e0o tr\u00ean m\u1ea1ng.", + "single_instance_allowed": "Ch\u1ec9 c\u1ea7n m\u1ed9t c\u1ea5u h\u00ecnh duy nh\u1ea5t c\u1ee7a Sonos l\u00e0 \u0111\u1ee7." + }, + "step": { + "confirm": { + "description": "B\u1ea1n c\u00f3 mu\u1ed1n thi\u1ebft l\u1eadp Sonos kh\u00f4ng?", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/zh-Hans.json b/homeassistant/components/sonos/.translations/zh-Hans.json new file mode 100644 index 00000000000000..17c1e78d3e8922 --- /dev/null +++ b/homeassistant/components/sonos/.translations/zh-Hans.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u6ca1\u6709\u5728\u7f51\u7edc\u4e0a\u627e\u5230 Sonos \u8bbe\u5907\u3002", + "single_instance_allowed": "\u53ea\u6709\u4e00\u6b21 Sonos \u914d\u7f6e\u662f\u5fc5\u8981\u7684\u3002" + }, + "step": { + "confirm": { + "description": "\u60a8\u60f3\u8981\u914d\u7f6e Sonos \u5417\uff1f", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/.translations/zh-Hant.json b/homeassistant/components/sonos/.translations/zh-Hant.json new file mode 100644 index 00000000000000..c6fb13c3605d30 --- /dev/null +++ b/homeassistant/components/sonos/.translations/zh-Hant.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "\u5728\u7db2\u8def\u4e0a\u627e\u4e0d\u5230 Sonos \u8a2d\u5099\u3002", + "single_instance_allowed": "\u50c5\u9700\u8a2d\u5b9a\u4e00\u6b21 Sonos \u5373\u53ef\u3002" + }, + "step": { + "confirm": { + "description": "\u662f\u5426\u8981\u8a2d\u5b9a Sonos\uff1f", + "title": "Sonos" + } + }, + "title": "Sonos" + } +} \ No newline at end of file diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py new file mode 100644 index 00000000000000..bbc05a3aa6116f --- /dev/null +++ b/homeassistant/components/sonos/__init__.py @@ -0,0 +1,37 @@ +"""Component to embed Sonos.""" +from homeassistant import config_entries +from homeassistant.helpers import config_entry_flow + + +DOMAIN = 'sonos' +REQUIREMENTS = ['SoCo==0.14'] + + +async def async_setup(hass, config): + """Set up the Sonos component.""" + conf = config.get(DOMAIN) + + hass.data[DOMAIN] = conf or {} + + if conf is not None: + hass.async_create_task(hass.config_entries.flow.async_init( + DOMAIN, context={'source': config_entries.SOURCE_IMPORT})) + + return True + + +async def async_setup_entry(hass, entry): + """Set up Sonos from a config entry.""" + hass.async_create_task(hass.config_entries.async_forward_entry_setup( + entry, 'media_player')) + return True + + +async def _async_has_devices(hass): + """Return if there are devices that can be discovered.""" + import soco + + return await hass.async_add_executor_job(soco.discover) + + +config_entry_flow.register_discovery_flow(DOMAIN, 'Sonos', _async_has_devices) diff --git a/homeassistant/components/sonos/strings.json b/homeassistant/components/sonos/strings.json new file mode 100644 index 00000000000000..4aa68712d599e7 --- /dev/null +++ b/homeassistant/components/sonos/strings.json @@ -0,0 +1,15 @@ +{ + "config": { + "title": "Sonos", + "step": { + "confirm": { + "title": "Sonos", + "description": "Do you want to setup Sonos?" + } + }, + "abort": { + "single_instance_allowed": "Only a single configuration of Sonos is necessary.", + "no_devices_found": "No Sonos devices found on the network." + } + } +} diff --git a/homeassistant/components/spaceapi.py b/homeassistant/components/spaceapi.py new file mode 100644 index 00000000000000..eaf1508071ad0e --- /dev/null +++ b/homeassistant/components/spaceapi.py @@ -0,0 +1,175 @@ +""" +Support for the SpaceAPI. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/spaceapi/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.http import HomeAssistantView +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_ICON, ATTR_LATITUDE, ATTR_LOCATION, ATTR_LONGITUDE, + ATTR_STATE, ATTR_UNIT_OF_MEASUREMENT, CONF_ADDRESS, CONF_EMAIL, + CONF_ENTITY_ID, CONF_SENSORS, CONF_STATE, CONF_URL) +import homeassistant.core as ha +import homeassistant.helpers.config_validation as cv +import homeassistant.util.dt as dt_util + +_LOGGER = logging.getLogger(__name__) + +ATTR_ADDRESS = 'address' +ATTR_API = 'api' +ATTR_CLOSE = 'close' +ATTR_CONTACT = 'contact' +ATTR_ISSUE_REPORT_CHANNELS = 'issue_report_channels' +ATTR_LASTCHANGE = 'lastchange' +ATTR_LOGO = 'logo' +ATTR_NAME = 'name' +ATTR_OPEN = 'open' +ATTR_SENSORS = 'sensors' +ATTR_SPACE = 'space' +ATTR_UNIT = 'unit' +ATTR_URL = 'url' +ATTR_VALUE = 'value' + +CONF_CONTACT = 'contact' +CONF_HUMIDITY = 'humidity' +CONF_ICON_CLOSED = 'icon_closed' +CONF_ICON_OPEN = 'icon_open' +CONF_ICONS = 'icons' +CONF_IRC = 'irc' +CONF_ISSUE_REPORT_CHANNELS = 'issue_report_channels' +CONF_LOCATION = 'location' +CONF_LOGO = 'logo' +CONF_MAILING_LIST = 'mailing_list' +CONF_PHONE = 'phone' +CONF_SPACE = 'space' +CONF_TEMPERATURE = 'temperature' +CONF_TWITTER = 'twitter' + +DATA_SPACEAPI = 'data_spaceapi' +DEPENDENCIES = ['http'] +DOMAIN = 'spaceapi' + +ISSUE_REPORT_CHANNELS = [CONF_EMAIL, CONF_IRC, CONF_MAILING_LIST, CONF_TWITTER] + +SENSOR_TYPES = [CONF_HUMIDITY, CONF_TEMPERATURE] +SPACEAPI_VERSION = 0.13 + +URL_API_SPACEAPI = '/api/spaceapi' + +LOCATION_SCHEMA = vol.Schema({ + vol.Optional(CONF_ADDRESS): cv.string, +}, required=True) + +CONTACT_SCHEMA = vol.Schema({ + vol.Optional(CONF_EMAIL): cv.string, + vol.Optional(CONF_IRC): cv.string, + vol.Optional(CONF_MAILING_LIST): cv.string, + vol.Optional(CONF_PHONE): cv.string, + vol.Optional(CONF_TWITTER): cv.string, +}, required=False) + +STATE_SCHEMA = vol.Schema({ + vol.Required(CONF_ENTITY_ID): cv.entity_id, + vol.Inclusive(CONF_ICON_CLOSED, CONF_ICONS): cv.url, + vol.Inclusive(CONF_ICON_OPEN, CONF_ICONS): cv.url, +}, required=False) + +SENSOR_SCHEMA = vol.Schema( + {vol.In(SENSOR_TYPES): [cv.entity_id]} +) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_CONTACT): CONTACT_SCHEMA, + vol.Required(CONF_ISSUE_REPORT_CHANNELS): + vol.All(cv.ensure_list, [vol.In(ISSUE_REPORT_CHANNELS)]), + vol.Required(CONF_LOCATION): LOCATION_SCHEMA, + vol.Required(CONF_LOGO): cv.url, + vol.Required(CONF_SPACE): cv.string, + vol.Required(CONF_STATE): STATE_SCHEMA, + vol.Required(CONF_URL): cv.string, + vol.Optional(CONF_SENSORS): SENSOR_SCHEMA, + }), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Register the SpaceAPI with the HTTP interface.""" + hass.data[DATA_SPACEAPI] = config[DOMAIN] + hass.http.register_view(APISpaceApiView) + + return True + + +class APISpaceApiView(HomeAssistantView): + """View to provide details according to the SpaceAPI.""" + + url = URL_API_SPACEAPI + name = 'api:spaceapi' + + @ha.callback + def get(self, request): + """Get SpaceAPI data.""" + hass = request.app['hass'] + spaceapi = dict(hass.data[DATA_SPACEAPI]) + is_sensors = spaceapi.get('sensors') + + location = { + ATTR_ADDRESS: spaceapi[ATTR_LOCATION][CONF_ADDRESS], + ATTR_LATITUDE: hass.config.latitude, + ATTR_LONGITUDE: hass.config.longitude, + } + + state_entity = spaceapi['state'][ATTR_ENTITY_ID] + space_state = hass.states.get(state_entity) + + if space_state is not None: + state = { + ATTR_OPEN: False if space_state.state == 'off' else True, + ATTR_LASTCHANGE: + dt_util.as_timestamp(space_state.last_updated), + } + else: + state = {ATTR_OPEN: 'null', ATTR_LASTCHANGE: 0} + + try: + state[ATTR_ICON] = { + ATTR_OPEN: spaceapi['state'][CONF_ICON_OPEN], + ATTR_CLOSE: spaceapi['state'][CONF_ICON_CLOSED], + } + except KeyError: + pass + + data = { + ATTR_API: SPACEAPI_VERSION, + ATTR_CONTACT: spaceapi[CONF_CONTACT], + ATTR_ISSUE_REPORT_CHANNELS: spaceapi[CONF_ISSUE_REPORT_CHANNELS], + ATTR_LOCATION: location, + ATTR_LOGO: spaceapi[CONF_LOGO], + ATTR_SPACE: spaceapi[CONF_SPACE], + ATTR_STATE: state, + ATTR_URL: spaceapi[CONF_URL], + } + + if is_sensors is not None: + sensors = {} + for sensor_type in is_sensors: + sensors[sensor_type] = [] + for sensor in spaceapi['sensors'][sensor_type]: + sensor_state = hass.states.get(sensor) + unit = sensor_state.attributes[ATTR_UNIT_OF_MEASUREMENT] + value = sensor_state.state + sensor_data = { + ATTR_LOCATION: spaceapi[CONF_SPACE], + ATTR_NAME: sensor_state.name, + ATTR_UNIT: unit, + ATTR_VALUE: value, + } + sensors[sensor_type].append(sensor_data) + data[ATTR_SENSORS] = sensors + + return self.json(data) diff --git a/homeassistant/components/spc.py b/homeassistant/components/spc.py index 9742bc25c63431..bf7db87f06b370 100644 --- a/homeassistant/components/spc.py +++ b/homeassistant/components/spc.py @@ -56,14 +56,14 @@ def async_setup(hass, config): # add sensor devices for each zone (typically motion/fire/door sensors) zones = yield from api.get_zones() if zones: - hass.async_add_job(discovery.async_load_platform( + hass.async_create_task(discovery.async_load_platform( hass, 'binary_sensor', DOMAIN, {ATTR_DISCOVER_DEVICES: zones}, config)) # create a separate alarm panel for each area areas = yield from api.get_areas() if areas: - hass.async_add_job(discovery.async_load_platform( + hass.async_create_task(discovery.async_load_platform( hass, 'alarm_control_panel', DOMAIN, {ATTR_DISCOVER_AREAS: areas}, config)) @@ -151,7 +151,7 @@ def _ws_process_message(message, async_callback, *args): "Unsuccessful websocket message delivered, ignoring: %s", message) try: yield from async_callback(message['data']['sia'], *args) - except: # noqa: E722 # pylint: disable=bare-except + except: # noqa: E722 pylint: disable=bare-except _LOGGER.exception("Exception in callback, ignoring") diff --git a/homeassistant/components/spider.py b/homeassistant/components/spider.py new file mode 100644 index 00000000000000..48632be6badfad --- /dev/null +++ b/homeassistant/components/spider.py @@ -0,0 +1,65 @@ +""" +Support for Spider Smart devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/spider/ +""" +from datetime import timedelta +import logging + +import voluptuous as vol + +from homeassistant.const import ( + CONF_PASSWORD, CONF_SCAN_INTERVAL, CONF_USERNAME) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.discovery import load_platform + +REQUIREMENTS = ['spiderpy==1.2.0'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'spider' + +SPIDER_COMPONENTS = [ + 'climate', + 'switch' +] + +SCAN_INTERVAL = timedelta(seconds=120) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): + cv.time_period, + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up Spider Component.""" + from spiderpy.spiderapi import SpiderApi + from spiderpy.spiderapi import UnauthorizedException + + username = config[DOMAIN][CONF_USERNAME] + password = config[DOMAIN][CONF_PASSWORD] + refresh_rate = config[DOMAIN][CONF_SCAN_INTERVAL] + + try: + api = SpiderApi(username, password, refresh_rate.total_seconds()) + + hass.data[DOMAIN] = { + 'controller': api, + 'thermostats': api.get_thermostats(), + 'power_plugs': api.get_power_plugs() + } + + for component in SPIDER_COMPONENTS: + load_platform(hass, component, DOMAIN, {}) + + _LOGGER.debug("Connection with Spider API succeeded") + return True + except UnauthorizedException: + _LOGGER.error("Can't connect to the Spider API") + return False diff --git a/homeassistant/components/switch/__init__.py b/homeassistant/components/switch/__init__.py index 9a35198628a649..cb69240ee73dca 100644 --- a/homeassistant/components/switch/__init__.py +++ b/homeassistant/components/switch/__init__.py @@ -95,7 +95,7 @@ def toggle(hass, entity_id=None): async def async_setup(hass, config): """Track states and offer events for switches.""" - component = EntityComponent( + component = hass.data[DOMAIN] = EntityComponent( _LOGGER, DOMAIN, hass, SCAN_INTERVAL, GROUP_NAME_ALL_SWITCHES) await component.async_setup(config) @@ -114,7 +114,8 @@ async def async_handle_switch_service(service): if not switch.should_poll: continue - update_tasks.append(switch.async_update_ha_state(True)) + update_tasks.append( + switch.async_update_ha_state(True, service.context)) if update_tasks: await asyncio.wait(update_tasks, loop=hass.loop) @@ -132,10 +133,19 @@ async def async_handle_switch_service(service): return True +async def async_setup_entry(hass, entry): + """Setup a config entry.""" + return await hass.data[DOMAIN].async_setup_entry(entry) + + +async def async_unload_entry(hass, entry): + """Unload a config entry.""" + return await hass.data[DOMAIN].async_unload_entry(entry) + + class SwitchDevice(ToggleEntity): """Representation of a switch.""" - # pylint: disable=no-self-use @property def current_power_w(self): """Return the current power usage in W.""" diff --git a/homeassistant/components/switch/amcrest.py b/homeassistant/components/switch/amcrest.py old mode 100755 new mode 100644 index 0b93bc98b1029c..cfe33562f9f987 --- a/homeassistant/components/switch/amcrest.py +++ b/homeassistant/components/switch/amcrest.py @@ -30,7 +30,7 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): all_switches = [] for setting in switches: - all_switches.append(AmcrestSwitch(setting, camera)) + all_switches.append(AmcrestSwitch(setting, camera, name)) async_add_devices(all_switches, True) @@ -38,11 +38,11 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class AmcrestSwitch(ToggleEntity): """Representation of an Amcrest IP camera switch.""" - def __init__(self, setting, camera): + def __init__(self, setting, camera, name): """Initialize the Amcrest switch.""" self._setting = setting self._camera = camera - self._name = SWITCHES[setting][0] + self._name = '{} {}'.format(SWITCHES[setting][0], name) self._icon = SWITCHES[setting][1] self._state = None diff --git a/homeassistant/components/switch/anel_pwrctrl.py b/homeassistant/components/switch/anel_pwrctrl.py index 9144222e5c78df..01d27b8abcd3b7 100644 --- a/homeassistant/components/switch/anel_pwrctrl.py +++ b/homeassistant/components/switch/anel_pwrctrl.py @@ -15,9 +15,7 @@ from homeassistant.const import (CONF_HOST, CONF_PASSWORD, CONF_USERNAME) from homeassistant.util import Throttle -REQUIREMENTS = ['https://github.com/mweinelt/anel-pwrctrl/archive/' - 'ed26e8830e28a2bfa4260a9002db23ce3e7e63d7.zip' - '#anel_pwrctrl==0.0.1'] +REQUIREMENTS = ['anel_pwrctrl-homeassistant==0.0.1.dev2'] _LOGGER = logging.getLogger(__name__) @@ -35,7 +33,6 @@ }) -# pylint: disable=unused-variable def setup_platform(hass, config, add_devices, discovery_info=None): """Set up PwrCtrl devices/switches.""" host = config.get(CONF_HOST, None) @@ -110,7 +107,7 @@ def turn_off(self, **kwargs): self._port.off() -class PwrCtrlDevice(object): +class PwrCtrlDevice: """Device representation for per device throttling.""" def __init__(self, device): diff --git a/homeassistant/components/switch/arduino.py b/homeassistant/components/switch/arduino.py index 1547f4f1dee363..2bcb04c566e5c2 100644 --- a/homeassistant/components/switch/arduino.py +++ b/homeassistant/components/switch/arduino.py @@ -10,7 +10,7 @@ import voluptuous as vol -import homeassistant.components.arduino as arduino +from homeassistant.components import arduino from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import CONF_NAME import homeassistant.helpers.config_validation as cv diff --git a/homeassistant/components/switch/arest.py b/homeassistant/components/switch/arest.py index 6e31694fd2db15..fd72d0728a00db 100644 --- a/homeassistant/components/switch/arest.py +++ b/homeassistant/components/switch/arest.py @@ -18,11 +18,13 @@ CONF_FUNCTIONS = 'functions' CONF_PINS = 'pins' +CONF_INVERT = 'invert' DEFAULT_NAME = 'aREST switch' PIN_FUNCTION_SCHEMA = vol.Schema({ vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_INVERT, default=False): cv.boolean, }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -54,7 +56,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): for pinnum, pin in pins.items(): dev.append(ArestSwitchPin( resource, config.get(CONF_NAME, response.json()[CONF_NAME]), - pin.get(CONF_NAME), pinnum)) + pin.get(CONF_NAME), pinnum, pin.get(CONF_INVERT))) functions = config.get(CONF_FUNCTIONS) for funcname, func in functions.items(): @@ -152,10 +154,11 @@ def update(self): class ArestSwitchPin(ArestSwitchBase): """Representation of an aREST switch. Based on digital I/O.""" - def __init__(self, resource, location, name, pin): + def __init__(self, resource, location, name, pin, invert): """Initialize the switch.""" super().__init__(resource, location, name) self._pin = pin + self.invert = invert request = requests.get( '{}/mode/{}/o'.format(self._resource, self._pin), timeout=10) @@ -165,8 +168,11 @@ def __init__(self, resource, location, name, pin): def turn_on(self, **kwargs): """Turn the device on.""" + turn_on_payload = int(not self.invert) request = requests.get( - '{}/digital/{}/1'.format(self._resource, self._pin), timeout=10) + '{}/digital/{}/{}'.format(self._resource, self._pin, + turn_on_payload), + timeout=10) if request.status_code == 200: self._state = True else: @@ -175,8 +181,11 @@ def turn_on(self, **kwargs): def turn_off(self, **kwargs): """Turn the device off.""" + turn_off_payload = int(self.invert) request = requests.get( - '{}/digital/{}/0'.format(self._resource, self._pin), timeout=10) + '{}/digital/{}/{}'.format(self._resource, self._pin, + turn_off_payload), + timeout=10) if request.status_code == 200: self._state = False else: @@ -188,7 +197,8 @@ def update(self): try: request = requests.get( '{}/digital/{}'.format(self._resource, self._pin), timeout=10) - self._state = request.json()['return_value'] != 0 + status_value = int(self.invert) + self._state = request.json()['return_value'] != status_value self._available = True except requests.exceptions.ConnectionError: _LOGGER.warning("No route to device %s", self._resource) diff --git a/homeassistant/components/switch/bbb_gpio.py b/homeassistant/components/switch/bbb_gpio.py index 6dc5df4ffe3513..94952ac736b753 100644 --- a/homeassistant/components/switch/bbb_gpio.py +++ b/homeassistant/components/switch/bbb_gpio.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA -import homeassistant.components.bbb_gpio as bbb_gpio +from homeassistant.components import bbb_gpio from homeassistant.const import (DEVICE_DEFAULT_NAME, CONF_NAME) from homeassistant.helpers.entity import ToggleEntity import homeassistant.helpers.config_validation as cv @@ -34,7 +34,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the BeagleBone Black GPIO devices.""" pins = config.get(CONF_PINS) diff --git a/homeassistant/components/switch/broadlink.py b/homeassistant/components/switch/broadlink.py index 460021121775da..6b754effaf1deb 100644 --- a/homeassistant/components/switch/broadlink.py +++ b/homeassistant/components/switch/broadlink.py @@ -348,7 +348,7 @@ def update(self): self._state = self._parent_device.get_outlet_status(self._slot) -class BroadlinkMP1Switch(object): +class BroadlinkMP1Switch: """Representation of a Broadlink switch - To fetch states of all slots.""" def __init__(self, device): diff --git a/homeassistant/components/switch/command_line.py b/homeassistant/components/switch/command_line.py index 478b1c6e9adf58..127c7578940f40 100644 --- a/homeassistant/components/switch/command_line.py +++ b/homeassistant/components/switch/command_line.py @@ -32,7 +32,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Find and return switches controlled by shell commands.""" devices = config.get(CONF_SWITCHES, {}) diff --git a/homeassistant/components/switch/deconz.py b/homeassistant/components/switch/deconz.py new file mode 100644 index 00000000000000..d5fb22e97c467f --- /dev/null +++ b/homeassistant/components/switch/deconz.py @@ -0,0 +1,118 @@ +""" +Support for deCONZ switches. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.deconz/ +""" +from homeassistant.components.deconz.const import ( + DOMAIN as DATA_DECONZ, DATA_DECONZ_ID, DATA_DECONZ_UNSUB, + POWER_PLUGS, SIRENS) +from homeassistant.components.switch import SwitchDevice +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +DEPENDENCIES = ['deconz'] + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Old way of setting up deCONZ switches.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up switches for deCONZ component. + + Switches are based same device class as lights in deCONZ. + """ + @callback + def async_add_switch(lights): + """Add switch from deCONZ.""" + entities = [] + for light in lights: + if light.type in POWER_PLUGS: + entities.append(DeconzPowerPlug(light)) + elif light.type in SIRENS: + entities.append(DeconzSiren(light)) + async_add_devices(entities, True) + + hass.data[DATA_DECONZ_UNSUB].append( + async_dispatcher_connect(hass, 'deconz_new_light', async_add_switch)) + + async_add_switch(hass.data[DATA_DECONZ].lights.values()) + + +class DeconzSwitch(SwitchDevice): + """Representation of a deCONZ switch.""" + + def __init__(self, switch): + """Set up switch and add update callback to get data from websocket.""" + self._switch = switch + + async def async_added_to_hass(self): + """Subscribe to switches events.""" + self._switch.register_async_callback(self.async_update_callback) + self.hass.data[DATA_DECONZ_ID][self.entity_id] = self._switch.deconz_id + + @callback + def async_update_callback(self, reason): + """Update the switch's state.""" + self.async_schedule_update_ha_state() + + @property + def name(self): + """Return the name of the switch.""" + return self._switch.name + + @property + def unique_id(self): + """Return a unique identifier for this switch.""" + return self._switch.uniqueid + + @property + def available(self): + """Return True if light is available.""" + return self._switch.reachable + + @property + def should_poll(self): + """No polling needed.""" + return False + + +class DeconzPowerPlug(DeconzSwitch): + """Representation of power plugs from deCONZ.""" + + @property + def is_on(self): + """Return true if switch is on.""" + return self._switch.state + + async def async_turn_on(self, **kwargs): + """Turn on switch.""" + data = {'on': True} + await self._switch.async_set_state(data) + + async def async_turn_off(self, **kwargs): + """Turn off switch.""" + data = {'on': False} + await self._switch.async_set_state(data) + + +class DeconzSiren(DeconzSwitch): + """Representation of sirens from deCONZ.""" + + @property + def is_on(self): + """Return true if switch is on.""" + return self._switch.alert == 'lselect' + + async def async_turn_on(self, **kwargs): + """Turn on switch.""" + data = {'alert': 'lselect'} + await self._switch.async_set_state(data) + + async def async_turn_off(self, **kwargs): + """Turn off switch.""" + data = {'alert': 'none'} + await self._switch.async_set_state(data) diff --git a/homeassistant/components/switch/deluge.py b/homeassistant/components/switch/deluge.py index da0b3bf3228966..c71c3865f5dc19 100644 --- a/homeassistant/components/switch/deluge.py +++ b/homeassistant/components/switch/deluge.py @@ -32,7 +32,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Deluge switch.""" from deluge_client import DelugeRPCClient diff --git a/homeassistant/components/switch/demo.py b/homeassistant/components/switch/demo.py index 83b8ae796bb058..7e22f962330d5b 100644 --- a/homeassistant/components/switch/demo.py +++ b/homeassistant/components/switch/demo.py @@ -8,7 +8,6 @@ from homeassistant.const import DEVICE_DEFAULT_NAME -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up the demo switches.""" add_devices_callback([ diff --git a/homeassistant/components/switch/digital_ocean.py b/homeassistant/components/switch/digital_ocean.py index 081eea80e2dcbd..12a6aabb170079 100644 --- a/homeassistant/components/switch/digital_ocean.py +++ b/homeassistant/components/switch/digital_ocean.py @@ -13,7 +13,8 @@ from homeassistant.components.digital_ocean import ( CONF_DROPLETS, ATTR_CREATED_AT, ATTR_DROPLET_ID, ATTR_DROPLET_NAME, ATTR_FEATURES, ATTR_IPV4_ADDRESS, ATTR_IPV6_ADDRESS, ATTR_MEMORY, - ATTR_REGION, ATTR_VCPUS, DATA_DIGITAL_OCEAN) + ATTR_REGION, ATTR_VCPUS, CONF_ATTRIBUTION, DATA_DIGITAL_OCEAN) +from homeassistant.const import ATTR_ATTRIBUTION _LOGGER = logging.getLogger(__name__) @@ -69,6 +70,7 @@ def is_on(self): def device_state_attributes(self): """Return the state attributes of the Digital Ocean droplet.""" return { + ATTR_ATTRIBUTION: CONF_ATTRIBUTION, ATTR_CREATED_AT: self.data.created_at, ATTR_DROPLET_ID: self.data.id, ATTR_DROPLET_NAME: self.data.name, diff --git a/homeassistant/components/switch/digitalloggers.py b/homeassistant/components/switch/digitalloggers.py index f3af70c6222f74..29e6771d1d581f 100644 --- a/homeassistant/components/switch/digitalloggers.py +++ b/homeassistant/components/switch/digitalloggers.py @@ -122,7 +122,7 @@ def update(self): self._state = outlet_status[2] == 'ON' -class DINRelayDevice(object): +class DINRelayDevice: """Device representation for per device throttling.""" def __init__(self, power_switch): diff --git a/homeassistant/components/switch/dlink.py b/homeassistant/components/switch/dlink.py index 5d727e72138db7..9ce324ef6bb8c0 100644 --- a/homeassistant/components/switch/dlink.py +++ b/homeassistant/components/switch/dlink.py @@ -36,7 +36,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up a D-Link Smart Plug.""" from pyW215.pyW215 import SmartPlug @@ -126,7 +125,7 @@ def update(self): self.data.update() -class SmartPlugData(object): +class SmartPlugData: """Get the latest data from smart plug.""" def __init__(self, smartplug): diff --git a/homeassistant/components/switch/doorbird.py b/homeassistant/components/switch/doorbird.py index 9886b3a586d04a..92ba3640237edb 100644 --- a/homeassistant/components/switch/doorbird.py +++ b/homeassistant/components/switch/doorbird.py @@ -4,10 +4,10 @@ import voluptuous as vol +import homeassistant.helpers.config_validation as cv from homeassistant.components.doorbird import DOMAIN as DOORBIRD_DOMAIN from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice from homeassistant.const import CONF_SWITCHES -import homeassistant.helpers.config_validation as cv DEPENDENCIES = ['doorbird'] @@ -15,7 +15,7 @@ SWITCHES = { "open_door": { - "name": "Open Door", + "name": "{} Open Door", "icon": { True: "lock-open", False: "lock" @@ -23,7 +23,7 @@ "time": datetime.timedelta(seconds=3) }, "open_door_2": { - "name": "Open Door 2", + "name": "{} Open Door 2", "icon": { True: "lock-open", False: "lock" @@ -31,7 +31,7 @@ "time": datetime.timedelta(seconds=3) }, "light_on": { - "name": "Light On", + "name": "{} Light On", "icon": { True: "lightbulb-on", False: "lightbulb" @@ -48,31 +48,36 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the DoorBird switch platform.""" - device = hass.data.get(DOORBIRD_DOMAIN) - switches = [] - for switch in SWITCHES: - _LOGGER.debug("Adding DoorBird switch %s", SWITCHES[switch]["name"]) - switches.append(DoorBirdSwitch(device, switch)) + + for doorstation in hass.data[DOORBIRD_DOMAIN]: + + device = doorstation.device + + for switch in SWITCHES: + + _LOGGER.debug("Adding DoorBird switch %s", + SWITCHES[switch]["name"].format(doorstation.name)) + switches.append(DoorBirdSwitch(device, switch, doorstation.name)) add_devices(switches) - _LOGGER.info("Added DoorBird switches") class DoorBirdSwitch(SwitchDevice): """A relay in a DoorBird device.""" - def __init__(self, device, switch): + def __init__(self, device, switch, name): """Initialize a relay in a DoorBird device.""" self._device = device self._switch = switch + self._name = name self._state = False self._assume_off = datetime.datetime.min @property def name(self): """Return the name of the switch.""" - return SWITCHES[self._switch]["name"] + return SWITCHES[self._switch]["name"].format(self._name) @property def icon(self): diff --git a/homeassistant/components/switch/edimax.py b/homeassistant/components/switch/edimax.py index 40ebb54b603074..9cd7c48488649d 100644 --- a/homeassistant/components/switch/edimax.py +++ b/homeassistant/components/switch/edimax.py @@ -29,7 +29,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Find and return Edimax Smart Plugs.""" from pyedimax.smartplug import SmartPlug diff --git a/homeassistant/components/switch/enocean.py b/homeassistant/components/switch/enocean.py index abe197485d404d..986744aeec11b6 100644 --- a/homeassistant/components/switch/enocean.py +++ b/homeassistant/components/switch/enocean.py @@ -18,10 +18,12 @@ DEFAULT_NAME = 'EnOcean Switch' DEPENDENCIES = ['enocean'] +CONF_CHANNEL = 'channel' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ID): vol.All(cv.ensure_list, [vol.Coerce(int)]), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_CHANNEL, default=0): cv.positive_int, }) @@ -29,14 +31,15 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the EnOcean switch platform.""" dev_id = config.get(CONF_ID) devname = config.get(CONF_NAME) + channel = config.get(CONF_CHANNEL) - add_devices([EnOceanSwitch(dev_id, devname)]) + add_devices([EnOceanSwitch(dev_id, devname, channel)]) class EnOceanSwitch(enocean.EnOceanDevice, ToggleEntity): """Representation of an EnOcean switch device.""" - def __init__(self, dev_id, devname): + def __init__(self, dev_id, devname, channel): """Initialize the EnOcean switch device.""" enocean.EnOceanDevice.__init__(self) self.dev_id = dev_id @@ -44,6 +47,7 @@ def __init__(self, dev_id, devname): self._light = None self._on_state = False self._on_state2 = False + self.channel = channel self.stype = "switch" @property @@ -61,7 +65,7 @@ def turn_on(self, **kwargs): optional = [0x03, ] optional.extend(self.dev_id) optional.extend([0xff, 0x00]) - self.send_command(data=[0xD2, 0x01, 0x00, 0x64, 0x00, + self.send_command(data=[0xD2, 0x01, self.channel & 0xFF, 0x64, 0x00, 0x00, 0x00, 0x00, 0x00], optional=optional, packet_type=0x01) self._on_state = True @@ -71,7 +75,7 @@ def turn_off(self, **kwargs): optional = [0x03, ] optional.extend(self.dev_id) optional.extend([0xff, 0x00]) - self.send_command(data=[0xD2, 0x01, 0x00, 0x00, 0x00, + self.send_command(data=[0xD2, 0x01, self.channel & 0xFF, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00], optional=optional, packet_type=0x01) self._on_state = False diff --git a/homeassistant/components/switch/eufy.py b/homeassistant/components/switch/eufy.py index 891525d3979157..7320ea8d5571db 100644 --- a/homeassistant/components/switch/eufy.py +++ b/homeassistant/components/switch/eufy.py @@ -25,7 +25,6 @@ class EufySwitch(SwitchDevice): def __init__(self, device): """Initialize the light.""" - # pylint: disable=import-error import lakeside self._state = None diff --git a/homeassistant/components/switch/flux.py b/homeassistant/components/switch/flux.py index e0bfdeee030f3c..7df8f0e1aa620c 100644 --- a/homeassistant/components/switch/flux.py +++ b/homeassistant/components/switch/flux.py @@ -72,7 +72,8 @@ def set_lights_xy(hass, lights, x_val, y_val, brightness, transition): turn_on(hass, light, xy_color=[x_val, y_val], brightness=brightness, - transition=transition) + transition=transition, + white_value=brightness) def set_lights_temp(hass, lights, mired, brightness, transition): @@ -94,7 +95,6 @@ def set_lights_rgb(hass, lights, rgb, transition): transition=transition) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Flux switches.""" name = config.get(CONF_NAME) @@ -218,7 +218,6 @@ def flux_update(self, now=None): else: sunset_time = sunset - # pylint: disable=no-member night_length = int(stop_time.timestamp() - sunset_time.timestamp()) seconds_from_sunset = int(now.timestamp() - diff --git a/homeassistant/components/switch/fritzbox.py b/homeassistant/components/switch/fritzbox.py old mode 100755 new mode 100644 diff --git a/homeassistant/components/switch/fritzdect.py b/homeassistant/components/switch/fritzdect.py index 58ad745a2d2dd8..9c0f852846a7c5 100644 --- a/homeassistant/components/switch/fritzdect.py +++ b/homeassistant/components/switch/fritzdect.py @@ -52,7 +52,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): fritz = FritzBox(host, username, password) try: fritz.login() - except Exception: # pylint: disable=W0703 + except Exception: # pylint: disable=broad-except _LOGGER.error("Login to Fritz!Box failed") return @@ -163,7 +163,7 @@ def update(self): self.data.is_online = False -class FritzDectSwitchData(object): +class FritzDectSwitchData: """Get the latest data from the fritz box.""" def __init__(self, fritz, ain): diff --git a/homeassistant/components/switch/gc100.py b/homeassistant/components/switch/gc100.py index f4175926aa0610..34a29483d3cceb 100644 --- a/homeassistant/components/switch/gc100.py +++ b/homeassistant/components/switch/gc100.py @@ -23,7 +23,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the GC100 devices.""" switches = [] @@ -40,7 +39,6 @@ class GC100Switch(ToggleEntity): def __init__(self, name, port_addr, gc100): """Initialize the GC100 switch.""" - # pylint: disable=no-member self._name = name or DEVICE_DEFAULT_NAME self._port_addr = port_addr self._gc100 = gc100 diff --git a/homeassistant/components/switch/homekit_controller.py b/homeassistant/components/switch/homekit_controller.py index 6b97200ba499a3..3293c8fe1953bb 100644 --- a/homeassistant/components/switch/homekit_controller.py +++ b/homeassistant/components/switch/homekit_controller.py @@ -4,7 +4,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.homekit_controller/ """ -import json import logging from homeassistant.components.homekit_controller import (HomeKitEntity, @@ -56,13 +55,11 @@ def turn_on(self, **kwargs): characteristics = [{'aid': self._aid, 'iid': self._chars['on'], 'value': True}] - body = json.dumps({'characteristics': characteristics}) - self._securecon.put('/characteristics', body) + self.put_characteristics(characteristics) def turn_off(self, **kwargs): """Turn the specified switch off.""" characteristics = [{'aid': self._aid, 'iid': self._chars['on'], 'value': False}] - body = json.dumps({'characteristics': characteristics}) - self._securecon.put('/characteristics', body) + self.put_characteristics(characteristics) diff --git a/homeassistant/components/switch/homematicip_cloud.py b/homeassistant/components/switch/homematicip_cloud.py new file mode 100644 index 00000000000000..68884aaaa02963 --- /dev/null +++ b/homeassistant/components/switch/homematicip_cloud.py @@ -0,0 +1,87 @@ +""" +Support for HomematicIP switch. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/switch.homematicip_cloud/ +""" + +import logging + +from homeassistant.components.switch import SwitchDevice +from homeassistant.components.homematicip_cloud import ( + HomematicipGenericDevice, DOMAIN as HMIPC_DOMAIN, + HMIPC_HAPID) + +DEPENDENCIES = ['homematicip_cloud'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_POWER_CONSUMPTION = 'power_consumption' +ATTR_ENERGIE_COUNTER = 'energie_counter' +ATTR_PROFILE_MODE = 'profile_mode' + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the HomematicIP switch devices.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_devices): + """Set up the HomematicIP switch from a config entry.""" + from homematicip.device import ( + PlugableSwitch, PlugableSwitchMeasuring, + BrandSwitchMeasuring) + + home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home + devices = [] + for device in home.devices: + if isinstance(device, BrandSwitchMeasuring): + # BrandSwitchMeasuring inherits PlugableSwitchMeasuring + # This device is implemented in the light platform and will + # not be added in the switch platform + pass + elif isinstance(device, PlugableSwitchMeasuring): + devices.append(HomematicipSwitchMeasuring(home, device)) + elif isinstance(device, PlugableSwitch): + devices.append(HomematicipSwitch(home, device)) + + if devices: + async_add_devices(devices) + + +class HomematicipSwitch(HomematicipGenericDevice, SwitchDevice): + """MomematicIP switch device.""" + + def __init__(self, home, device): + """Initialize the switch device.""" + super().__init__(home, device) + + @property + def is_on(self): + """Return true if device is on.""" + return self._device.on + + async def async_turn_on(self, **kwargs): + """Turn the device on.""" + await self._device.turn_on() + + async def async_turn_off(self, **kwargs): + """Turn the device off.""" + await self._device.turn_off() + + +class HomematicipSwitchMeasuring(HomematicipSwitch): + """MomematicIP measuring switch device.""" + + @property + def current_power_w(self): + """Return the current power usage in W.""" + return self._device.currentPowerConsumption + + @property + def today_energy_kwh(self): + """Return the today total energy usage in kWh.""" + if self._device.energyCounter is None: + return 0 + return round(self._device.energyCounter) diff --git a/homeassistant/components/switch/hydrawise.py b/homeassistant/components/switch/hydrawise.py new file mode 100644 index 00000000000000..d0abe5febf5549 --- /dev/null +++ b/homeassistant/components/switch/hydrawise.py @@ -0,0 +1,103 @@ +""" +Support for Hydrawise cloud. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.hydrawise/ +""" +import logging + +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.components.hydrawise import ( + ALLOWED_WATERING_TIME, CONF_WATERING_TIME, + DATA_HYDRAWISE, DEFAULT_WATERING_TIME, HydrawiseEntity, SWITCHES, + DEVICE_MAP, DEVICE_MAP_INDEX) +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice +from homeassistant.const import CONF_MONITORED_CONDITIONS + +DEPENDENCIES = ['hydrawise'] + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=SWITCHES): + vol.All(cv.ensure_list, [vol.In(SWITCHES)]), + vol.Optional(CONF_WATERING_TIME, default=DEFAULT_WATERING_TIME): + vol.All(vol.In(ALLOWED_WATERING_TIME)), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up a sensor for a Hydrawise device.""" + hydrawise = hass.data[DATA_HYDRAWISE].data + + default_watering_timer = config.get(CONF_WATERING_TIME) + + sensors = [] + for sensor_type in config.get(CONF_MONITORED_CONDITIONS): + # create a switch for each zone + for zone in hydrawise.relays: + sensors.append( + HydrawiseSwitch(default_watering_timer, + zone, + sensor_type)) + + add_devices(sensors, True) + + +class HydrawiseSwitch(HydrawiseEntity, SwitchDevice): + """A switch implementation for Hydrawise device.""" + + def __init__(self, default_watering_timer, *args): + """Initialize a switch for Hydrawise device.""" + super().__init__(*args) + self._default_watering_timer = default_watering_timer + + @property + def is_on(self): + """Return true if device is on.""" + return self._state + + def turn_on(self, **kwargs): + """Turn the device on.""" + if self._sensor_type == 'manual_watering': + self.hass.data[DATA_HYDRAWISE].data.run_zone( + self._default_watering_timer, (self.data['relay']-1)) + elif self._sensor_type == 'auto_watering': + self.hass.data[DATA_HYDRAWISE].data.suspend_zone( + 0, (self.data['relay']-1)) + + def turn_off(self, **kwargs): + """Turn the device off.""" + if self._sensor_type == 'manual_watering': + self.hass.data[DATA_HYDRAWISE].data.run_zone( + 0, (self.data['relay']-1)) + elif self._sensor_type == 'auto_watering': + self.hass.data[DATA_HYDRAWISE].data.suspend_zone( + 365, (self.data['relay']-1)) + + def update(self): + """Update device state.""" + mydata = self.hass.data[DATA_HYDRAWISE].data + _LOGGER.debug("Updating Hydrawise switch: %s", self._name) + if self._sensor_type == 'manual_watering': + if not mydata.running: + self._state = False + else: + self._state = int( + mydata.running[0]['relay']) == self.data['relay'] + elif self._sensor_type == 'auto_watering': + for relay in mydata.relays: + if relay['relay'] == self.data['relay']: + if relay.get('suspended') is not None: + self._state = False + else: + self._state = True + break + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return DEVICE_MAP[self._sensor_type][ + DEVICE_MAP_INDEX.index('ICON_INDEX')] diff --git a/homeassistant/components/switch/ihc.py b/homeassistant/components/switch/ihc.py index 499a4ca53a7195..3f461784693076 100644 --- a/homeassistant/components/switch/ihc.py +++ b/homeassistant/components/switch/ihc.py @@ -3,8 +3,6 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.ihc/ """ -from xml.etree.ElementTree import Element - import voluptuous as vol from homeassistant.components.ihc import ( @@ -53,7 +51,7 @@ class IHCSwitch(IHCDevice, SwitchDevice): """IHC Switch.""" def __init__(self, ihc_controller, name: str, ihc_id: int, - info: bool, product: Element = None) -> None: + info: bool, product=None) -> None: """Initialize the IHC switch.""" super().__init__(ihc_controller, name, ihc_id, product) self._state = False diff --git a/homeassistant/components/switch/insteon_local.py b/homeassistant/components/switch/insteon_local.py index 4456436ea61aa9..c4c8a8546706c4 100644 --- a/homeassistant/components/switch/insteon_local.py +++ b/homeassistant/components/switch/insteon_local.py @@ -8,7 +8,7 @@ from datetime import timedelta from homeassistant.components.switch import SwitchDevice -import homeassistant.util as util +from homeassistant import util _CONFIGURING = {} _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/insteon_plm.py b/homeassistant/components/switch/insteon_plm.py index be562e9d909d67..c357d1ccc0418e 100644 --- a/homeassistant/components/switch/insteon_plm.py +++ b/homeassistant/components/switch/insteon_plm.py @@ -30,7 +30,8 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): device.address.hex, device.states[state_key].name) new_entity = None - if state_name in ['lightOnOff', 'outletTopOnOff', 'outletBottomOnOff']: + if state_name in ['lightOnOff', 'outletTopOnOff', 'outletBottomOnOff', + 'x10OnOffSwitch']: new_entity = InsteonPLMSwitchDevice(device, state_key) elif state_name == 'openClosedRelay': new_entity = InsteonPLMOpenClosedDevice(device, state_key) @@ -45,8 +46,7 @@ class InsteonPLMSwitchDevice(InsteonPLMEntity, SwitchDevice): @property def is_on(self): """Return the boolean response if the node is on.""" - onlevel = self._insteon_device_state.value - return bool(onlevel) + return bool(self._insteon_device_state.value) @asyncio.coroutine def async_turn_on(self, **kwargs): @@ -62,6 +62,11 @@ def async_turn_off(self, **kwargs): class InsteonPLMOpenClosedDevice(InsteonPLMEntity, SwitchDevice): """A Class for an Insteon device.""" + @property + def is_on(self): + """Return the boolean response if the node is on.""" + return bool(self._insteon_device_state.value) + @asyncio.coroutine def async_turn_on(self, **kwargs): """Turn device on.""" diff --git a/homeassistant/components/switch/isy994.py b/homeassistant/components/switch/isy994.py index efdda6ed40cb79..2a7dee87747db7 100644 --- a/homeassistant/components/switch/isy994.py +++ b/homeassistant/components/switch/isy994.py @@ -5,17 +5,16 @@ https://home-assistant.io/components/switch.isy994/ """ import logging -from typing import Callable # noqa +from typing import Callable from homeassistant.components.switch import SwitchDevice, DOMAIN from homeassistant.components.isy994 import (ISY994_NODES, ISY994_PROGRAMS, ISYDevice) -from homeassistant.helpers.typing import ConfigType # noqa +from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) -# pylint: disable=unused-argument def setup_platform(hass, config: ConfigType, add_devices: Callable[[list], None], discovery_info=None): """Set up the ISY994 switch platform.""" diff --git a/homeassistant/components/switch/kankun.py b/homeassistant/components/switch/kankun.py index 88a07b68cd94cb..c830e2299f6ce4 100644 --- a/homeassistant/components/switch/kankun.py +++ b/homeassistant/components/switch/kankun.py @@ -34,7 +34,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up Kankun Wifi switches.""" switches = config.get('switches', {}) diff --git a/homeassistant/components/switch/knx.py b/homeassistant/components/switch/knx.py index a96f96a9c5c242..c13631ca5e67c2 100644 --- a/homeassistant/components/switch/knx.py +++ b/homeassistant/components/switch/knx.py @@ -72,7 +72,6 @@ def async_register_callbacks(self): """Register callbacks to update hass after device was changed.""" async def after_update_callback(device): """Call after device was updated.""" - # pylint: disable=unused-argument await self.async_update_ha_state() self.device.register_device_updated_cb(after_update_callback) diff --git a/homeassistant/components/switch/konnected.py b/homeassistant/components/switch/konnected.py new file mode 100644 index 00000000000000..53c6406b28a896 --- /dev/null +++ b/homeassistant/components/switch/konnected.py @@ -0,0 +1,94 @@ +""" +Support for wired switches attached to a Konnected device. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.konnected/ +""" + +import logging + +from homeassistant.components.konnected import ( + DOMAIN as KONNECTED_DOMAIN, PIN_TO_ZONE, CONF_ACTIVATION, + STATE_LOW, STATE_HIGH) +from homeassistant.helpers.entity import ToggleEntity +from homeassistant.const import (CONF_DEVICES, CONF_SWITCHES, ATTR_STATE) + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['konnected'] + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set switches attached to a Konnected device.""" + if discovery_info is None: + return + + data = hass.data[KONNECTED_DOMAIN] + device_id = discovery_info['device_id'] + client = data[CONF_DEVICES][device_id]['client'] + switches = [KonnectedSwitch(device_id, pin_num, pin_data, client) + for pin_num, pin_data in + data[CONF_DEVICES][device_id][CONF_SWITCHES].items()] + async_add_devices(switches) + + +class KonnectedSwitch(ToggleEntity): + """Representation of a Konnected switch.""" + + def __init__(self, device_id, pin_num, data, client): + """Initialize the switch.""" + self._data = data + self._device_id = device_id + self._pin_num = pin_num + self._activation = self._data.get(CONF_ACTIVATION, STATE_HIGH) + self._state = self._boolean_state(self._data.get(ATTR_STATE)) + self._name = self._data.get( + 'name', 'Konnected {} Actuator {}'.format( + device_id, PIN_TO_ZONE[pin_num])) + self._client = client + _LOGGER.debug('Created new switch: %s', self._name) + + @property + def name(self): + """Return the name of the switch.""" + return self._name + + @property + def is_on(self): + """Return the status of the sensor.""" + return self._state + + def turn_on(self, **kwargs): + """Send a command to turn on the switch.""" + resp = self._client.put_device( + self._pin_num, int(self._activation == STATE_HIGH)) + + if resp.get(ATTR_STATE) is not None: + self._set_state(self._boolean_state(resp.get(ATTR_STATE))) + + def turn_off(self, **kwargs): + """Send a command to turn off the switch.""" + resp = self._client.put_device( + self._pin_num, int(self._activation == STATE_LOW)) + + if resp.get(ATTR_STATE) is not None: + self._set_state(self._boolean_state(resp.get(ATTR_STATE))) + + def _boolean_state(self, int_state): + if int_state is None: + return False + if int_state == 0: + return self._activation == STATE_LOW + if int_state == 1: + return self._activation == STATE_HIGH + + def _set_state(self, state): + self._state = state + self.schedule_update_ha_state() + _LOGGER.debug('Setting status of %s actuator pin %s to %s', + self._device_id, self.name, state) + + async def async_added_to_hass(self): + """Store entity_id.""" + self._data['entity_id'] = self.entity_id diff --git a/homeassistant/components/switch/linode.py b/homeassistant/components/switch/linode.py index 91177e321169ab..43f4bdc31b4b72 100644 --- a/homeassistant/components/switch/linode.py +++ b/homeassistant/components/switch/linode.py @@ -51,35 +51,23 @@ def __init__(self, li, node_id): self._node_id = node_id self.data = None self._state = None + self._attrs = {} + self._name = None @property def name(self): """Return the name of the switch.""" - if self.data is not None: - return self.data.label + return self._name @property def is_on(self): """Return true if switch is on.""" - if self.data is not None: - return self.data.status == 'running' - return False + return self._state @property def device_state_attributes(self): """Return the state attributes of the Linode Node.""" - if self.data: - return { - ATTR_CREATED: self.data.created, - ATTR_NODE_ID: self.data.id, - ATTR_NODE_NAME: self.data.label, - ATTR_IPV4_ADDRESS: self.data.ipv4, - ATTR_IPV6_ADDRESS: self.data.ipv6, - ATTR_MEMORY: self.data.specs.memory, - ATTR_REGION: self.data.region.country, - ATTR_VCPUS: self.data.specs.vcpus, - } - return {} + return self._attrs def turn_on(self, **kwargs): """Boot-up the Node.""" @@ -98,3 +86,16 @@ def update(self): for node in self._linode.data: if node.id == self._node_id: self.data = node + if self.data is not None: + self._state = self.data.status == 'running' + self._attrs = { + ATTR_CREATED: self.data.created, + ATTR_NODE_ID: self.data.id, + ATTR_NODE_NAME: self.data.label, + ATTR_IPV4_ADDRESS: self.data.ipv4, + ATTR_IPV6_ADDRESS: self.data.ipv6, + ATTR_MEMORY: self.data.specs.memory, + ATTR_REGION: self.data.region.country, + ATTR_VCPUS: self.data.specs.vcpus, + } + self._name = self.data.label diff --git a/homeassistant/components/switch/litejet.py b/homeassistant/components/switch/litejet.py index 1e7c46733ad431..79ef4a5fd7f4f9 100644 --- a/homeassistant/components/switch/litejet.py +++ b/homeassistant/components/switch/litejet.py @@ -6,7 +6,7 @@ """ import logging -import homeassistant.components.litejet as litejet +from homeassistant.components import litejet from homeassistant.components.switch import SwitchDevice DEPENDENCIES = ['litejet'] diff --git a/homeassistant/components/switch/lutron_caseta.py b/homeassistant/components/switch/lutron_caseta.py index da36c76f41dbce..f5e7cf2836f892 100644 --- a/homeassistant/components/switch/lutron_caseta.py +++ b/homeassistant/components/switch/lutron_caseta.py @@ -16,7 +16,6 @@ DEPENDENCIES = ['lutron_caseta'] -# pylint: disable=unused-argument @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up Lutron switch.""" diff --git a/homeassistant/components/switch/mfi.py b/homeassistant/components/switch/mfi.py index c0dc72440d3308..2c547fa210f1b2 100644 --- a/homeassistant/components/switch/mfi.py +++ b/homeassistant/components/switch/mfi.py @@ -39,7 +39,6 @@ }) -# pylint: disable=unused-variable def setup_platform(hass, config, add_devices, discovery_info=None): """Set up mFi sensors.""" host = config.get(CONF_HOST) diff --git a/homeassistant/components/switch/modbus.py b/homeassistant/components/switch/modbus.py index ca70c212774067..94e1d7ea6d63d9 100644 --- a/homeassistant/components/switch/modbus.py +++ b/homeassistant/components/switch/modbus.py @@ -7,7 +7,7 @@ import logging import voluptuous as vol -import homeassistant.components.modbus as modbus +from homeassistant.components import modbus from homeassistant.const import ( CONF_NAME, CONF_SLAVE, CONF_COMMAND_ON, CONF_COMMAND_OFF) from homeassistant.helpers.entity import ToggleEntity diff --git a/homeassistant/components/switch/mqtt.py b/homeassistant/components/switch/mqtt.py index 69f12536c5f9a0..eb91f8d846ae12 100644 --- a/homeassistant/components/switch/mqtt.py +++ b/homeassistant/components/switch/mqtt.py @@ -5,6 +5,7 @@ https://home-assistant.io/components/switch.mqtt/ """ import logging +from typing import Optional import voluptuous as vol @@ -17,7 +18,7 @@ from homeassistant.const import ( CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE, CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, CONF_ICON, STATE_ON) -import homeassistant.components.mqtt as mqtt +from homeassistant.components import mqtt import homeassistant.helpers.config_validation as cv from homeassistant.helpers.restore_state import async_get_last_state @@ -29,12 +30,18 @@ DEFAULT_PAYLOAD_ON = 'ON' DEFAULT_PAYLOAD_OFF = 'OFF' DEFAULT_OPTIMISTIC = False +CONF_UNIQUE_ID = 'unique_id' +CONF_STATE_ON = "state_on" +CONF_STATE_OFF = "state_off" PLATFORM_SCHEMA = mqtt.MQTT_RW_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_ICON): cv.icon, vol.Optional(CONF_PAYLOAD_ON, default=DEFAULT_PAYLOAD_ON): cv.string, vol.Optional(CONF_PAYLOAD_OFF, default=DEFAULT_PAYLOAD_OFF): cv.string, + vol.Optional(CONF_STATE_ON): cv.string, + vol.Optional(CONF_STATE_OFF): cv.string, + vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_OPTIMISTIC, default=DEFAULT_OPTIMISTIC): cv.boolean, }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema) @@ -59,9 +66,12 @@ async def async_setup_platform(hass, config, async_add_devices, config.get(CONF_RETAIN), config.get(CONF_PAYLOAD_ON), config.get(CONF_PAYLOAD_OFF), + config.get(CONF_STATE_ON), + config.get(CONF_STATE_OFF), config.get(CONF_OPTIMISTIC), config.get(CONF_PAYLOAD_AVAILABLE), config.get(CONF_PAYLOAD_NOT_AVAILABLE), + config.get(CONF_UNIQUE_ID), value_template, )]) @@ -71,8 +81,10 @@ class MqttSwitch(MqttAvailability, SwitchDevice): def __init__(self, name, icon, state_topic, command_topic, availability_topic, - qos, retain, payload_on, payload_off, optimistic, - payload_available, payload_not_available, value_template): + qos, retain, payload_on, payload_off, state_on, + state_off, optimistic, payload_available, + payload_not_available, unique_id: Optional[str], + value_template): """Initialize the MQTT switch.""" super().__init__(availability_topic, qos, payload_available, payload_not_available) @@ -85,8 +97,11 @@ def __init__(self, name, icon, self._retain = retain self._payload_on = payload_on self._payload_off = payload_off + self._state_on = state_on if state_on else self._payload_on + self._state_off = state_off if state_off else self._payload_off self._optimistic = optimistic self._template = value_template + self._unique_id = unique_id async def async_added_to_hass(self): """Subscribe to MQTT events.""" @@ -98,9 +113,9 @@ def state_message_received(topic, payload, qos): if self._template is not None: payload = self._template.async_render_with_possible_json_value( payload) - if payload == self._payload_on: + if payload == self._state_on: self._state = True - elif payload == self._payload_off: + elif payload == self._state_off: self._state = False self.async_schedule_update_ha_state() @@ -139,6 +154,11 @@ def assumed_state(self): """Return true if we do optimistic updates.""" return self._optimistic + @property + def unique_id(self): + """Return a unique ID.""" + return self._unique_id + @property def icon(self): """Return the icon.""" diff --git a/homeassistant/components/switch/mysensors.py b/homeassistant/components/switch/mysensors.py index c0f45cad86123c..340eed83b567e8 100644 --- a/homeassistant/components/switch/mysensors.py +++ b/homeassistant/components/switch/mysensors.py @@ -42,7 +42,7 @@ async def async_setup_platform( hass, DOMAIN, discovery_info, device_class_map, async_add_devices=async_add_devices) - def send_ir_code_service(service): + async def async_send_ir_code_service(service): """Set IR code as device state attribute.""" entity_ids = service.data.get(ATTR_ENTITY_ID) ir_code = service.data.get(ATTR_IR_CODE) @@ -58,14 +58,14 @@ def send_ir_code_service(service): kwargs = {ATTR_IR_CODE: ir_code} for device in _devices: - device.turn_on(**kwargs) + await device.async_turn_on(**kwargs) hass.services.async_register( - DOMAIN, SERVICE_SEND_IR_CODE, send_ir_code_service, + DOMAIN, SERVICE_SEND_IR_CODE, async_send_ir_code_service, schema=SEND_IR_CODE_SERVICE_SCHEMA) -class MySensorsSwitch(mysensors.MySensorsEntity, SwitchDevice): +class MySensorsSwitch(mysensors.device.MySensorsEntity, SwitchDevice): """Representation of the value of a MySensors Switch child node.""" @property @@ -84,23 +84,23 @@ def is_on(self): """Return True if switch is on.""" return self._values.get(self.value_type) == STATE_ON - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the switch on.""" self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, 1) if self.gateway.optimistic: # optimistically assume that switch has changed state self._values[self.value_type] = STATE_ON - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the switch off.""" self.gateway.set_child_value( self.node_id, self.child_id, self.value_type, 0) if self.gateway.optimistic: # optimistically assume that switch has changed state self._values[self.value_type] = STATE_OFF - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() class MySensorsIRSwitch(MySensorsSwitch): @@ -117,7 +117,7 @@ def is_on(self): set_req = self.gateway.const.SetReq return self._values.get(set_req.V_LIGHT) == STATE_ON - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the IR switch on.""" set_req = self.gateway.const.SetReq if ATTR_IR_CODE in kwargs: @@ -130,11 +130,11 @@ def turn_on(self, **kwargs): # optimistically assume that switch has changed state self._values[self.value_type] = self._ir_code self._values[set_req.V_LIGHT] = STATE_ON - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() # turn off switch after switch was turned on - self.turn_off() + await self.async_turn_off() - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the IR switch off.""" set_req = self.gateway.const.SetReq self.gateway.set_child_value( @@ -142,7 +142,7 @@ def turn_off(self, **kwargs): if self.gateway.optimistic: # optimistically assume that switch has changed state self._values[set_req.V_LIGHT] = STATE_OFF - self.schedule_update_ha_state() + self.async_schedule_update_ha_state() async def async_update(self): """Update the controller with the latest value from a sensor.""" diff --git a/homeassistant/components/switch/mystrom.py b/homeassistant/components/switch/mystrom.py index 0a87d41d2fefa1..85fc546d00e591 100644 --- a/homeassistant/components/switch/mystrom.py +++ b/homeassistant/components/switch/mystrom.py @@ -12,7 +12,7 @@ from homeassistant.const import (CONF_NAME, CONF_HOST) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-mystrom==0.4.2'] +REQUIREMENTS = ['python-mystrom==0.4.4'] DEFAULT_NAME = 'myStrom Switch' @@ -34,8 +34,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): try: MyStromPlug(host).get_status() except exceptions.MyStromConnectionError: - _LOGGER.error("No route to device '%s'", host) - return False + _LOGGER.error("No route to device: %s", host) + return add_devices([MyStromSwitch(name, host)]) @@ -74,8 +74,7 @@ def turn_on(self, **kwargs): try: self.plug.set_relay_on() except exceptions.MyStromConnectionError: - _LOGGER.error("No route to device '%s'. Is device offline?", - self._resource) + _LOGGER.error("No route to device: %s", self._resource) def turn_off(self, **kwargs): """Turn the switch off.""" @@ -83,8 +82,7 @@ def turn_off(self, **kwargs): try: self.plug.set_relay_off() except exceptions.MyStromConnectionError: - _LOGGER.error("No route to device '%s'. Is device offline?", - self._resource) + _LOGGER.error("No route to device: %s", self._resource) def update(self): """Get the latest data from the device and update the data.""" @@ -93,5 +91,4 @@ def update(self): self.data = self.plug.get_status() except exceptions.MyStromConnectionError: self.data = {'power': 0, 'relay': False} - _LOGGER.error("No route to device '%s'. Is device offline?", - self._resource) + _LOGGER.error("No route to device: %s", self._resource) diff --git a/homeassistant/components/switch/neato.py b/homeassistant/components/switch/neato.py index a797abb47fcf9d..34dad9bb5818ad 100644 --- a/homeassistant/components/switch/neato.py +++ b/homeassistant/components/switch/neato.py @@ -5,6 +5,7 @@ https://home-assistant.io/components/switch.neato/ """ import logging +from datetime import timedelta import requests from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.helpers.entity import ToggleEntity @@ -14,6 +15,8 @@ DEPENDENCIES = ['neato'] +SCAN_INTERVAL = timedelta(minutes=10) + SWITCH_TYPE_SCHEDULE = 'schedule' SWITCH_TYPES = { @@ -64,7 +67,7 @@ def update(self): _LOGGER.debug('self._state=%s', self._state) if self.type == SWITCH_TYPE_SCHEDULE: _LOGGER.debug("State: %s", self._state) - if self.robot.schedule_enabled: + if self._state['details']['isScheduleEnabled']: self._schedule_state = STATE_ON else: self._schedule_state = STATE_OFF diff --git a/homeassistant/components/switch/orvibo.py b/homeassistant/components/switch/orvibo.py index e039a29809d4ed..fdb4752f594432 100644 --- a/homeassistant/components/switch/orvibo.py +++ b/homeassistant/components/switch/orvibo.py @@ -31,7 +31,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up S20 switches.""" from orvibo.s20 import discover, S20, S20Exception diff --git a/homeassistant/components/switch/pilight.py b/homeassistant/components/switch/pilight.py index 57fa4b00c98733..7ffce13ff6afd6 100644 --- a/homeassistant/components/switch/pilight.py +++ b/homeassistant/components/switch/pilight.py @@ -10,7 +10,7 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv -import homeassistant.components.pilight as pilight +from homeassistant.components import pilight from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import (CONF_NAME, CONF_ID, CONF_SWITCHES, CONF_STATE, CONF_PROTOCOL, STATE_ON) @@ -80,7 +80,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): add_devices(devices) -class _ReceiveHandle(object): +class _ReceiveHandle: def __init__(self, config, echo): """Initialize the handle.""" self.config_items = config.items() diff --git a/homeassistant/components/switch/pulseaudio_loopback.py b/homeassistant/components/switch/pulseaudio_loopback.py index 007e74e14fd37c..06f2ee5f550cb1 100644 --- a/homeassistant/components/switch/pulseaudio_loopback.py +++ b/homeassistant/components/switch/pulseaudio_loopback.py @@ -11,7 +11,7 @@ import voluptuous as vol -import homeassistant.util as util +from homeassistant import util from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import (CONF_NAME, CONF_HOST, CONF_PORT) import homeassistant.helpers.config_validation as cv @@ -54,7 +54,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Read in all of our configuration, and initialize the loopback switch.""" name = config.get(CONF_NAME) diff --git a/homeassistant/components/switch/rachio.py b/homeassistant/components/switch/rachio.py index dc661c3e5bfb10..5f0ca995c90f20 100644 --- a/homeassistant/components/switch/rachio.py +++ b/homeassistant/components/switch/rachio.py @@ -4,227 +4,239 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.rachio/ """ +from abc import abstractmethod from datetime import timedelta import logging - import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice -from homeassistant.const import CONF_ACCESS_TOKEN +from homeassistant.components.rachio import (DOMAIN as DOMAIN_RACHIO, + KEY_DEVICE_ID, + KEY_ENABLED, + KEY_ID, + KEY_NAME, + KEY_ON, + KEY_SUBTYPE, + KEY_SUMMARY, + KEY_ZONE_ID, + KEY_ZONE_NUMBER, + SIGNAL_RACHIO_CONTROLLER_UPDATE, + SIGNAL_RACHIO_ZONE_UPDATE, + SUBTYPE_ZONE_STARTED, + SUBTYPE_ZONE_STOPPED, + SUBTYPE_ZONE_COMPLETED, + SUBTYPE_SLEEP_MODE_ON, + SUBTYPE_SLEEP_MODE_OFF) import homeassistant.helpers.config_validation as cv -import homeassistant.util as util +from homeassistant.helpers.dispatcher import dispatcher_connect -REQUIREMENTS = ['rachiopy==0.1.2'] +DEPENDENCIES = ['rachio'] _LOGGER = logging.getLogger(__name__) +# Manual run length CONF_MANUAL_RUN_MINS = 'manual_run_mins' - -DATA_RACHIO = 'rachio' - DEFAULT_MANUAL_RUN_MINS = 10 -MIN_UPDATE_INTERVAL = timedelta(seconds=30) -MIN_FORCED_UPDATE_INTERVAL = timedelta(seconds=1) - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_ACCESS_TOKEN): cv.string, vol.Optional(CONF_MANUAL_RUN_MINS, default=DEFAULT_MANUAL_RUN_MINS): cv.positive_int, }) +ATTR_ZONE_SUMMARY = 'Summary' +ATTR_ZONE_NUMBER = 'Zone number' + def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Rachio switches.""" - from rachiopy import Rachio + manual_run_time = timedelta(minutes=config.get(CONF_MANUAL_RUN_MINS)) + _LOGGER.info("Rachio run time is %s", str(manual_run_time)) - # Get options - manual_run_mins = config.get(CONF_MANUAL_RUN_MINS) - _LOGGER.debug("Rachio run time is %d min", manual_run_mins) + # Add all zones from all controllers as switches + devices = [] + for controller in hass.data[DOMAIN_RACHIO].controllers: + devices.append(RachioStandbySwitch(hass, controller)) - access_token = config.get(CONF_ACCESS_TOKEN) + for zone in controller.list_zones(): + devices.append(RachioZone(hass, controller, zone, manual_run_time)) - # Configure API - _LOGGER.debug("Configuring Rachio API") - rachio = Rachio(access_token) + add_devices(devices) + _LOGGER.info("%d Rachio switch(es) added", len(devices)) - person = None - try: - person = _get_person(rachio) - except KeyError: - _LOGGER.error( - "Could not reach the Rachio API. Is your access token valid?") - return - # Get and persist devices - devices = _list_devices(rachio, manual_run_mins) - if not devices: - _LOGGER.error( - "No Rachio devices found in account %s", person['username']) - return +class RachioSwitch(SwitchDevice): + """Represent a Rachio state that can be toggled.""" - hass.data[DATA_RACHIO] = devices[0] + def __init__(self, controller, poll=True): + """Initialize a new Rachio switch.""" + self._controller = controller - if len(devices) > 1: - _LOGGER.warning("Multiple Rachio devices found in account, " - "using %s", hass.data[DATA_RACHIO].device_id) - else: - _LOGGER.debug("Found Rachio device") + if poll: + self._state = self._poll_update() + else: + self._state = None - hass.data[DATA_RACHIO].update() - add_devices(hass.data[DATA_RACHIO].list_zones()) + @property + def should_poll(self) -> bool: + """Declare that this entity pushes its state to HA.""" + return False + @property + def name(self) -> str: + """Get a name for this switch.""" + return "Switch on {}".format(self._controller.name) -def _get_person(rachio): - """Pull the account info of the person whose access token was provided.""" - person_id = rachio.person.getInfo()[1]['id'] - return rachio.person.get(person_id)[1] + @property + def is_on(self) -> bool: + """Return whether the switch is currently on.""" + return self._state + @abstractmethod + def _poll_update(self, data=None) -> bool: + """Poll the API.""" + pass -def _list_devices(rachio, manual_run_mins): - """Pull a list of devices on the account.""" - return [RachioIro(rachio, d['id'], manual_run_mins) - for d in _get_person(rachio)['devices']] + def _handle_any_update(self, *args, **kwargs) -> None: + """Determine whether an update event applies to this device.""" + if args[0][KEY_DEVICE_ID] != self._controller.controller_id: + # For another device + return + # For this device + self._handle_update(args, kwargs) -class RachioIro(object): - """Representation of a Rachio Iro.""" + @abstractmethod + def _handle_update(self, *args, **kwargs) -> None: + """Handle incoming webhook data.""" + pass - def __init__(self, rachio, device_id, manual_run_mins): - """Initialize a Rachio device.""" - self.rachio = rachio - self._device_id = device_id - self.manual_run_mins = manual_run_mins - self._device = None - self._running = None - self._zones = None - def __str__(self): - """Display the device as a string.""" - return "Rachio Iro {}".format(self.serial_number) +class RachioStandbySwitch(RachioSwitch): + """Representation of a standby status/button.""" - @property - def device_id(self): - """Return the Rachio API device ID.""" - return self._device['id'] + def __init__(self, hass, controller): + """Instantiate a new Rachio standby mode switch.""" + dispatcher_connect(hass, SIGNAL_RACHIO_CONTROLLER_UPDATE, + self._handle_any_update) + super().__init__(controller, poll=False) + self._poll_update(controller.init_data) @property - def status(self): - """Return the current status of the device.""" - return self._device['status'] + def name(self) -> str: + """Return the name of the standby switch.""" + return "{} in standby mode".format(self._controller.name) @property - def serial_number(self): - """Return the serial number of the device.""" - return self._device['serialNumber'] + def icon(self) -> str: + """Return an icon for the standby switch.""" + return "mdi:power" - @property - def is_paused(self): - """Return whether the device is temporarily disabled.""" - return self._device['paused'] + def _poll_update(self, data=None) -> bool: + """Request the state from the API.""" + if data is None: + data = self._controller.rachio.device.get( + self._controller.controller_id)[1] - @property - def is_on(self): - """Return whether the device is powered on and connected.""" - return self._device['on'] + return not data[KEY_ON] - @property - def current_schedule(self): - """Return the schedule that the device is running right now.""" - return self._running + def _handle_update(self, *args, **kwargs) -> None: + """Update the state using webhook data.""" + if args[0][KEY_SUBTYPE] == SUBTYPE_SLEEP_MODE_ON: + self._state = True + elif args[0][KEY_SUBTYPE] == SUBTYPE_SLEEP_MODE_OFF: + self._state = False - def list_zones(self, include_disabled=False): - """Return a list of the zones connected to the device, incl. data.""" - if not self._zones: - self._zones = [RachioZone(self.rachio, self, zone['id'], - self.manual_run_mins) - for zone in self._device['zones']] + self.schedule_update_ha_state() - if include_disabled: - return self._zones + def turn_on(self, **kwargs) -> None: + """Put the controller in standby mode.""" + self._controller.rachio.device.off(self._controller.controller_id) - self.update(no_throttle=True) - return [z for z in self._zones if z.is_enabled] + def turn_off(self, **kwargs) -> None: + """Resume controller functionality.""" + self._controller.rachio.device.on(self._controller.controller_id) - @util.Throttle(MIN_UPDATE_INTERVAL, MIN_FORCED_UPDATE_INTERVAL) - def update(self, **kwargs): - """Pull updated device info from the Rachio API.""" - self._device = self.rachio.device.get(self._device_id)[1] - self._running = self.rachio.device\ - .getCurrentSchedule(self._device_id)[1] - # Possibly update all zones - for zone in self.list_zones(include_disabled=True): - zone.update() - - _LOGGER.debug("Updated %s", str(self)) - - -class RachioZone(SwitchDevice): +class RachioZone(RachioSwitch): """Representation of one zone of sprinklers connected to the Rachio Iro.""" - def __init__(self, rachio, device, zone_id, manual_run_mins): + def __init__(self, hass, controller, data, manual_run_time): """Initialize a new Rachio Zone.""" - self.rachio = rachio - self._device = device - self._zone_id = zone_id - self._zone = None - self._manual_run_secs = manual_run_mins * 60 + self._id = data[KEY_ID] + self._zone_name = data[KEY_NAME] + self._zone_number = data[KEY_ZONE_NUMBER] + self._zone_enabled = data[KEY_ENABLED] + self._manual_run_time = manual_run_time + self._summary = str() + super().__init__(controller) + + # Listen for all zone updates + dispatcher_connect(hass, SIGNAL_RACHIO_ZONE_UPDATE, + self._handle_update) def __str__(self): """Display the zone as a string.""" - return "Rachio Zone {}".format(self.name) + return 'Rachio Zone "{}" on {}'.format(self.name, + str(self._controller)) @property - def zone_id(self): + def zone_id(self) -> str: """How the Rachio API refers to the zone.""" - return self._zone['id'] + return self._id @property - def unique_id(self): - """Return the unique string ID for the zone.""" - return '{iro}-{zone}'.format( - iro=self._device.device_id, zone=self.zone_id) - - @property - def number(self): - """Return the physical connection of the zone pump.""" - return self._zone['zoneNumber'] + def name(self) -> str: + """Return the friendly name of the zone.""" + return self._zone_name @property - def name(self): - """Return the friendly name of the zone.""" - return self._zone['name'] + def icon(self) -> str: + """Return the icon to display.""" + return "mdi:water" @property - def is_enabled(self): + def zone_is_enabled(self) -> bool: """Return whether the zone is allowed to run.""" - return self._zone['enabled'] + return self._zone_enabled @property - def is_on(self): - """Return whether the zone is currently running.""" - schedule = self._device.current_schedule - return self.zone_id == schedule.get('zoneId') + def state_attributes(self) -> dict: + """Return the optional state attributes.""" + return { + ATTR_ZONE_NUMBER: self._zone_number, + ATTR_ZONE_SUMMARY: self._summary, + } + + def turn_on(self, **kwargs) -> None: + """Start watering this zone.""" + # Stop other zones first + self.turn_off() - def update(self): - """Pull updated zone info from the Rachio API.""" - self._zone = self.rachio.zone.get(self._zone_id)[1] + # Start this zone + self._controller.rachio.zone.start(self.zone_id, + self._manual_run_time.seconds) + _LOGGER.debug("Watering %s on %s", self.name, self._controller.name) - # Possibly update device - self._device.update() + def turn_off(self, **kwargs) -> None: + """Stop watering all zones.""" + self._controller.stop_watering() - _LOGGER.debug("Updated %s", str(self)) + def _poll_update(self, data=None) -> bool: + """Poll the API to check whether the zone is running.""" + schedule = self._controller.current_schedule + return self.zone_id == schedule.get(KEY_ZONE_ID) - def turn_on(self, **kwargs): - """Start the zone.""" - # Stop other zones first - self.turn_off() + def _handle_update(self, *args, **kwargs) -> None: + """Handle incoming webhook zone data.""" + if args[0][KEY_ZONE_ID] != self.zone_id: + return + + self._summary = kwargs.get(KEY_SUMMARY, str()) - _LOGGER.info("Watering %s for %d s", self.name, self._manual_run_secs) - self.rachio.zone.start(self.zone_id, self._manual_run_secs) + if args[0][KEY_SUBTYPE] == SUBTYPE_ZONE_STARTED: + self._state = True + elif args[0][KEY_SUBTYPE] in [SUBTYPE_ZONE_STOPPED, + SUBTYPE_ZONE_COMPLETED]: + self._state = False - def turn_off(self, **kwargs): - """Stop all zones.""" - _LOGGER.info("Stopping watering of all zones") - self.rachio.device.stopWater(self._device.device_id) + self.schedule_update_ha_state() diff --git a/homeassistant/components/switch/raincloud.py b/homeassistant/components/switch/raincloud.py index 8a5c4347cf7c08..a4bac8fee1cd46 100644 --- a/homeassistant/components/switch/raincloud.py +++ b/homeassistant/components/switch/raincloud.py @@ -88,7 +88,6 @@ def device_state_attributes(self): """Return the state attributes.""" return { ATTR_ATTRIBUTION: CONF_ATTRIBUTION, - 'current_time': self.data.current_time, 'default_manual_timer': self._default_watering_timer, 'identifier': self.data.serial } diff --git a/homeassistant/components/switch/rainmachine.py b/homeassistant/components/switch/rainmachine.py index 8306b3233305ca..b0cdf334cfa127 100644 --- a/homeassistant/components/switch/rainmachine.py +++ b/homeassistant/components/switch/rainmachine.py @@ -1,26 +1,107 @@ -"""Implements a RainMachine sprinkler controller for Home Assistant.""" +""" +This component provides support for RainMachine programs and zones. -from logging import getLogger +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/switch.rainmachine/ +""" +import logging from homeassistant.components.rainmachine import ( - CONF_ZONE_RUN_TIME, DATA_RAINMACHINE, DEFAULT_ATTRIBUTION, MIN_SCAN_TIME, - MIN_SCAN_TIME_FORCED) + CONF_ZONE_RUN_TIME, DATA_RAINMACHINE, DEFAULT_ZONE_RUN, + PROGRAM_UPDATE_TOPIC, ZONE_UPDATE_TOPIC, RainMachineEntity) +from homeassistant.const import ATTR_ID from homeassistant.components.switch import SwitchDevice -from homeassistant.const import ATTR_ATTRIBUTION -from homeassistant.util import Throttle +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, async_dispatcher_send) DEPENDENCIES = ['rainmachine'] -_LOGGER = getLogger(__name__) +_LOGGER = logging.getLogger(__name__) +ATTR_AREA = 'area' +ATTR_CS_ON = 'cs_on' +ATTR_CURRENT_CYCLE = 'current_cycle' ATTR_CYCLES = 'cycles' -ATTR_TOTAL_DURATION = 'total_duration' - -DEFAULT_ZONE_RUN = 60 * 10 - - -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set this component up under its platform.""" +ATTR_DELAY = 'delay' +ATTR_DELAY_ON = 'delay_on' +ATTR_FIELD_CAPACITY = 'field_capacity' +ATTR_NO_CYCLES = 'number_of_cycles' +ATTR_PRECIP_RATE = 'sprinkler_head_precipitation_rate' +ATTR_RESTRICTIONS = 'restrictions' +ATTR_SLOPE = 'slope' +ATTR_SOAK = 'soak' +ATTR_SOIL_TYPE = 'soil_type' +ATTR_SPRINKLER_TYPE = 'sprinkler_head_type' +ATTR_STATUS = 'status' +ATTR_SUN_EXPOSURE = 'sun_exposure' +ATTR_VEGETATION_TYPE = 'vegetation_type' +ATTR_ZONES = 'zones' + +DAYS = [ + 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', + 'Sunday' +] + +PROGRAM_STATUS_MAP = {0: 'Not Running', 1: 'Running', 2: 'Queued'} + +SOIL_TYPE_MAP = { + 0: 'Not Set', + 1: 'Clay Loam', + 2: 'Silty Clay', + 3: 'Clay', + 4: 'Loam', + 5: 'Sandy Loam', + 6: 'Loamy Sand', + 7: 'Sand', + 8: 'Sandy Clay', + 9: 'Silt Loam', + 10: 'Silt', + 99: 'Other' +} + +SLOPE_TYPE_MAP = { + 0: 'Not Set', + 1: 'Flat', + 2: 'Moderate', + 3: 'High', + 4: 'Very High', + 99: 'Other' +} + +SPRINKLER_TYPE_MAP = { + 0: 'Not Set', + 1: 'Popup Spray', + 2: 'Rotors', + 3: 'Surface Drip', + 4: 'Bubblers Drip', + 99: 'Other' +} + +SUN_EXPOSURE_MAP = { + 0: 'Not Set', + 1: 'Full Sun', + 2: 'Partial Shade', + 3: 'Full Shade' +} + +VEGETATION_MAP = { + 0: 'Not Set', + 2: 'Cool Season Grass', + 3: 'Fruit Trees', + 4: 'Flowers', + 5: 'Vegetables', + 6: 'Citrus', + 7: 'Trees and Bushes', + 9: 'Drought Tolerant Plants', + 10: 'Warm Season Grass', + 99: 'Other' +} + + +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): + """Set up the RainMachine Switch platform.""" if discovery_info is None: return @@ -28,48 +109,40 @@ def setup_platform(hass, config, add_devices, discovery_info=None): zone_run_time = discovery_info.get(CONF_ZONE_RUN_TIME, DEFAULT_ZONE_RUN) - client, device_mac = hass.data.get(DATA_RAINMACHINE) + rainmachine = hass.data[DATA_RAINMACHINE] entities = [] - for program in client.programs.all().get('programs', {}): + + programs = await rainmachine.client.programs.all() + for program in programs: if not program.get('active'): continue _LOGGER.debug('Adding program: %s', program) - entities.append( - RainMachineProgram(client, device_mac, program)) + entities.append(RainMachineProgram(rainmachine, program)) - for zone in client.zones.all().get('zones', {}): + zones = await rainmachine.client.zones.all() + for zone in zones: if not zone.get('active'): continue _LOGGER.debug('Adding zone: %s', zone) - entities.append( - RainMachineZone(client, device_mac, zone, - zone_run_time)) - - add_devices(entities, True) - + entities.append(RainMachineZone(rainmachine, zone, zone_run_time)) -class RainMachineEntity(SwitchDevice): - """A class to represent a generic RainMachine entity.""" + async_add_devices(entities, True) - def __init__(self, client, device_mac, entity_json): - """Initialize a generic RainMachine entity.""" - self._api_type = 'remote' if client.auth.using_remote_api else 'local' - self._client = client - self._entity_json = entity_json - self.device_mac = device_mac +class RainMachineSwitch(RainMachineEntity, SwitchDevice): + """A class to represent a generic RainMachine switch.""" - self._attrs = { - ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION - } + def __init__(self, rainmachine, switch_type, obj): + """Initialize a generic RainMachine switch.""" + super().__init__(rainmachine) - @property - def device_state_attributes(self) -> dict: - """Return the state attributes.""" - return self._attrs + self._name = obj['name'] + self._obj = obj + self._rainmachine_entity_id = obj['uid'] + self._switch_type = switch_type @property def icon(self) -> str: @@ -79,130 +152,173 @@ def icon(self) -> str: @property def is_enabled(self) -> bool: """Return whether the entity is enabled.""" - return self._entity_json.get('active') + return self._obj.get('active') @property - def rainmachine_entity_id(self) -> int: - """Return the RainMachine ID for this entity.""" - return self._entity_json.get('uid') + def unique_id(self) -> str: + """Return a unique, HASS-friendly identifier for this entity.""" + return '{0}_{1}_{2}'.format( + self.rainmachine.device_mac.replace(':', ''), self._switch_type, + self._rainmachine_entity_id) + @callback + def _program_updated(self): + """Update state, trigger updates.""" + self.async_schedule_update_ha_state(True) -class RainMachineProgram(RainMachineEntity): + +class RainMachineProgram(RainMachineSwitch): """A RainMachine program.""" + def __init__(self, rainmachine, obj): + """Initialize a generic RainMachine switch.""" + super().__init__(rainmachine, 'program', obj) + @property def is_on(self) -> bool: """Return whether the program is running.""" - return bool(self._entity_json.get('status')) + return bool(self._obj.get('status')) @property - def name(self) -> str: - """Return the name of the program.""" - return 'Program: {0}'.format(self._entity_json.get('name')) + def zones(self) -> list: + """Return a list of active zones associated with this program.""" + return [z for z in self._obj['wateringTimes'] if z['active']] - @property - def unique_id(self) -> str: - """Return a unique, HASS-friendly identifier for this entity.""" - return '{0}_program_{1}'.format( - self.device_mac.replace(':', ''), self.rainmachine_entity_id) + async def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, PROGRAM_UPDATE_TOPIC, self._program_updated) - def turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs) -> None: """Turn the program off.""" - import regenmaschine.exceptions as exceptions + from regenmaschine.errors import RequestError try: - self._client.programs.stop(self.rainmachine_entity_id) - except exceptions.BrokenAPICall: - _LOGGER.error('programs.stop currently broken in remote API') - except exceptions.HTTPError as exc_info: - _LOGGER.error('Unable to turn off program "%s"', self.unique_id) - _LOGGER.debug(exc_info) - - def turn_on(self, **kwargs) -> None: + await self.rainmachine.client.programs.stop( + self._rainmachine_entity_id) + async_dispatcher_send(self.hass, PROGRAM_UPDATE_TOPIC) + except RequestError as err: + _LOGGER.error( + 'Unable to turn off program "%s": %s', self.unique_id, + str(err)) + + async def async_turn_on(self, **kwargs) -> None: """Turn the program on.""" - import regenmaschine.exceptions as exceptions + from regenmaschine.errors import RequestError try: - self._client.programs.start(self.rainmachine_entity_id) - except exceptions.BrokenAPICall: - _LOGGER.error('programs.start currently broken in remote API') - except exceptions.HTTPError as exc_info: - _LOGGER.error('Unable to turn on program "%s"', self.unique_id) - _LOGGER.debug(exc_info) - - @Throttle(MIN_SCAN_TIME, MIN_SCAN_TIME_FORCED) - def update(self) -> None: + await self.rainmachine.client.programs.start( + self._rainmachine_entity_id) + async_dispatcher_send(self.hass, PROGRAM_UPDATE_TOPIC) + except RequestError as err: + _LOGGER.error( + 'Unable to turn on program "%s": %s', self.unique_id, str(err)) + + async def async_update(self) -> None: """Update info for the program.""" - import regenmaschine.exceptions as exceptions + from regenmaschine.errors import RequestError try: - self._entity_json = self._client.programs.get( - self.rainmachine_entity_id) - except exceptions.HTTPError as exc_info: - _LOGGER.error('Unable to update info for program "%s"', - self.unique_id) - _LOGGER.debug(exc_info) - - -class RainMachineZone(RainMachineEntity): + self._obj = await self.rainmachine.client.programs.get( + self._rainmachine_entity_id) + + self._attrs.update({ + ATTR_ID: self._obj['uid'], + ATTR_SOAK: self._obj.get('soak'), + ATTR_STATUS: PROGRAM_STATUS_MAP[self._obj.get('status')], + ATTR_ZONES: ', '.join(z['name'] for z in self.zones) + }) + except RequestError as err: + _LOGGER.error( + 'Unable to update info for program "%s": %s', self.unique_id, + str(err)) + + +class RainMachineZone(RainMachineSwitch): """A RainMachine zone.""" - def __init__(self, client, device_mac, zone_json, - zone_run_time): + def __init__(self, rainmachine, obj, zone_run_time): """Initialize a RainMachine zone.""" - super().__init__(client, device_mac, zone_json) + super().__init__(rainmachine, 'zone', obj) + + self._properties_json = {} self._run_time = zone_run_time - self._attrs.update({ - ATTR_CYCLES: self._entity_json.get('noOfCycles'), - ATTR_TOTAL_DURATION: self._entity_json.get('userDuration') - }) @property def is_on(self) -> bool: """Return whether the zone is running.""" - return bool(self._entity_json.get('state')) + return bool(self._obj.get('state')) - @property - def name(self) -> str: - """Return the name of the zone.""" - return 'Zone: {0}'.format(self._entity_json.get('name')) + async def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, PROGRAM_UPDATE_TOPIC, self._program_updated) + async_dispatcher_connect( + self.hass, ZONE_UPDATE_TOPIC, self._program_updated) - @property - def unique_id(self) -> str: - """Return a unique, HASS-friendly identifier for this entity.""" - return '{0}_zone_{1}'.format( - self.device_mac.replace(':', ''), self.rainmachine_entity_id) - - def turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs) -> None: """Turn the zone off.""" - import regenmaschine.exceptions as exceptions + from regenmaschine.errors import RequestError try: - self._client.zones.stop(self.rainmachine_entity_id) - except exceptions.HTTPError as exc_info: - _LOGGER.error('Unable to turn off zone "%s"', self.unique_id) - _LOGGER.debug(exc_info) + await self.rainmachine.client.zones.stop( + self._rainmachine_entity_id) + except RequestError as err: + _LOGGER.error( + 'Unable to turn off zone "%s": %s', self.unique_id, str(err)) - def turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs) -> None: """Turn the zone on.""" - import regenmaschine.exceptions as exceptions + from regenmaschine.errors import RequestError try: - self._client.zones.start(self.rainmachine_entity_id, - self._run_time) - except exceptions.HTTPError as exc_info: - _LOGGER.error('Unable to turn on zone "%s"', self.unique_id) - _LOGGER.debug(exc_info) - - @Throttle(MIN_SCAN_TIME, MIN_SCAN_TIME_FORCED) - def update(self) -> None: + await self.rainmachine.client.zones.start( + self._rainmachine_entity_id, self._run_time) + except RequestError as err: + _LOGGER.error( + 'Unable to turn on zone "%s": %s', self.unique_id, str(err)) + + async def async_update(self) -> None: """Update info for the zone.""" - import regenmaschine.exceptions as exceptions + from regenmaschine.errors import RequestError try: - self._entity_json = self._client.zones.get( - self.rainmachine_entity_id) - except exceptions.HTTPError as exc_info: - _LOGGER.error('Unable to update info for zone "%s"', - self.unique_id) - _LOGGER.debug(exc_info) + self._obj = await self.rainmachine.client.zones.get( + self._rainmachine_entity_id) + + self._properties_json = await self.rainmachine.client.zones.get( + self._rainmachine_entity_id, details=True) + + self._attrs.update({ + ATTR_ID: + self._obj['uid'], + ATTR_AREA: + self._properties_json.get('waterSense').get('area'), + ATTR_CURRENT_CYCLE: + self._obj.get('cycle'), + ATTR_FIELD_CAPACITY: + self._properties_json.get('waterSense') + .get('fieldCapacity'), + ATTR_NO_CYCLES: + self._obj.get('noOfCycles'), + ATTR_PRECIP_RATE: + self._properties_json.get('waterSense') + .get('precipitationRate'), + ATTR_RESTRICTIONS: + self._obj.get('restriction'), + ATTR_SLOPE: + SLOPE_TYPE_MAP.get(self._properties_json.get('slope')), + ATTR_SOIL_TYPE: + SOIL_TYPE_MAP.get(self._properties_json.get('sun')), + ATTR_SPRINKLER_TYPE: + SPRINKLER_TYPE_MAP.get( + self._properties_json.get('group_id')), + ATTR_SUN_EXPOSURE: + SUN_EXPOSURE_MAP.get(self._properties_json.get('sun')), + ATTR_VEGETATION_TYPE: + VEGETATION_MAP.get(self._obj.get('type')), + }) + except RequestError as err: + _LOGGER.error( + 'Unable to update info for zone "%s": %s', self.unique_id, + str(err)) diff --git a/homeassistant/components/switch/raspihats.py b/homeassistant/components/switch/raspihats.py index 7be3a6f0baafe6..7173ad35dafbfd 100644 --- a/homeassistant/components/switch/raspihats.py +++ b/homeassistant/components/switch/raspihats.py @@ -39,7 +39,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the raspihats switch devices.""" I2CHatSwitch.I2C_HATS_MANAGER = hass.data[I2C_HATS_MANAGER] diff --git a/homeassistant/components/switch/rest.py b/homeassistant/components/switch/rest.py index 9c589d1d95b9eb..914408406a9e80 100644 --- a/homeassistant/components/switch/rest.py +++ b/homeassistant/components/switch/rest.py @@ -47,7 +47,6 @@ }) -# pylint: disable=unused-argument @asyncio.coroutine def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the RESTful switch.""" diff --git a/homeassistant/components/switch/rfxtrx.py b/homeassistant/components/switch/rfxtrx.py index 7dd1d25ad94e6d..17b5c8e40d5510 100644 --- a/homeassistant/components/switch/rfxtrx.py +++ b/homeassistant/components/switch/rfxtrx.py @@ -8,13 +8,13 @@ import voluptuous as vol -import homeassistant.components.rfxtrx as rfxtrx +from homeassistant.components import rfxtrx from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA -from homeassistant.const import CONF_NAME from homeassistant.components.rfxtrx import ( CONF_AUTOMATIC_ADD, CONF_FIRE_EVENT, DEFAULT_SIGNAL_REPETITIONS, CONF_SIGNAL_REPETITIONS, CONF_DEVICES) from homeassistant.helpers import config_validation as cv +from homeassistant.const import CONF_NAME DEPENDENCIES = ['rfxtrx'] @@ -24,7 +24,7 @@ vol.Optional(CONF_DEVICES, default={}): { cv.string: vol.Schema({ vol.Required(CONF_NAME): cv.string, - vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean + vol.Optional(CONF_FIRE_EVENT, default=False): cv.boolean, }) }, vol.Optional(CONF_AUTOMATIC_ADD, default=False): cv.boolean, diff --git a/homeassistant/components/switch/rpi_gpio.py b/homeassistant/components/switch/rpi_gpio.py index ac38da1c6a7b9d..300af4be61d1fc 100644 --- a/homeassistant/components/switch/rpi_gpio.py +++ b/homeassistant/components/switch/rpi_gpio.py @@ -9,7 +9,7 @@ import voluptuous as vol from homeassistant.components.switch import PLATFORM_SCHEMA -import homeassistant.components.rpi_gpio as rpi_gpio +from homeassistant.components import rpi_gpio from homeassistant.const import DEVICE_DEFAULT_NAME from homeassistant.helpers.entity import ToggleEntity import homeassistant.helpers.config_validation as cv @@ -34,7 +34,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Raspberry PI GPIO devices.""" invert_logic = config.get(CONF_INVERT_LOGIC) diff --git a/homeassistant/components/switch/rpi_pfio.py b/homeassistant/components/switch/rpi_pfio.py index c10f417ba497ca..dad0c7c59ba39c 100644 --- a/homeassistant/components/switch/rpi_pfio.py +++ b/homeassistant/components/switch/rpi_pfio.py @@ -8,9 +8,9 @@ import voluptuous as vol -import homeassistant.components.rpi_pfio as rpi_pfio +from homeassistant.components import rpi_pfio from homeassistant.components.switch import PLATFORM_SCHEMA -from homeassistant.const import DEVICE_DEFAULT_NAME +from homeassistant.const import ATTR_NAME, DEVICE_DEFAULT_NAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import ToggleEntity @@ -19,7 +19,6 @@ DEPENDENCIES = ['rpi_pfio'] ATTR_INVERT_LOGIC = 'invert_logic' -ATTR_NAME = 'name' CONF_PORTS = 'ports' diff --git a/homeassistant/components/switch/rpi_rf.py b/homeassistant/components/switch/rpi_rf.py index 40200f05806343..03f11de21f708c 100644 --- a/homeassistant/components/switch/rpi_rf.py +++ b/homeassistant/components/switch/rpi_rf.py @@ -44,7 +44,7 @@ }) -# pylint: disable=unused-argument, import-error, no-member +# pylint: disable=no-member def setup_platform(hass, config, add_devices, discovery_info=None): """Find and return switches controlled by a generic RF device via GPIO.""" import rpi_rf diff --git a/homeassistant/components/switch/scsgate.py b/homeassistant/components/switch/scsgate.py index 8b2734612defee..b549f351afc84e 100644 --- a/homeassistant/components/switch/scsgate.py +++ b/homeassistant/components/switch/scsgate.py @@ -8,7 +8,7 @@ import voluptuous as vol -import homeassistant.components.scsgate as scsgate +from homeassistant.components import scsgate from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_STATE, CONF_NAME, CONF_DEVICES) @@ -152,7 +152,7 @@ def process_event(self, message): ) -class SCSGateScenarioSwitch(object): +class SCSGateScenarioSwitch: """Provides a SCSGate scenario switch. This switch is always in an 'off" state, when toggled it's used to trigger diff --git a/homeassistant/components/switch/snmp.py b/homeassistant/components/switch/snmp.py index b0c192cdafad42..9c84584e833b9e 100644 --- a/homeassistant/components/switch/snmp.py +++ b/homeassistant/components/switch/snmp.py @@ -13,7 +13,7 @@ CONF_HOST, CONF_NAME, CONF_PORT, CONF_PAYLOAD_ON, CONF_PAYLOAD_OFF) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pysnmp==4.4.4'] +REQUIREMENTS = ['pysnmp==4.4.5'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/spider.py b/homeassistant/components/switch/spider.py new file mode 100644 index 00000000000000..94b7db8f1e5b2b --- /dev/null +++ b/homeassistant/components/switch/spider.py @@ -0,0 +1,77 @@ +""" +Support for Spider switches. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.spider/ +""" + +import logging + +from homeassistant.components.spider import DOMAIN as SPIDER_DOMAIN +from homeassistant.components.switch import SwitchDevice + +DEPENDENCIES = ['spider'] + +_LOGGER = logging.getLogger(__name__) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the Spider thermostat.""" + if discovery_info is None: + return + + devices = [SpiderPowerPlug(hass.data[SPIDER_DOMAIN]['controller'], device) + for device in hass.data[SPIDER_DOMAIN]['power_plugs']] + + add_devices(devices, True) + + +class SpiderPowerPlug(SwitchDevice): + """Representation of a Spider Power Plug.""" + + def __init__(self, api, power_plug): + """Initialize the Vera device.""" + self.api = api + self.power_plug = power_plug + + @property + def unique_id(self): + """Return the ID of this switch.""" + return self.power_plug.id + + @property + def name(self): + """Return the name of the switch if any.""" + return self.power_plug.name + + @property + def current_power_w(self): + """Return the current power usage in W.""" + return round(self.power_plug.current_energy_consumption) + + @property + def today_energy_kwh(self): + """Return the current power usage in Kwh.""" + return round(self.power_plug.today_energy_consumption / 1000, 2) + + @property + def is_on(self): + """Return true if switch is on. Standby is on.""" + return self.power_plug.is_on + + @property + def available(self): + """Return true if switch is available.""" + return self.power_plug.is_available + + def turn_on(self, **kwargs): + """Turn device on.""" + self.power_plug.turn_on() + + def turn_off(self, **kwargs): + """Turn device off.""" + self.power_plug.turn_off() + + def update(self): + """Get the latest data.""" + self.power_plug = self.api.get_power_plug(self.unique_id) diff --git a/homeassistant/components/switch/tahoma.py b/homeassistant/components/switch/tahoma.py index 339a0c393863bd..aa3554a494c695 100644 --- a/homeassistant/components/switch/tahoma.py +++ b/homeassistant/components/switch/tahoma.py @@ -1,7 +1,7 @@ """ Support for Tahoma Switch - those are push buttons for garage door etc. -Those buttons are implemented as switchs that are never on. They only +Those buttons are implemented as switches that are never on. They only receive the turn_on action, perform the relay click, and stay in OFF state For more details about this platform, please refer to the documentation at @@ -19,7 +19,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up Tahoma switchs.""" + """Set up Tahoma switches.""" controller = hass.data[TAHOMA_DOMAIN]['controller'] devices = [] for switch in hass.data[TAHOMA_DOMAIN]['devices']['switch']: diff --git a/homeassistant/components/switch/tellstick.py b/homeassistant/components/switch/tellstick.py index ae19e77c2e5a92..5f7930a8a7c47a 100644 --- a/homeassistant/components/switch/tellstick.py +++ b/homeassistant/components/switch/tellstick.py @@ -10,7 +10,6 @@ from homeassistant.helpers.entity import ToggleEntity -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up Tellstick switches.""" if (discovery_info is None or diff --git a/homeassistant/components/switch/telnet.py b/homeassistant/components/switch/telnet.py index c3a608b96924a1..381f2ec9bec829 100644 --- a/homeassistant/components/switch/telnet.py +++ b/homeassistant/components/switch/telnet.py @@ -38,7 +38,6 @@ SCAN_INTERVAL = timedelta(seconds=10) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Find and return switches controlled by telnet commands.""" devices = config.get(CONF_SWITCHES, {}) diff --git a/homeassistant/components/switch/template.py b/homeassistant/components/switch/template.py index 93ebf98e9ace67..a6fa8241940b1c 100644 --- a/homeassistant/components/switch/template.py +++ b/homeassistant/components/switch/template.py @@ -44,7 +44,6 @@ @asyncio.coroutine -# pylint: disable=unused-argument def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the Template switch.""" switches = [] diff --git a/homeassistant/components/switch/thinkingcleaner.py b/homeassistant/components/switch/thinkingcleaner.py index 37c2f52e228d68..0753435cfba12a 100644 --- a/homeassistant/components/switch/thinkingcleaner.py +++ b/homeassistant/components/switch/thinkingcleaner.py @@ -8,8 +8,7 @@ import logging from datetime import timedelta -import homeassistant.util as util - +from homeassistant import util from homeassistant.const import (STATE_ON, STATE_OFF) from homeassistant.helpers.entity import ToggleEntity diff --git a/homeassistant/components/switch/tplink.py b/homeassistant/components/switch/tplink.py index 1eca5284f76f2d..0cacdfe1539d1e 100644 --- a/homeassistant/components/switch/tplink.py +++ b/homeassistant/components/switch/tplink.py @@ -14,7 +14,7 @@ from homeassistant.const import (CONF_HOST, CONF_NAME, ATTR_VOLTAGE) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyHS100==0.3.0'] +REQUIREMENTS = ['pyHS100==0.3.2'] _LOGGER = logging.getLogger(__name__) @@ -32,7 +32,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the TPLink switch platform.""" from pyHS100 import SmartPlug @@ -88,8 +87,6 @@ def update(self): """Update the TP-Link switch's state.""" from pyHS100 import SmartDeviceException try: - self._available = True - self._state = self.smartplug.state == \ self.smartplug.SWITCH_STATE_ON @@ -122,6 +119,10 @@ def update(self): # Device returned no daily history pass + self._available = True + except (SmartDeviceException, OSError) as ex: - _LOGGER.warning("Could not read state for %s: %s", self.name, ex) - self._available = False + if self._available: + _LOGGER.warning( + "Could not read state for %s: %s", self.name, ex) + self._available = False diff --git a/homeassistant/components/switch/transmission.py b/homeassistant/components/switch/transmission.py index 840fdae44d935e..ffe285a23f3942 100644 --- a/homeassistant/components/switch/transmission.py +++ b/homeassistant/components/switch/transmission.py @@ -31,7 +31,6 @@ }) -# pylint: disable=unused-argument def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Transmission switch.""" import transmissionrpc diff --git a/homeassistant/components/switch/tuya.py b/homeassistant/components/switch/tuya.py new file mode 100644 index 00000000000000..4f69e76f954b25 --- /dev/null +++ b/homeassistant/components/switch/tuya.py @@ -0,0 +1,47 @@ +""" +Support for Tuya switch. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.tuya/ +""" +from homeassistant.components.switch import ENTITY_ID_FORMAT, SwitchDevice +from homeassistant.components.tuya import DATA_TUYA, TuyaDevice + +DEPENDENCIES = ['tuya'] + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up Tuya Switch device.""" + if discovery_info is None: + return + tuya = hass.data[DATA_TUYA] + dev_ids = discovery_info.get('dev_ids') + devices = [] + for dev_id in dev_ids: + device = tuya.get_device_by_id(dev_id) + if device is None: + continue + devices.append(TuyaSwitch(device)) + add_devices(devices) + + +class TuyaSwitch(TuyaDevice, SwitchDevice): + """Tuya Switch Device.""" + + def __init__(self, tuya): + """Init Tuya switch device.""" + super().__init__(tuya) + self.entity_id = ENTITY_ID_FORMAT.format(tuya.object_id()) + + @property + def is_on(self): + """Return true if switch is on.""" + return self.tuya.state() + + def turn_on(self, **kwargs): + """Turn the switch on.""" + self.tuya.turn_on() + + def turn_off(self, **kwargs): + """Turn the device off.""" + self.tuya.turn_off() diff --git a/homeassistant/components/switch/velbus.py b/homeassistant/components/switch/velbus.py index 15090091a52480..46f6e893c9714d 100644 --- a/homeassistant/components/switch/velbus.py +++ b/homeassistant/components/switch/velbus.py @@ -4,108 +4,42 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/switch.velbus/ """ - -import asyncio import logging -import voluptuous as vol - -from homeassistant.const import CONF_NAME, CONF_DEVICES -from homeassistant.components.switch import SwitchDevice, PLATFORM_SCHEMA -from homeassistant.components.velbus import DOMAIN -import homeassistant.helpers.config_validation as cv +from homeassistant.components.switch import SwitchDevice +from homeassistant.components.velbus import ( + DOMAIN as VELBUS_DOMAIN, VelbusEntity) _LOGGER = logging.getLogger(__name__) -SWITCH_SCHEMA = { - vol.Required('module'): cv.positive_int, - vol.Required('channel'): cv.positive_int, - vol.Required(CONF_NAME): cv.string -} - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_DEVICES): - vol.All(cv.ensure_list, [SWITCH_SCHEMA]) -}) - DEPENDENCIES = ['velbus'] -def setup_platform(hass, config, add_devices, discovery_info=None): - """Set up the Switch.""" - velbus = hass.data[DOMAIN] - devices = [] +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the Velbus Switch platform.""" + if discovery_info is None: + return + switches = [] + for switch in discovery_info: + module = hass.data[VELBUS_DOMAIN].get_module(switch[0]) + channel = switch[1] + switches.append(VelbusSwitch(module, channel)) + async_add_devices(switches) - for switch in config[CONF_DEVICES]: - devices.append(VelbusSwitch(switch, velbus)) - add_devices(devices) - return True - -class VelbusSwitch(SwitchDevice): +class VelbusSwitch(VelbusEntity, SwitchDevice): """Representation of a switch.""" - def __init__(self, switch, velbus): - """Initialize a Velbus switch.""" - self._velbus = velbus - self._name = switch[CONF_NAME] - self._module = switch['module'] - self._channel = switch['channel'] - self._state = False - - @asyncio.coroutine - def async_added_to_hass(self): - """Add listener for Velbus messages on bus.""" - def _init_velbus(): - """Initialize Velbus on startup.""" - self._velbus.subscribe(self._on_message) - self.get_status() - - yield from self.hass.async_add_job(_init_velbus) - - def _on_message(self, message): - import velbus - if isinstance(message, velbus.RelayStatusMessage) and \ - message.address == self._module and \ - message.channel == self._channel: - self._state = message.is_on() - self.schedule_update_ha_state() - - @property - def name(self): - """Return the display name of this switch.""" - return self._name - - @property - def should_poll(self): - """Disable polling.""" - return False - @property def is_on(self): """Return true if the switch is on.""" - return self._state + return self._module.is_on(self._channel) def turn_on(self, **kwargs): """Instruct the switch to turn on.""" - import velbus - message = velbus.SwitchRelayOnMessage() - message.set_defaults(self._module) - message.relay_channels = [self._channel] - self._velbus.send(message) + self._module.turn_on(self._channel) def turn_off(self, **kwargs): """Instruct the switch to turn off.""" - import velbus - message = velbus.SwitchRelayOffMessage() - message.set_defaults(self._module) - message.relay_channels = [self._channel] - self._velbus.send(message) - - def get_status(self): - """Retrieve current status.""" - import velbus - message = velbus.ModuleStatusRequestMessage() - message.set_defaults(self._module) - message.channels = [self._channel] - self._velbus.send(message) + self._module.turn_off(self._channel) diff --git a/homeassistant/components/switch/vera.py b/homeassistant/components/switch/vera.py index d7c284e4ccf515..82e2756c23054e 100644 --- a/homeassistant/components/switch/vera.py +++ b/homeassistant/components/switch/vera.py @@ -19,8 +19,8 @@ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Vera switches.""" add_devices( - VeraSwitch(device, hass.data[VERA_CONTROLLER]) for - device in hass.data[VERA_DEVICES]['switch']) + [VeraSwitch(device, hass.data[VERA_CONTROLLER]) for + device in hass.data[VERA_DEVICES]['switch']], True) class VeraSwitch(VeraDevice, SwitchDevice): diff --git a/homeassistant/components/switch/verisure.py b/homeassistant/components/switch/verisure.py index 810946a505883b..4b126e5d3320be 100644 --- a/homeassistant/components/switch/verisure.py +++ b/homeassistant/components/switch/verisure.py @@ -72,6 +72,7 @@ def turn_off(self, **kwargs): self._state = False self._change_timestamp = time() + # pylint: disable=no-self-use def update(self): """Get the latest date of the smartplug.""" hub.update_overview() diff --git a/homeassistant/components/switch/wemo.py b/homeassistant/components/switch/wemo.py index 4f06f94155831a..35ea435bf48390 100644 --- a/homeassistant/components/switch/wemo.py +++ b/homeassistant/components/switch/wemo.py @@ -33,10 +33,9 @@ WEMO_STANDBY = 8 -# pylint: disable=unused-argument, too-many-function-args def setup_platform(hass, config, add_devices_callback, discovery_info=None): """Set up discovered WeMo switches.""" - import pywemo.discovery as discovery + from pywemo import discovery if discovery_info is not None: location = discovery_info['ssdp_description'] @@ -167,9 +166,9 @@ def detail_state(self): standby_state = int(self.insight_params['state']) if standby_state == WEMO_ON: return STATE_ON - elif standby_state == WEMO_OFF: + if standby_state == WEMO_OFF: return STATE_OFF - elif standby_state == WEMO_STANDBY: + if standby_state == WEMO_STANDBY: return STATE_STANDBY return STATE_UNKNOWN diff --git a/homeassistant/components/switch/wirelesstag.py b/homeassistant/components/switch/wirelesstag.py new file mode 100644 index 00000000000000..cce8c349a31448 --- /dev/null +++ b/homeassistant/components/switch/wirelesstag.py @@ -0,0 +1,118 @@ +""" +Switch implementation for Wireless Sensor Tags (wirelesstag.net) platform. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/switch.wirelesstag/ +""" +import logging + +import voluptuous as vol + + +from homeassistant.components.wirelesstag import ( + DOMAIN as WIRELESSTAG_DOMAIN, + WIRELESSTAG_TYPE_13BIT, WIRELESSTAG_TYPE_WATER, + WIRELESSTAG_TYPE_ALSPRO, + WIRELESSTAG_TYPE_WEMO_DEVICE, + WirelessTagBaseSensor) +from homeassistant.components.switch import PLATFORM_SCHEMA, SwitchDevice +from homeassistant.const import ( + CONF_MONITORED_CONDITIONS) +import homeassistant.helpers.config_validation as cv + +DEPENDENCIES = ['wirelesstag'] + +_LOGGER = logging.getLogger(__name__) + +ARM_TEMPERATURE = 'temperature' +ARM_HUMIDITY = 'humidity' +ARM_MOTION = 'motion' +ARM_LIGHT = 'light' +ARM_MOISTURE = 'moisture' + +# Switch types: Name, tag sensor type +SWITCH_TYPES = { + ARM_TEMPERATURE: ['Arm Temperature', 'temperature'], + ARM_HUMIDITY: ['Arm Humidity', 'humidity'], + ARM_MOTION: ['Arm Motion', 'motion'], + ARM_LIGHT: ['Arm Light', 'light'], + ARM_MOISTURE: ['Arm Moisture', 'moisture'] +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_MONITORED_CONDITIONS, default=[]): + vol.All(cv.ensure_list, [vol.In(SWITCH_TYPES)]), +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up switches for a Wireless Sensor Tags.""" + platform = hass.data.get(WIRELESSTAG_DOMAIN) + + switches = [] + tags = platform.load_tags() + for switch_type in config.get(CONF_MONITORED_CONDITIONS): + for _, tag in tags.items(): + if switch_type in WirelessTagSwitch.allowed_switches(tag): + switches.append(WirelessTagSwitch(platform, tag, switch_type)) + + add_devices(switches, True) + + +class WirelessTagSwitch(WirelessTagBaseSensor, SwitchDevice): + """A switch implementation for Wireless Sensor Tags.""" + + @classmethod + def allowed_switches(cls, tag): + """Return allowed switch types for wireless tag.""" + all_sensors = SWITCH_TYPES.keys() + sensors_per_tag_spec = { + WIRELESSTAG_TYPE_13BIT: [ + ARM_TEMPERATURE, ARM_HUMIDITY, ARM_MOTION], + WIRELESSTAG_TYPE_WATER: [ + ARM_TEMPERATURE, ARM_MOISTURE], + WIRELESSTAG_TYPE_ALSPRO: [ + ARM_TEMPERATURE, ARM_HUMIDITY, ARM_MOTION, ARM_LIGHT], + WIRELESSTAG_TYPE_WEMO_DEVICE: [] + } + + tag_type = tag.tag_type + + result = ( + sensors_per_tag_spec[tag_type] + if tag_type in sensors_per_tag_spec else all_sensors) + _LOGGER.info("Allowed switches: %s tag_type: %s", + str(result), tag_type) + + return result + + def __init__(self, api, tag, switch_type): + """Initialize a switch for Wireless Sensor Tag.""" + super().__init__(api, tag) + self._switch_type = switch_type + self.sensor_type = SWITCH_TYPES[self._switch_type][1] + self._name = '{} {}'.format(self._tag.name, + SWITCH_TYPES[self._switch_type][0]) + + def turn_on(self, **kwargs): + """Turn on the switch.""" + self._api.arm(self) + + def turn_off(self, **kwargs): + """Turn on the switch.""" + self._api.disarm(self) + + @property + def is_on(self) -> bool: + """Return True if entity is on.""" + return self._state + + def updated_state_value(self): + """Provide formatted value.""" + return self.principal_value + + @property + def principal_value(self): + """Provide actual value of switch.""" + attr_name = 'is_{}_sensor_armed'.format(self.sensor_type) + return getattr(self._tag, attr_name, False) diff --git a/homeassistant/components/switch/xiaomi_miio.py b/homeassistant/components/switch/xiaomi_miio.py index 149acd76c07c00..37b16f44ea8eec 100644 --- a/homeassistant/components/switch/xiaomi_miio.py +++ b/homeassistant/components/switch/xiaomi_miio.py @@ -39,7 +39,7 @@ 'chuangmi.plug.v3']), }) -REQUIREMENTS = ['python-miio==0.3.9', 'construct==2.9.41'] +REQUIREMENTS = ['python-miio==0.4.0', 'construct==2.9.41'] ATTR_POWER = 'power' ATTR_TEMPERATURE = 'temperature' @@ -97,7 +97,6 @@ } -# pylint: disable=unused-argument async def async_setup_platform(hass, config, async_add_devices, discovery_info=None): """Set up the switch from config.""" @@ -142,7 +141,7 @@ async def async_setup_platform(hass, config, async_add_devices, elif model in ['qmi.powerstrip.v1', 'zimi.powerstrip.v2']: from miio import PowerStrip - plug = PowerStrip(host, token) + plug = PowerStrip(host, token, model=model) device = XiaomiPowerStripSwitch(name, plug, model, unique_id) devices.append(device) hass.data[DATA_KEY][host] = device @@ -422,8 +421,11 @@ def __init__(self, name, plug, model, unique_id, channel_usb): self._device_features = FEATURE_FLAGS_PLUG_V3 self._state_attrs.update({ ATTR_WIFI_LED: None, - ATTR_LOAD_POWER: None, }) + if self._channel_usb is False: + self._state_attrs.update({ + ATTR_LOAD_POWER: None, + }) async def async_turn_on(self, **kwargs): """Turn a channel on.""" @@ -477,7 +479,7 @@ async def async_update(self): if state.wifi_led: self._state_attrs[ATTR_WIFI_LED] = state.wifi_led - if state.load_power: + if self._channel_usb is False and state.load_power: self._state_attrs[ATTR_LOAD_POWER] = state.load_power except DeviceException as ex: diff --git a/homeassistant/components/switch/zha.py b/homeassistant/components/switch/zha.py index 22eb50be86b9a7..6109dc192f3c95 100644 --- a/homeassistant/components/switch/zha.py +++ b/homeassistant/components/switch/zha.py @@ -51,7 +51,7 @@ def should_poll(self) -> bool: @property def is_on(self) -> bool: """Return if the switch is on based on the statemachine.""" - if self._state == 'unknown': + if self._state is None: return False return bool(self._state) diff --git a/homeassistant/components/switch/zoneminder.py b/homeassistant/components/switch/zoneminder.py index adf3bf2d9bd624..fa32843eb4b94b 100644 --- a/homeassistant/components/switch/zoneminder.py +++ b/homeassistant/components/switch/zoneminder.py @@ -10,7 +10,7 @@ from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import (CONF_COMMAND_ON, CONF_COMMAND_OFF) -import homeassistant.components.zoneminder as zoneminder +from homeassistant.components import zoneminder import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/zwave.py b/homeassistant/components/switch/zwave.py index 3b82d87d7e75a3..31f942bd3af7df 100644 --- a/homeassistant/components/switch/zwave.py +++ b/homeassistant/components/switch/zwave.py @@ -6,11 +6,9 @@ """ import logging import time -# Because we do not compile openzwave on CI -# pylint: disable=import-error from homeassistant.components.switch import DOMAIN, SwitchDevice from homeassistant.components import zwave -from homeassistant.components.zwave import workaround, async_setup_platform # noqa # pylint: disable=unused-import +from homeassistant.components.zwave import workaround, async_setup_platform # noqa pylint: disable=unused-import _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index 5994184d815637..2a2a19aa2f5e27 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -126,11 +126,7 @@ def emit(self, record): if record.levelno >= logging.WARN: stack = [] if not record.exc_info: - try: - stack = [f for f, _, _, _ in traceback.extract_stack()] - except ValueError: - # On Python 3.4 under py.test getting the stack might fail. - pass + stack = [f for f, _, _, _ in traceback.extract_stack()] entry = self._create_entry(record, stack) self.records.appendleft(entry) diff --git a/homeassistant/components/tahoma.py b/homeassistant/components/tahoma.py index 9848d20094cbfa..aaa64489168671 100644 --- a/homeassistant/components/tahoma.py +++ b/homeassistant/components/tahoma.py @@ -32,7 +32,7 @@ }, extra=vol.ALLOW_EXTRA) TAHOMA_COMPONENTS = [ - 'scene', 'sensor', 'cover', 'switch' + 'scene', 'sensor', 'cover', 'switch', 'binary_sensor' ] TAHOMA_TYPES = { @@ -40,12 +40,19 @@ 'rts:CurtainRTSComponent': 'cover', 'rts:BlindRTSComponent': 'cover', 'rts:VenetianBlindRTSComponent': 'cover', + 'rts:DualCurtainRTSComponent': 'cover', + 'rts:ExteriorVenetianBlindRTSComponent': 'cover', + 'io:ExteriorVenetianBlindIOComponent': 'cover', + 'io:RollerShutterUnoIOComponent': 'cover', 'io:RollerShutterWithLowSpeedManagementIOComponent': 'cover', 'io:RollerShutterVeluxIOComponent': 'cover', 'io:RollerShutterGenericIOComponent': 'cover', 'io:WindowOpenerVeluxIOComponent': 'cover', 'io:LightIOSystemSensor': 'sensor', 'rts:GarageDoor4TRTSComponent': 'switch', + 'io:VerticalExteriorAwningIOComponent': 'cover', + 'io:HorizontalAwningIOComponent': 'cover', + 'rtds:RTDSSmokeSensor': 'smoke', } diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index af0fe5bd5725a5..53695102601536 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -22,7 +22,7 @@ from homeassistant.exceptions import TemplateError from homeassistant.setup import async_prepare_setup_platform -REQUIREMENTS = ['python-telegram-bot==10.0.2'] +REQUIREMENTS = ['python-telegram-bot==10.1.0'] _LOGGER = logging.getLogger(__name__) @@ -502,7 +502,7 @@ def edit_message(self, type_edit, chat_id=None, **kwargs): text, chat_id=chat_id, message_id=message_id, inline_message_id=inline_message_id, **params) - elif type_edit == SERVICE_EDIT_CAPTION: + if type_edit == SERVICE_EDIT_CAPTION: func_send = self.bot.editMessageCaption params[ATTR_CAPTION] = kwargs.get(ATTR_CAPTION) else: diff --git a/homeassistant/components/telegram_bot/polling.py b/homeassistant/components/telegram_bot/polling.py index ba8dc54b26492e..6ee42b32504684 100644 --- a/homeassistant/components/telegram_bot/polling.py +++ b/homeassistant/components/telegram_bot/polling.py @@ -92,8 +92,7 @@ def get_updates(self, offset): if resp.status == 200: _json = yield from resp.json() return _json - else: - raise WrongHttpStatus('wrong status {}'.format(resp.status)) + raise WrongHttpStatus('wrong status {}'.format(resp.status)) finally: if resp is not None: yield from resp.release() diff --git a/homeassistant/components/tellduslive.py b/homeassistant/components/tellduslive.py index dfb4b1e5fa9c24..c2b7ba9ba0f533 100644 --- a/homeassistant/components/tellduslive.py +++ b/homeassistant/components/tellduslive.py @@ -206,7 +206,7 @@ def tellstick_discovered(service, info): return True -class TelldusLiveClient(object): +class TelldusLiveClient: """Get the latest data and update the states.""" def __init__(self, hass, config, session): @@ -240,11 +240,11 @@ def identify_device(device): from tellduslive import (DIM, UP, TURNON) if device.methods & DIM: return 'light' - elif device.methods & UP: + if device.methods & UP: return 'cover' - elif device.methods & TURNON: + if device.methods & TURNON: return 'switch' - elif device.methods == 0: + if device.methods == 0: return 'binary_sensor' _LOGGER.warning( "Unidentified device type (methods: %d)", device.methods) @@ -349,9 +349,9 @@ def _battery_level(self): BATTERY_OK) if self.device.battery == BATTERY_LOW: return 1 - elif self.device.battery == BATTERY_UNKNOWN: + if self.device.battery == BATTERY_UNKNOWN: return None - elif self.device.battery == BATTERY_OK: + if self.device.battery == BATTERY_OK: return 100 return self.device.battery # Percentage diff --git a/homeassistant/components/thingspeak.py b/homeassistant/components/thingspeak.py index a21d44527a1314..9a876a87683b2e 100644 --- a/homeassistant/components/thingspeak.py +++ b/homeassistant/components/thingspeak.py @@ -11,9 +11,8 @@ from homeassistant.const import ( CONF_API_KEY, CONF_ID, CONF_WHITELIST, STATE_UNAVAILABLE, STATE_UNKNOWN) -from homeassistant.helpers import state as state_helper +from homeassistant.helpers import event, state as state_helper import homeassistant.helpers.config_validation as cv -import homeassistant.helpers.event as event REQUIREMENTS = ['thingspeak==0.4.1'] diff --git a/homeassistant/components/toon.py b/homeassistant/components/toon.py index ffb820e81481c6..cfd0d297d5466f 100644 --- a/homeassistant/components/toon.py +++ b/homeassistant/components/toon.py @@ -59,7 +59,7 @@ def setup(hass, config): return True -class ToonDataStore(object): +class ToonDataStore: """An object to store the Toon data.""" def __init__(self, username, password, gas=DEFAULT_GAS, diff --git a/homeassistant/components/tradfri.py b/homeassistant/components/tradfri.py index 72d1b4c769f1f6..b2e41902552bf9 100644 --- a/homeassistant/components/tradfri.py +++ b/homeassistant/components/tradfri.py @@ -15,7 +15,7 @@ from homeassistant.components.discovery import SERVICE_IKEA_TRADFRI from homeassistant.util.json import load_json, save_json -REQUIREMENTS = ['pytradfri[async]==5.4.2'] +REQUIREMENTS = ['pytradfri[async]==5.5.1'] DOMAIN = 'tradfri' GATEWAY_IDENTITY = 'homeassistant' @@ -166,8 +166,8 @@ async def _setup_gateway(hass, hass_config, host, identity, key, return True gateways[gateway_id] = gateway - hass.async_add_job(discovery.async_load_platform( + hass.async_create_task(discovery.async_load_platform( hass, 'light', DOMAIN, {'gateway': gateway_id}, hass_config)) - hass.async_add_job(discovery.async_load_platform( + hass.async_create_task(discovery.async_load_platform( hass, 'sensor', DOMAIN, {'gateway': gateway_id}, hass_config)) return True diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 999b584360cd79..f060c9f353aeba 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -29,7 +29,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.setup import async_prepare_setup_platform -REQUIREMENTS = ['mutagen==1.40.0'] +REQUIREMENTS = ['mutagen==1.41.0'] _LOGGER = logging.getLogger(__name__) @@ -169,7 +169,7 @@ async def async_clear_cache_handle(service): return True -class SpeechManager(object): +class SpeechManager: """Representation of a speech store.""" def __init__(self, hass): @@ -440,7 +440,7 @@ def write_tags(filename, data, provider, message, language, options): return data_bytes.getvalue() -class Provider(object): +class Provider: """Represent a single TTS provider.""" hass = None diff --git a/homeassistant/components/tts/amazon_polly.py b/homeassistant/components/tts/amazon_polly.py index 46c1a24caa0e8a..d59331984b7122 100644 --- a/homeassistant/components/tts/amazon_polly.py +++ b/homeassistant/components/tts/amazon_polly.py @@ -38,7 +38,8 @@ 'Chantal', 'Celine', 'Mathieu', 'Dora', 'Karl', 'Carla', 'Giorgio', 'Mizuki', 'Liv', 'Lotte', 'Ruben', 'Ewa', 'Jacek', 'Jan', 'Maja', 'Ricardo', 'Vitoria', 'Cristiano', - 'Ines', 'Carmen', 'Maxim', 'Tatyana', 'Astrid', 'Filiz'] + 'Ines', 'Carmen', 'Maxim', 'Tatyana', 'Astrid', 'Filiz', + 'Aditi', 'Léa', 'Matthew', 'Seoyeon', 'Takumi', 'Vicki'] SUPPORTED_OUTPUT_FORMATS = ['mp3', 'ogg_vorbis', 'pcm'] diff --git a/homeassistant/components/tts/google.py b/homeassistant/components/tts/google.py index bf03ec1adad57b..cb05795c445388 100644 --- a/homeassistant/components/tts/google.py +++ b/homeassistant/components/tts/google.py @@ -29,7 +29,7 @@ 'hr', 'cs', 'da', 'nl', 'en', 'en-au', 'en-uk', 'en-us', 'eo', 'fi', 'fr', 'de', 'el', 'hi', 'hu', 'is', 'id', 'it', 'ja', 'ko', 'la', 'lv', 'mk', 'no', 'pl', 'pt', 'pt-br', 'ro', 'ru', 'sr', 'sk', 'es', 'es-es', - 'es-us', 'sw', 'sv', 'ta', 'th', 'tr', 'vi', 'cy', 'uk', + 'es-us', 'sw', 'sv', 'ta', 'th', 'tr', 'vi', 'cy', 'uk', 'bg-BG' ] DEFAULT_LANG = 'en' diff --git a/homeassistant/components/tuya.py b/homeassistant/components/tuya.py new file mode 100644 index 00000000000000..33f34164b02cda --- /dev/null +++ b/homeassistant/components/tuya.py @@ -0,0 +1,167 @@ +""" +Support for Tuya Smart devices. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/tuya/ +""" +from datetime import timedelta +import logging +import voluptuous as vol + +from homeassistant.core import callback +import homeassistant.helpers.config_validation as cv +from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, CONF_PLATFORM) +from homeassistant.helpers import discovery +from homeassistant.helpers.dispatcher import ( + dispatcher_send, async_dispatcher_connect) +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import track_time_interval + +REQUIREMENTS = ['tuyapy==0.1.3'] + +_LOGGER = logging.getLogger(__name__) + +CONF_COUNTRYCODE = 'country_code' + +DOMAIN = 'tuya' +DATA_TUYA = 'data_tuya' + +SIGNAL_DELETE_ENTITY = 'tuya_delete' +SIGNAL_UPDATE_ENTITY = 'tuya_update' + +SERVICE_FORCE_UPDATE = 'force_update' +SERVICE_PULL_DEVICES = 'pull_devices' + +TUYA_TYPE_TO_HA = { + 'climate': 'climate', + 'cover': 'cover', + 'fan': 'fan', + 'light': 'light', + 'scene': 'scene', + 'switch': 'switch', +} + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_PASSWORD): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_COUNTRYCODE): cv.string, + vol.Optional(CONF_PLATFORM, default='tuya'): cv.string, + }) +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up Tuya Component.""" + from tuyapy import TuyaApi + + tuya = TuyaApi() + username = config[DOMAIN][CONF_USERNAME] + password = config[DOMAIN][CONF_PASSWORD] + country_code = config[DOMAIN][CONF_COUNTRYCODE] + platform = config[DOMAIN][CONF_PLATFORM] + + hass.data[DATA_TUYA] = tuya + tuya.init(username, password, country_code, platform) + hass.data[DOMAIN] = { + 'entities': {} + } + + def load_devices(device_list): + """Load new devices by device_list.""" + device_type_list = {} + for device in device_list: + dev_type = device.device_type() + if (dev_type in TUYA_TYPE_TO_HA and + device.object_id() not in hass.data[DOMAIN]['entities']): + ha_type = TUYA_TYPE_TO_HA[dev_type] + if ha_type not in device_type_list: + device_type_list[ha_type] = [] + device_type_list[ha_type].append(device.object_id()) + hass.data[DOMAIN]['entities'][device.object_id()] = None + for ha_type, dev_ids in device_type_list.items(): + discovery.load_platform( + hass, ha_type, DOMAIN, {'dev_ids': dev_ids}, config) + + device_list = tuya.get_all_devices() + load_devices(device_list) + + def poll_devices_update(event_time): + """Check if accesstoken is expired and pull device list from server.""" + _LOGGER.debug("Pull devices from Tuya.") + tuya.poll_devices_update() + # Add new discover device. + device_list = tuya.get_all_devices() + load_devices(device_list) + # Delete not exist device. + newlist_ids = [] + for device in device_list: + newlist_ids.append(device.object_id()) + for dev_id in list(hass.data[DOMAIN]['entities']): + if dev_id not in newlist_ids: + dispatcher_send(hass, SIGNAL_DELETE_ENTITY, dev_id) + hass.data[DOMAIN]['entities'].pop(dev_id) + + track_time_interval(hass, poll_devices_update, timedelta(minutes=5)) + + hass.services.register(DOMAIN, SERVICE_PULL_DEVICES, poll_devices_update) + + def force_update(call): + """Force all devices to pull data.""" + dispatcher_send(hass, SIGNAL_UPDATE_ENTITY) + + hass.services.register(DOMAIN, SERVICE_FORCE_UPDATE, force_update) + + return True + + +class TuyaDevice(Entity): + """Tuya base device.""" + + def __init__(self, tuya): + """Init Tuya devices.""" + self.tuya = tuya + + async def async_added_to_hass(self): + """Call when entity is added to hass.""" + dev_id = self.tuya.object_id() + self.hass.data[DOMAIN]['entities'][dev_id] = self.entity_id + async_dispatcher_connect( + self.hass, SIGNAL_DELETE_ENTITY, self._delete_callback) + async_dispatcher_connect( + self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback) + + @property + def object_id(self): + """Return Tuya device id.""" + return self.tuya.object_id() + + @property + def unique_id(self): + """Return a unique ID.""" + return 'tuya.{}'.format(self.tuya.object_id()) + + @property + def name(self): + """Return Tuya device name.""" + return self.tuya.name() + + @property + def available(self): + """Return if the device is available.""" + return self.tuya.available() + + def update(self): + """Refresh Tuya device data.""" + self.tuya.update() + + @callback + def _delete_callback(self, dev_id): + """Remove this entity.""" + if dev_id == self.object_id: + self.hass.async_add_job(self.async_remove()) + + @callback + def _update_callback(self): + """Call update method.""" + self.async_schedule_update_ha_state(True) diff --git a/homeassistant/components/upcloud.py b/homeassistant/components/upcloud.py index 9de7f6c444413e..0f503dcdc39032 100644 --- a/homeassistant/components/upcloud.py +++ b/homeassistant/components/upcloud.py @@ -92,7 +92,7 @@ def upcloud_update(event_time): return True -class UpCloud(object): +class UpCloud: """Handle all communication with the UpCloud API.""" def __init__(self, manager): diff --git a/homeassistant/components/updater.py b/homeassistant/components/updater.py index 9ccf280ed0468b..0cb22bd98dcd93 100644 --- a/homeassistant/components/updater.py +++ b/homeassistant/components/updater.py @@ -25,7 +25,7 @@ import homeassistant.helpers.config_validation as cv import homeassistant.util.dt as dt_util -REQUIREMENTS = ['distro==1.2.0'] +REQUIREMENTS = ['distro==1.3.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/upnp.py b/homeassistant/components/upnp.py index 8aeb93fed25f3f..b4fe9d3fce9c68 100644 --- a/homeassistant/components/upnp.py +++ b/homeassistant/components/upnp.py @@ -88,7 +88,7 @@ async def async_setup(hass, config): service = device.find_first_service(IP_SERVICE) if _service['serviceType'] == CIC_SERVICE: unit = config.get(CONF_UNITS) - hass.async_add_job(discovery.async_load_platform( + hass.async_create_task(discovery.async_load_platform( hass, 'sensor', DOMAIN, {'unit': unit}, config)) except UpnpSoapError as error: _LOGGER.error(error) diff --git a/homeassistant/components/usps.py b/homeassistant/components/usps.py index 364562f111937f..41aa240492bead 100644 --- a/homeassistant/components/usps.py +++ b/homeassistant/components/usps.py @@ -65,7 +65,7 @@ def setup(hass, config): return True -class USPSData(object): +class USPSData: """Stores the data retrieved from USPS. For each entity to use, acts as the single point responsible for fetching diff --git a/homeassistant/components/vacuum/__init__.py b/homeassistant/components/vacuum/__init__.py index 1b7d5685231602..97d009626b8248 100644 --- a/homeassistant/components/vacuum/__init__.py +++ b/homeassistant/components/vacuum/__init__.py @@ -14,12 +14,12 @@ from homeassistant.components import group from homeassistant.const import ( ATTR_BATTERY_LEVEL, ATTR_COMMAND, ATTR_ENTITY_ID, SERVICE_TOGGLE, - SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON) + SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_ON, STATE_PAUSED, STATE_IDLE) from homeassistant.loader import bind_hass import homeassistant.helpers.config_validation as cv from homeassistant.helpers.config_validation import PLATFORM_SCHEMA # noqa from homeassistant.helpers.entity_component import EntityComponent -from homeassistant.helpers.entity import ToggleEntity +from homeassistant.helpers.entity import (ToggleEntity, Entity) from homeassistant.helpers.icon import icon_for_battery_level _LOGGER = logging.getLogger(__name__) @@ -45,6 +45,8 @@ SERVICE_SEND_COMMAND = 'send_command' SERVICE_SET_FAN_SPEED = 'set_fan_speed' SERVICE_START_PAUSE = 'start_pause' +SERVICE_START = 'start' +SERVICE_PAUSE = 'pause' SERVICE_STOP = 'stop' VACUUM_SERVICE_SCHEMA = vol.Schema({ @@ -57,7 +59,7 @@ VACUUM_SEND_COMMAND_SERVICE_SCHEMA = VACUUM_SERVICE_SCHEMA.extend({ vol.Required(ATTR_COMMAND): cv.string, - vol.Optional(ATTR_PARAMS): vol.Any(cv.Dict, cv.ensure_list), + vol.Optional(ATTR_PARAMS): vol.Any(dict, cv.ensure_list), }) SERVICE_TO_METHOD = { @@ -65,6 +67,8 @@ SERVICE_TURN_OFF: {'method': 'async_turn_off'}, SERVICE_TOGGLE: {'method': 'async_toggle'}, SERVICE_START_PAUSE: {'method': 'async_start_pause'}, + SERVICE_START: {'method': 'async_start'}, + SERVICE_PAUSE: {'method': 'async_pause'}, SERVICE_RETURN_TO_BASE: {'method': 'async_return_to_base'}, SERVICE_CLEAN_SPOT: {'method': 'async_clean_spot'}, SERVICE_LOCATE: {'method': 'async_locate'}, @@ -75,6 +79,13 @@ 'schema': VACUUM_SEND_COMMAND_SERVICE_SCHEMA}, } +STATE_CLEANING = 'cleaning' +STATE_DOCKED = 'docked' +STATE_IDLE = STATE_IDLE +STATE_PAUSED = STATE_PAUSED +STATE_RETURNING = 'returning' +STATE_ERROR = 'error' + DEFAULT_NAME = 'Vacuum cleaner robot' SUPPORT_TURN_ON = 1 @@ -89,6 +100,8 @@ SUPPORT_LOCATE = 512 SUPPORT_CLEAN_SPOT = 1024 SUPPORT_MAP = 2048 +SUPPORT_STATE = 4096 +SUPPORT_START = 8192 @bind_hass @@ -147,6 +160,20 @@ def start_pause(hass, entity_id=None): hass.services.call(DOMAIN, SERVICE_START_PAUSE, data) +@bind_hass +def start(hass, entity_id=None): + """Tell all or specified vacuum to start or resume the current task.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else None + hass.services.call(DOMAIN, SERVICE_START, data) + + +@bind_hass +def pause(hass, entity_id=None): + """Tell all or the specified vacuum to pause the current task.""" + data = {ATTR_ENTITY_ID: entity_id} if entity_id else None + hass.services.call(DOMAIN, SERVICE_PAUSE, data) + + @bind_hass def stop(hass, entity_id=None): """Stop all or specified vacuum.""" @@ -208,33 +235,22 @@ def async_handle_vacuum_service(service): return True -class VacuumDevice(ToggleEntity): - """Representation of a vacuum cleaner robot.""" +class _BaseVacuum(Entity): + """Representation of a base vacuum. + + Contains common properties and functions for all vacuum devices. + """ @property def supported_features(self): """Flag vacuum cleaner features that are supported.""" raise NotImplementedError() - @property - def status(self): - """Return the status of the vacuum cleaner.""" - return None - @property def battery_level(self): """Return the battery level of the vacuum cleaner.""" return None - @property - def battery_icon(self): - """Return the battery icon for the vacuum cleaner.""" - charging = False - if self.status is not None: - charging = 'charg' in self.status.lower() - return icon_for_battery_level( - battery_level=self.battery_level, charging=charging) - @property def fan_speed(self): """Return the fan speed of the vacuum cleaner.""" @@ -245,6 +261,94 @@ def fan_speed_list(self): """Get the list of available fan speed steps of the vacuum cleaner.""" raise NotImplementedError() + def stop(self, **kwargs): + """Stop the vacuum cleaner.""" + raise NotImplementedError() + + async def async_stop(self, **kwargs): + """Stop the vacuum cleaner. + + This method must be run in the event loop. + """ + await self.hass.async_add_executor_job(partial(self.stop, **kwargs)) + + def return_to_base(self, **kwargs): + """Set the vacuum cleaner to return to the dock.""" + raise NotImplementedError() + + async def async_return_to_base(self, **kwargs): + """Set the vacuum cleaner to return to the dock. + + This method must be run in the event loop. + """ + await self.hass.async_add_executor_job( + partial(self.return_to_base, **kwargs)) + + def clean_spot(self, **kwargs): + """Perform a spot clean-up.""" + raise NotImplementedError() + + async def async_clean_spot(self, **kwargs): + """Perform a spot clean-up. + + This method must be run in the event loop. + """ + await self.hass.async_add_executor_job( + partial(self.clean_spot, **kwargs)) + + def locate(self, **kwargs): + """Locate the vacuum cleaner.""" + raise NotImplementedError() + + async def async_locate(self, **kwargs): + """Locate the vacuum cleaner. + + This method must be run in the event loop. + """ + await self.hass.async_add_executor_job(partial(self.locate, **kwargs)) + + def set_fan_speed(self, fan_speed, **kwargs): + """Set fan speed.""" + raise NotImplementedError() + + async def async_set_fan_speed(self, fan_speed, **kwargs): + """Set fan speed. + + This method must be run in the event loop. + """ + await self.hass.async_add_executor_job( + partial(self.set_fan_speed, fan_speed, **kwargs)) + + def send_command(self, command, params=None, **kwargs): + """Send a command to a vacuum cleaner.""" + raise NotImplementedError() + + async def async_send_command(self, command, params=None, **kwargs): + """Send a command to a vacuum cleaner. + + This method must be run in the event loop. + """ + await self.hass.async_add_executor_job( + partial(self.send_command, command, params=params, **kwargs)) + + +class VacuumDevice(_BaseVacuum, ToggleEntity): + """Representation of a vacuum cleaner robot.""" + + @property + def status(self): + """Return the status of the vacuum cleaner.""" + return None + + @property + def battery_icon(self): + """Return the battery icon for the vacuum cleaner.""" + charging = False + if self.status is not None: + charging = 'charg' in self.status.lower() + return icon_for_battery_level( + battery_level=self.battery_level, charging=charging) + @property def state_attributes(self): """Return the state attributes of the vacuum cleaner.""" @@ -267,100 +371,88 @@ def turn_on(self, **kwargs): """Turn the vacuum on and start cleaning.""" raise NotImplementedError() - def async_turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the vacuum on and start cleaning. - This method must be run in the event loop and returns a coroutine. + This method must be run in the event loop. """ - return self.hass.async_add_job(partial(self.turn_on, **kwargs)) + await self.hass.async_add_executor_job( + partial(self.turn_on, **kwargs)) def turn_off(self, **kwargs): """Turn the vacuum off stopping the cleaning and returning home.""" raise NotImplementedError() - def async_turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the vacuum off stopping the cleaning and returning home. - This method must be run in the event loop and returns a coroutine. + This method must be run in the event loop. """ - return self.hass.async_add_job(partial(self.turn_off, **kwargs)) + await self.hass.async_add_executor_job( + partial(self.turn_off, **kwargs)) - def return_to_base(self, **kwargs): - """Set the vacuum cleaner to return to the dock.""" - raise NotImplementedError() - - def async_return_to_base(self, **kwargs): - """Set the vacuum cleaner to return to the dock. - - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(partial(self.return_to_base, **kwargs)) - - def stop(self, **kwargs): - """Stop the vacuum cleaner.""" + def start_pause(self, **kwargs): + """Start, pause or resume the cleaning task.""" raise NotImplementedError() - def async_stop(self, **kwargs): - """Stop the vacuum cleaner. + async def async_start_pause(self, **kwargs): + """Start, pause or resume the cleaning task. - This method must be run in the event loop and returns a coroutine. + This method must be run in the event loop. """ - return self.hass.async_add_job(partial(self.stop, **kwargs)) + await self.hass.async_add_executor_job( + partial(self.start_pause, **kwargs)) - def clean_spot(self, **kwargs): - """Perform a spot clean-up.""" - raise NotImplementedError() - def async_clean_spot(self, **kwargs): - """Perform a spot clean-up. +class StateVacuumDevice(_BaseVacuum): + """Representation of a vacuum cleaner robot that supports states.""" - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(partial(self.clean_spot, **kwargs)) + @property + def state(self): + """Return the state of the vacuum cleaner.""" + return None - def locate(self, **kwargs): - """Locate the vacuum cleaner.""" - raise NotImplementedError() + @property + def battery_icon(self): + """Return the battery icon for the vacuum cleaner.""" + charging = bool(self.state == STATE_DOCKED) - def async_locate(self, **kwargs): - """Locate the vacuum cleaner. + return icon_for_battery_level( + battery_level=self.battery_level, charging=charging) - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job(partial(self.locate, **kwargs)) + @property + def state_attributes(self): + """Return the state attributes of the vacuum cleaner.""" + data = {} - def set_fan_speed(self, fan_speed, **kwargs): - """Set fan speed.""" - raise NotImplementedError() + if self.battery_level is not None: + data[ATTR_BATTERY_LEVEL] = self.battery_level + data[ATTR_BATTERY_ICON] = self.battery_icon - def async_set_fan_speed(self, fan_speed, **kwargs): - """Set fan speed. + if self.fan_speed is not None: + data[ATTR_FAN_SPEED] = self.fan_speed + data[ATTR_FAN_SPEED_LIST] = self.fan_speed_list - This method must be run in the event loop and returns a coroutine. - """ - return self.hass.async_add_job( - partial(self.set_fan_speed, fan_speed, **kwargs)) + return data - def start_pause(self, **kwargs): - """Start, pause or resume the cleaning task.""" + def start(self): + """Start or resume the cleaning task.""" raise NotImplementedError() - def async_start_pause(self, **kwargs): - """Start, pause or resume the cleaning task. + async def async_start(self): + """Start or resume the cleaning task. - This method must be run in the event loop and returns a coroutine. + This method must be run in the event loop. """ - return self.hass.async_add_job( - partial(self.start_pause, **kwargs)) + await self.hass.async_add_executor_job(self.start) - def send_command(self, command, params=None, **kwargs): - """Send a command to a vacuum cleaner.""" + def pause(self): + """Pause the cleaning task.""" raise NotImplementedError() - def async_send_command(self, command, params=None, **kwargs): - """Send a command to a vacuum cleaner. + async def async_pause(self): + """Pause the cleaning task. - This method must be run in the event loop and returns a coroutine. + This method must be run in the event loop. """ - return self.hass.async_add_job( - partial(self.send_command, command, params=params, **kwargs)) + await self.hass.async_add_executor_job(self.pause) diff --git a/homeassistant/components/vacuum/demo.py b/homeassistant/components/vacuum/demo.py index bd501167ffa8d7..5d4c6856a4dde2 100644 --- a/homeassistant/components/vacuum/demo.py +++ b/homeassistant/components/vacuum/demo.py @@ -10,7 +10,9 @@ ATTR_CLEANED_AREA, SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, SUPPORT_LOCATE, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, SUPPORT_SEND_COMMAND, SUPPORT_STATUS, SUPPORT_STOP, SUPPORT_TURN_OFF, - SUPPORT_TURN_ON, VacuumDevice) + SUPPORT_TURN_ON, SUPPORT_STATE, SUPPORT_START, STATE_CLEANING, + STATE_DOCKED, STATE_IDLE, STATE_PAUSED, STATE_RETURNING, VacuumDevice, + StateVacuumDevice) _LOGGER = logging.getLogger(__name__) @@ -28,12 +30,17 @@ SUPPORT_LOCATE | SUPPORT_STATUS | SUPPORT_BATTERY | \ SUPPORT_CLEAN_SPOT +SUPPORT_STATE_SERVICES = SUPPORT_STATE | SUPPORT_PAUSE | SUPPORT_STOP | \ + SUPPORT_RETURN_HOME | SUPPORT_FAN_SPEED | \ + SUPPORT_BATTERY | SUPPORT_CLEAN_SPOT | SUPPORT_START + FAN_SPEEDS = ['min', 'medium', 'high', 'max'] DEMO_VACUUM_COMPLETE = '0_Ground_floor' DEMO_VACUUM_MOST = '1_First_floor' DEMO_VACUUM_BASIC = '2_Second_floor' DEMO_VACUUM_MINIMAL = '3_Third_floor' DEMO_VACUUM_NONE = '4_Fourth_floor' +DEMO_VACUUM_STATE = '5_Fifth_floor' def setup_platform(hass, config, add_devices, discovery_info=None): @@ -44,13 +51,13 @@ def setup_platform(hass, config, add_devices, discovery_info=None): DemoVacuum(DEMO_VACUUM_BASIC, SUPPORT_BASIC_SERVICES), DemoVacuum(DEMO_VACUUM_MINIMAL, SUPPORT_MINIMAL_SERVICES), DemoVacuum(DEMO_VACUUM_NONE, 0), + StateDemoVacuum(DEMO_VACUUM_STATE), ]) class DemoVacuum(VacuumDevice): """Representation of a demo vacuum.""" - # pylint: disable=no-self-use def __init__(self, name, supported_features): """Initialize the vacuum.""" self._name = name @@ -205,3 +212,125 @@ def send_command(self, command, params=None, **kwargs): self._status = 'Executing {}({})'.format(command, params) self._state = True self.schedule_update_ha_state() + + +class StateDemoVacuum(StateVacuumDevice): + """Representation of a demo vacuum supporting states.""" + + def __init__(self, name): + """Initialize the vacuum.""" + self._name = name + self._supported_features = SUPPORT_STATE_SERVICES + self._state = STATE_DOCKED + self._fan_speed = FAN_SPEEDS[1] + self._cleaned_area = 0 + self._battery_level = 100 + + @property + def name(self): + """Return the name of the vacuum.""" + return self._name + + @property + def should_poll(self): + """No polling needed for a demo vacuum.""" + return False + + @property + def supported_features(self): + """Flag supported features.""" + return self._supported_features + + @property + def state(self): + """Return the current state of the vacuum.""" + return self._state + + @property + def battery_level(self): + """Return the current battery level of the vacuum.""" + if self.supported_features & SUPPORT_BATTERY == 0: + return + + return max(0, min(100, self._battery_level)) + + @property + def fan_speed(self): + """Return the current fan speed of the vacuum.""" + if self.supported_features & SUPPORT_FAN_SPEED == 0: + return + + return self._fan_speed + + @property + def fan_speed_list(self): + """Return the list of supported fan speeds.""" + if self.supported_features & SUPPORT_FAN_SPEED == 0: + return + return FAN_SPEEDS + + @property + def device_state_attributes(self): + """Return device state attributes.""" + return {ATTR_CLEANED_AREA: round(self._cleaned_area, 2)} + + def start(self): + """Start or resume the cleaning task.""" + if self.supported_features & SUPPORT_START == 0: + return + + if self._state != STATE_CLEANING: + self._state = STATE_CLEANING + self._cleaned_area += 1.32 + self._battery_level -= 1 + self.schedule_update_ha_state() + + def pause(self): + """Pause the cleaning task.""" + if self.supported_features & SUPPORT_PAUSE == 0: + return + + if self._state == STATE_CLEANING: + self._state = STATE_PAUSED + self.schedule_update_ha_state() + + def stop(self, **kwargs): + """Stop the cleaning task, do not return to dock.""" + if self.supported_features & SUPPORT_STOP == 0: + return + + self._state = STATE_IDLE + self.schedule_update_ha_state() + + def return_to_base(self, **kwargs): + """Return dock to charging base.""" + if self.supported_features & SUPPORT_RETURN_HOME == 0: + return + + self._state = STATE_RETURNING + self.schedule_update_ha_state() + + self.hass.loop.call_later(30, self.__set_state_to_dock) + + def clean_spot(self, **kwargs): + """Perform a spot clean-up.""" + if self.supported_features & SUPPORT_CLEAN_SPOT == 0: + return + + self._state = STATE_CLEANING + self._cleaned_area += 1.32 + self._battery_level -= 1 + self.schedule_update_ha_state() + + def set_fan_speed(self, fan_speed, **kwargs): + """Set the vacuum's fan speed.""" + if self.supported_features & SUPPORT_FAN_SPEED == 0: + return + + if fan_speed in self.fan_speed_list: + self._fan_speed = fan_speed + self.schedule_update_ha_state() + + def __set_state_to_dock(self): + self._state = STATE_DOCKED + self.schedule_update_ha_state() diff --git a/homeassistant/components/vacuum/mqtt.py b/homeassistant/components/vacuum/mqtt.py index ef3bb0f636b7ef..fd80f4cdbfbaf7 100644 --- a/homeassistant/components/vacuum/mqtt.py +++ b/homeassistant/components/vacuum/mqtt.py @@ -9,7 +9,7 @@ import voluptuous as vol -import homeassistant.components.mqtt as mqtt +from homeassistant.components import mqtt from homeassistant.components.mqtt import MqttAvailability from homeassistant.components.vacuum import ( SUPPORT_BATTERY, SUPPORT_CLEAN_SPOT, SUPPORT_FAN_SPEED, @@ -210,7 +210,6 @@ def async_setup_platform(hass, config, async_add_devices, discovery_info=None): class MqttVacuum(MqttAvailability, VacuumDevice): """Representation of a MQTT-controlled vacuum.""" - # pylint: disable=no-self-use def __init__( self, name, supported_features, qos, retain, command_topic, payload_turn_on, payload_turn_off, payload_return_to_base, diff --git a/homeassistant/components/vacuum/neato.py b/homeassistant/components/vacuum/neato.py index 9eba34cea321b3..224e763a097d9e 100644 --- a/homeassistant/components/vacuum/neato.py +++ b/homeassistant/components/vacuum/neato.py @@ -5,14 +5,15 @@ https://home-assistant.io/components/vacuum.neato/ """ import logging - +from datetime import timedelta import requests from homeassistant.const import STATE_OFF, STATE_ON from homeassistant.components.vacuum import ( VacuumDevice, SUPPORT_BATTERY, SUPPORT_PAUSE, SUPPORT_RETURN_HOME, SUPPORT_STATUS, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, - SUPPORT_MAP, ATTR_STATUS, ATTR_BATTERY_LEVEL, ATTR_BATTERY_ICON) + SUPPORT_MAP, ATTR_STATUS, ATTR_BATTERY_LEVEL, ATTR_BATTERY_ICON, + SUPPORT_LOCATE) from homeassistant.components.neato import ( NEATO_ROBOTS, NEATO_LOGIN, NEATO_MAP_DATA, ACTION, ERRORS, MODE, ALERTS) @@ -20,9 +21,11 @@ DEPENDENCIES = ['neato'] +SCAN_INTERVAL = timedelta(minutes=5) + SUPPORT_NEATO = SUPPORT_BATTERY | SUPPORT_PAUSE | SUPPORT_RETURN_HOME | \ SUPPORT_STOP | SUPPORT_TURN_OFF | SUPPORT_TURN_ON | \ - SUPPORT_STATUS | SUPPORT_MAP + SUPPORT_STATUS | SUPPORT_MAP | SUPPORT_LOCATE ATTR_CLEAN_START = 'clean_start' ATTR_CLEAN_STOP = 'clean_stop' @@ -94,10 +97,14 @@ def update(self): elif self._state['state'] == 4: self._status_state = ERRORS.get(self._state['error']) - if (self.robot.state['action'] == 1 or - self.robot.state['action'] == 2 or - self.robot.state['action'] == 3 and - self.robot.state['state'] == 2): + if (self._state['action'] == 1 or + self._state['action'] == 2 or + self._state['action'] == 3 and + self._state['state'] == 2): + self._clean_state = STATE_ON + elif (self._state['action'] == 11 or + self._state['action'] == 12 and + self._state['state'] == 2): self._clean_state = STATE_ON else: self._clean_state = STATE_OFF @@ -205,3 +212,7 @@ def start_pause(self, **kwargs): self.robot.pause_cleaning() if self._state['state'] == 3: self.robot.resume_cleaning() + + def locate(self, **kwargs): + """Locate the robot by making it emit a sound.""" + self.robot.locate() diff --git a/homeassistant/components/vacuum/roomba.py b/homeassistant/components/vacuum/roomba.py index 44d22e03f41624..750c2c0ae0abad 100644 --- a/homeassistant/components/vacuum/roomba.py +++ b/homeassistant/components/vacuum/roomba.py @@ -284,7 +284,9 @@ def async_update(self): software_version = state.get('softwareVer') # Error message in plain english - error_msg = self.vacuum.error_message + error_msg = 'None' + if hasattr(self.vacuum, 'error_message'): + error_msg = self.vacuum.error_message self._battery_level = state.get('batPct') self._status = self.vacuum.current_state diff --git a/homeassistant/components/vacuum/services.yaml b/homeassistant/components/vacuum/services.yaml index 863157074bce19..6e40b3d67fc8eb 100644 --- a/homeassistant/components/vacuum/services.yaml +++ b/homeassistant/components/vacuum/services.yaml @@ -35,6 +35,20 @@ start_pause: description: Name of the vacuum entity. example: 'vacuum.xiaomi_vacuum_cleaner' +start: + description: Start or resume the cleaning task. + fields: + entity_id: + description: Name of the vacuum entity. + example: 'vacuum.xiaomi_vacuum_cleaner' + +pause: + description: Pause the cleaning task. + fields: + entity_id: + description: Name of the vacuum entity. + example: 'vacuum.xiaomi_vacuum_cleaner' + return_to_base: description: Tell the vacuum cleaner to return to its dock. fields: diff --git a/homeassistant/components/vacuum/xiaomi_miio.py b/homeassistant/components/vacuum/xiaomi_miio.py index 620014a1baee71..f6789d78b9ae97 100644 --- a/homeassistant/components/vacuum/xiaomi_miio.py +++ b/homeassistant/components/vacuum/xiaomi_miio.py @@ -19,7 +19,7 @@ ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_TOKEN, STATE_OFF, STATE_ON) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-miio==0.3.9', 'construct==2.9.41'] +REQUIREMENTS = ['python-miio==0.4.0', 'construct==2.9.41'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/velbus.py b/homeassistant/components/velbus.py index ff2db955d31aae..8c9449169058a8 100644 --- a/homeassistant/components/velbus.py +++ b/homeassistant/components/velbus.py @@ -9,8 +9,10 @@ import homeassistant.helpers.config_validation as cv from homeassistant.const import EVENT_HOMEASSISTANT_STOP, CONF_PORT +from homeassistant.helpers.discovery import load_platform +from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['python-velbus==2.0.11'] +REQUIREMENTS = ['python-velbus==2.0.17'] _LOGGER = logging.getLogger(__name__) @@ -26,18 +28,76 @@ }, extra=vol.ALLOW_EXTRA) -def setup(hass, config): +async def async_setup(hass, config): """Set up the Velbus platform.""" import velbus port = config[DOMAIN].get(CONF_PORT) - connection = velbus.VelbusUSBConnection(port) - controller = velbus.Controller(connection) + controller = velbus.Controller(port) + hass.data[DOMAIN] = controller def stop_velbus(event): """Disconnect from serial port.""" _LOGGER.debug("Shutting down ") - connection.stop() + controller.stop() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, stop_velbus) + + def callback(): + modules = controller.get_modules() + discovery_info = { + 'switch': [], + 'binary_sensor': [] + } + for module in modules: + for channel in range(1, module.number_of_channels() + 1): + for category in discovery_info: + if category in module.get_categories(channel): + discovery_info[category].append(( + module.get_module_address(), + channel + )) + load_platform(hass, 'switch', DOMAIN, + discovery_info['switch'], config) + load_platform(hass, 'binary_sensor', DOMAIN, + discovery_info['binary_sensor'], config) + + controller.scan(callback) - hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, stop_velbus) return True + + +class VelbusEntity(Entity): + """Representation of a Velbus entity.""" + + def __init__(self, module, channel): + """Initialize a Velbus entity.""" + self._module = module + self._channel = channel + + @property + def unique_id(self): + """Get unique ID.""" + serial = 0 + if self._module.serial == 0: + serial = self._module.get_module_address() + else: + serial = self._module.serial + return "{}-{}".format(serial, self._channel) + + @property + def name(self): + """Return the display name of this entity.""" + return self._module.get_name(self._channel) + + @property + def should_poll(self): + """Disable polling.""" + return False + + async def async_added_to_hass(self): + """Add listener for state changes.""" + self._module.on_status_update(self._channel, self._on_update) + + def _on_update(self, state): + self.schedule_update_ha_state() diff --git a/homeassistant/components/velux.py b/homeassistant/components/velux.py index 47daf17f2a9254..c3c6c1e211400d 100644 --- a/homeassistant/components/velux.py +++ b/homeassistant/components/velux.py @@ -39,7 +39,7 @@ async def async_setup(hass, config): return False for component in SUPPORTED_DOMAINS: - hass.async_add_job( + hass.async_create_task( discovery.async_load_platform(hass, component, DOMAIN, {}, config)) return True diff --git a/homeassistant/components/vera.py b/homeassistant/components/vera.py index 5cc4de0d5ca9ea..5bc6260c0a71be 100644 --- a/homeassistant/components/vera.py +++ b/homeassistant/components/vera.py @@ -19,7 +19,7 @@ EVENT_HOMEASSISTANT_STOP, CONF_LIGHTS, CONF_EXCLUDE) from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyvera==0.2.42'] +REQUIREMENTS = ['pyvera==0.2.44'] _LOGGER = logging.getLogger(__name__) @@ -53,7 +53,6 @@ ] -# pylint: disable=unused-argument, too-many-function-args def setup(hass, base_config): """Set up for Vera devices.""" import pyvera as veraApi @@ -148,12 +147,10 @@ def __init__(self, vera_device, controller): slugify(vera_device.name), vera_device.device_id) self.controller.register(vera_device, self._update_callback) - self.update() def _update_callback(self, _device): """Update the state.""" - self.update() - self.schedule_update_ha_state() + self.schedule_update_ha_state(True) @property def name(self): diff --git a/homeassistant/components/verisure.py b/homeassistant/components/verisure.py index b367752c24783f..1f26ab639d6fc4 100644 --- a/homeassistant/components/verisure.py +++ b/homeassistant/components/verisure.py @@ -89,7 +89,7 @@ def capture_smartcam(service): return True -class VerisureHub(object): +class VerisureHub: """A Verisure hub wrapper class.""" def __init__(self, domain_config, verisure): diff --git a/homeassistant/components/volvooncall.py b/homeassistant/components/volvooncall.py index 6557be2fb1bebd..0ce8870bedfa05 100644 --- a/homeassistant/components/volvooncall.py +++ b/homeassistant/components/volvooncall.py @@ -136,12 +136,11 @@ def vehicle_name(self, vehicle): if (vehicle.registration_number and vehicle.registration_number.lower()) in self.names: return self.names[vehicle.registration_number.lower()] - elif (vehicle.vin and - vehicle.vin.lower() in self.names): + if vehicle.vin and vehicle.vin.lower() in self.names: return self.names[vehicle.vin.lower()] - elif vehicle.registration_number: + if vehicle.registration_number: return vehicle.registration_number - elif vehicle.vin: + if vehicle.vin: return vehicle.vin return '' diff --git a/homeassistant/components/vultr.py b/homeassistant/components/vultr.py index 59fc707bb28e5f..b28189444ee5eb 100644 --- a/homeassistant/components/vultr.py +++ b/homeassistant/components/vultr.py @@ -74,7 +74,7 @@ def setup(hass, config): return True -class Vultr(object): +class Vultr: """Handle all communication with the Vultr API.""" def __init__(self, api_key): diff --git a/homeassistant/components/watson_iot.py b/homeassistant/components/watson_iot.py new file mode 100644 index 00000000000000..889984eb223bf8 --- /dev/null +++ b/homeassistant/components/watson_iot.py @@ -0,0 +1,213 @@ +""" +A component which allows you to send data to the IBM Watson IoT Platform. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/watson_iot/ +""" +import logging +import queue +import threading +import time + +import voluptuous as vol + +from homeassistant.const import ( + CONF_DOMAINS, CONF_ENTITIES, CONF_EXCLUDE, CONF_ID, CONF_INCLUDE, + CONF_TOKEN, CONF_TYPE, EVENT_HOMEASSISTANT_STOP, EVENT_STATE_CHANGED, + STATE_UNAVAILABLE, STATE_UNKNOWN) +from homeassistant.helpers import state as state_helper +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['ibmiotf==0.3.4'] + +_LOGGER = logging.getLogger(__name__) + +CONF_ORG = 'organization' + +DOMAIN = 'watson_iot' + +MAX_TRIES = 3 + +RETRY_DELAY = 20 + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All(vol.Schema({ + vol.Required(CONF_ORG): cv.string, + vol.Required(CONF_TYPE): cv.string, + vol.Required(CONF_ID): cv.string, + vol.Required(CONF_TOKEN): cv.string, + vol.Optional(CONF_EXCLUDE, default={}): vol.Schema({ + vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, + vol.Optional(CONF_DOMAINS, default=[]): + vol.All(cv.ensure_list, [cv.string]) + }), + vol.Optional(CONF_INCLUDE, default={}): vol.Schema({ + vol.Optional(CONF_ENTITIES, default=[]): cv.entity_ids, + vol.Optional(CONF_DOMAINS, default=[]): + vol.All(cv.ensure_list, [cv.string]) + }), + })), +}, extra=vol.ALLOW_EXTRA) + + +def setup(hass, config): + """Set up the Watson IoT Platform component.""" + from ibmiotf import gateway + + conf = config[DOMAIN] + + include = conf[CONF_INCLUDE] + exclude = conf[CONF_EXCLUDE] + whitelist_e = set(include[CONF_ENTITIES]) + whitelist_d = set(include[CONF_DOMAINS]) + blacklist_e = set(exclude[CONF_ENTITIES]) + blacklist_d = set(exclude[CONF_DOMAINS]) + + client_args = { + 'org': conf[CONF_ORG], + 'type': conf[CONF_TYPE], + 'id': conf[CONF_ID], + 'auth-method': 'token', + 'auth-token': conf[CONF_TOKEN], + } + watson_gateway = gateway.Client(client_args) + + def event_to_json(event): + """Add an event to the outgoing list.""" + state = event.data.get('new_state') + if state is None or state.state in ( + STATE_UNKNOWN, '', STATE_UNAVAILABLE) or \ + state.entity_id in blacklist_e or state.domain in blacklist_d: + return + + if (whitelist_e and state.entity_id not in whitelist_e) or \ + (whitelist_d and state.domain not in whitelist_d): + return + + try: + _state_as_value = float(state.state) + except ValueError: + _state_as_value = None + + if _state_as_value is None: + try: + _state_as_value = float(state_helper.state_as_number(state)) + except ValueError: + _state_as_value = None + + out_event = { + 'tags': { + 'domain': state.domain, + 'entity_id': state.object_id, + }, + 'time': event.time_fired.isoformat(), + 'fields': { + 'state': state.state, + } + } + if _state_as_value is not None: + out_event['fields']['state_value'] = _state_as_value + + for key, value in state.attributes.items(): + if key != 'unit_of_measurement': + # If the key is already in fields + if key in out_event['fields']: + key = '{}_'.format(key) + # For each value we try to cast it as float + # But if we can not do it we store the value + # as string + try: + out_event['fields'][key] = float(value) + except (ValueError, TypeError): + out_event['fields'][key] = str(value) + + return out_event + + instance = hass.data[DOMAIN] = WatsonIOTThread( + hass, watson_gateway, event_to_json) + instance.start() + + def shutdown(event): + """Shut down the thread.""" + instance.queue.put(None) + instance.join() + + hass.bus.listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) + + return True + + +class WatsonIOTThread(threading.Thread): + """A threaded event handler class.""" + + def __init__(self, hass, gateway, event_to_json): + """Initialize the listener.""" + threading.Thread.__init__(self, name='WatsonIOT') + self.queue = queue.Queue() + self.gateway = gateway + self.gateway.connect() + self.event_to_json = event_to_json + self.write_errors = 0 + self.shutdown = False + hass.bus.listen(EVENT_STATE_CHANGED, self._event_listener) + + def _event_listener(self, event): + """Listen for new messages on the bus and queue them for Watson IoT.""" + item = (time.monotonic(), event) + self.queue.put(item) + + def get_events_json(self): + """Return an event formatted for writing.""" + events = [] + + try: + item = self.queue.get() + + if item is None: + self.shutdown = True + else: + event_json = self.event_to_json(item[1]) + if event_json: + events.append(event_json) + + except queue.Empty: + pass + + return events + + def write_to_watson(self, events): + """Write preprocessed events to watson.""" + import ibmiotf + + for event in events: + for retry in range(MAX_TRIES + 1): + try: + for field in event['fields']: + value = event['fields'][field] + device_success = self.gateway.publishDeviceEvent( + event['tags']['domain'], + event['tags']['entity_id'], + field, 'json', value) + if not device_success: + _LOGGER.error( + "Failed to publish message to Watson IoT") + continue + break + except (ibmiotf.MissingMessageEncoderException, IOError): + if retry < MAX_TRIES: + time.sleep(RETRY_DELAY) + else: + _LOGGER.exception( + "Failed to publish message to Watson IoT") + + def run(self): + """Process incoming events.""" + while not self.shutdown: + event = self.get_events_json() + if event: + self.write_to_watson(event) + self.queue.task_done() + + def block_till_done(self): + """Block till all events processed.""" + self.queue.join() diff --git a/homeassistant/components/weather/__init__.py b/homeassistant/components/weather/__init__.py index c36c960c4fcf4f..a43999f2276255 100644 --- a/homeassistant/components/weather/__init__.py +++ b/homeassistant/components/weather/__init__.py @@ -46,7 +46,6 @@ def async_setup(hass, config): return True -# pylint: disable=no-member, no-self-use class WeatherEntity(Entity): """ABC for weather data.""" diff --git a/homeassistant/components/weather/bom.py b/homeassistant/components/weather/bom.py index 236aeb2fa2ed5f..ad74bb4fb7777b 100644 --- a/homeassistant/components/weather/bom.py +++ b/homeassistant/components/weather/bom.py @@ -48,7 +48,7 @@ class BOMWeather(WeatherEntity): def __init__(self, bom_data, stationname=None): """Initialise the platform with a data instance and station name.""" self.bom_data = bom_data - self.stationname = stationname or self.bom_data.data.get('name') + self.stationname = stationname or self.bom_data.latest_data.get('name') def update(self): """Update current conditions.""" @@ -62,14 +62,14 @@ def name(self): @property def condition(self): """Return the current condition.""" - return self.bom_data.data.get('weather') + return self.bom_data.get_reading('weather') # Now implement the WeatherEntity interface @property def temperature(self): """Return the platform temperature.""" - return self.bom_data.data.get('air_temp') + return self.bom_data.get_reading('air_temp') @property def temperature_unit(self): @@ -79,17 +79,17 @@ def temperature_unit(self): @property def pressure(self): """Return the mean sea-level pressure.""" - return self.bom_data.data.get('press_msl') + return self.bom_data.get_reading('press_msl') @property def humidity(self): """Return the relative humidity.""" - return self.bom_data.data.get('rel_hum') + return self.bom_data.get_reading('rel_hum') @property def wind_speed(self): """Return the wind speed.""" - return self.bom_data.data.get('wind_spd_kmh') + return self.bom_data.get_reading('wind_spd_kmh') @property def wind_bearing(self): @@ -99,7 +99,7 @@ def wind_bearing(self): 'S', 'SSW', 'SW', 'WSW', 'W', 'WNW', 'NW', 'NNW'] wind = {name: idx * 360 / 16 for idx, name in enumerate(directions)} - return wind.get(self.bom_data.data.get('wind_dir')) + return wind.get(self.bom_data.get_reading('wind_dir')) @property def attribution(self): diff --git a/homeassistant/components/weather/darksky.py b/homeassistant/components/weather/darksky.py index f0712542ea544b..6dac22bc941a2a 100644 --- a/homeassistant/components/weather/darksky.py +++ b/homeassistant/components/weather/darksky.py @@ -12,7 +12,8 @@ import voluptuous as vol from homeassistant.components.weather import ( - ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME, PLATFORM_SCHEMA, WeatherEntity) + ATTR_FORECAST_TEMP, ATTR_FORECAST_TIME, ATTR_FORECAST_CONDITION, + PLATFORM_SCHEMA, WeatherEntity) from homeassistant.const import ( CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, TEMP_CELSIUS, TEMP_FAHRENHEIT) @@ -25,6 +26,22 @@ ATTRIBUTION = "Powered by Dark Sky" +MAP_CONDITION = { + 'clear-day': 'sunny', + 'clear-night': 'clear-night', + 'rain': 'rainy', + 'snow': 'snowy', + 'sleet': 'snowy-rainy', + 'wind': 'windy', + 'fog': 'fog', + 'cloudy': 'cloudy', + 'partly-cloudy-day': 'partlycloudy', + 'partly-cloudy-night': 'partlycloudy', + 'hail': 'hail', + 'thunderstorm': 'lightning', + 'tornado': None, +} + CONF_UNITS = 'units' DEFAULT_NAME = 'Dark Sky' @@ -108,7 +125,7 @@ def pressure(self): @property def condition(self): """Return the weather condition.""" - return self._ds_currently.get('summary') + return MAP_CONDITION.get(self._ds_currently.get('icon')) @property def forecast(self): @@ -116,8 +133,11 @@ def forecast(self): return [{ ATTR_FORECAST_TIME: datetime.fromtimestamp(entry.d.get('time')).isoformat(), - ATTR_FORECAST_TEMP: entry.d.get('temperature')} - for entry in self._ds_hourly.data] + ATTR_FORECAST_TEMP: + entry.d.get('temperature'), + ATTR_FORECAST_CONDITION: + MAP_CONDITION.get(entry.d.get('icon')) + } for entry in self._ds_hourly.data] def update(self): """Get the latest data from Dark Sky.""" @@ -129,7 +149,7 @@ def update(self): self._ds_daily = self._dark_sky.daily -class DarkSkyData(object): +class DarkSkyData: """Get the latest data from Dark Sky.""" def __init__(self, api_key, latitude, longitude, units): diff --git a/homeassistant/components/weather/ecobee.py b/homeassistant/components/weather/ecobee.py index 80ee4c29fbe880..59737c578a5915 100644 --- a/homeassistant/components/weather/ecobee.py +++ b/homeassistant/components/weather/ecobee.py @@ -4,6 +4,7 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/weather.ecobee/ """ +from datetime import datetime from homeassistant.components import ecobee from homeassistant.components.weather import ( WeatherEntity, ATTR_FORECAST_CONDITION, ATTR_FORECAST_TEMP, @@ -134,8 +135,10 @@ def forecast(self): try: forecasts = [] for day in self.weather['forecasts']: + date_time = datetime.strptime(day['dateTime'], + '%Y-%m-%d %H:%M:%S').isoformat() forecast = { - ATTR_FORECAST_TIME: day['dateTime'], + ATTR_FORECAST_TIME: date_time, ATTR_FORECAST_CONDITION: day['condition'], ATTR_FORECAST_TEMP: float(day['tempHigh']) / 10, } diff --git a/homeassistant/components/weather/ipma.py b/homeassistant/components/weather/ipma.py new file mode 100644 index 00000000000000..ef4f1b349d72a5 --- /dev/null +++ b/homeassistant/components/weather/ipma.py @@ -0,0 +1,172 @@ +""" +Support for IPMA weather service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/weather.ipma/ +""" +import logging +from datetime import timedelta + +import async_timeout +import voluptuous as vol + +from homeassistant.components.weather import ( + WeatherEntity, PLATFORM_SCHEMA, ATTR_FORECAST_CONDITION, + ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_TEMP, + ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME) +from homeassistant.const import \ + CONF_NAME, TEMP_CELSIUS, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers import config_validation as cv +from homeassistant.util import Throttle + +REQUIREMENTS = ['pyipma==1.1.3'] + +_LOGGER = logging.getLogger(__name__) + +ATTRIBUTION = 'Instituto Português do Mar e Atmosfera' + +ATTR_WEATHER_DESCRIPTION = "description" + +MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=30) + +CONDITION_CLASSES = { + 'cloudy': [4, 5, 24, 25, 27], + 'fog': [16, 17, 26], + 'hail': [21, 22], + 'lightning': [19], + 'lightning-rainy': [20, 23], + 'partlycloudy': [2, 3], + 'pouring': [8, 11], + 'rainy': [6, 7, 9, 10, 12, 13, 14, 15], + 'snowy': [18], + 'snowy-rainy': [], + 'sunny': [1], + 'windy': [], + 'windy-variant': [], + 'exceptional': [], +} + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, +}) + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the ipma platform.""" + latitude = config.get(CONF_LATITUDE, hass.config.latitude) + longitude = config.get(CONF_LONGITUDE, hass.config.longitude) + + if None in (latitude, longitude): + _LOGGER.error("Latitude or longitude not set in Home Assistant config") + return + + from pyipma import Station + + websession = async_get_clientsession(hass) + with async_timeout.timeout(10, loop=hass.loop): + station = await Station.get(websession, float(latitude), + float(longitude)) + + _LOGGER.debug("Initializing ipma weather: coordinates %s, %s", + latitude, longitude) + + async_add_devices([IPMAWeather(station, config)], True) + + +class IPMAWeather(WeatherEntity): + """Representation of a weather condition.""" + + def __init__(self, station, config): + """Initialise the platform with a data instance and station name.""" + self._station_name = config.get(CONF_NAME, station.local) + self._station = station + self._condition = None + self._forecast = None + self._description = None + + @Throttle(MIN_TIME_BETWEEN_UPDATES) + async def async_update(self): + """Update Condition and Forecast.""" + with async_timeout.timeout(10, loop=self.hass.loop): + self._condition = await self._station.observation() + self._forecast = await self._station.forecast() + self._description = self._forecast[0].description + + @property + def attribution(self): + """Return the attribution.""" + return ATTRIBUTION + + @property + def name(self): + """Return the name of the station.""" + return self._station_name + + @property + def condition(self): + """Return the current condition.""" + return next((k for k, v in CONDITION_CLASSES.items() + if self._forecast[0].idWeatherType in v), None) + + @property + def temperature(self): + """Return the current temperature.""" + return self._condition.temperature + + @property + def pressure(self): + """Return the current pressure.""" + return self._condition.pressure + + @property + def humidity(self): + """Return the name of the sensor.""" + return self._condition.humidity + + @property + def wind_speed(self): + """Return the current windspeed.""" + return self._condition.windspeed + + @property + def wind_bearing(self): + """Return the current wind bearing (degrees).""" + return self._condition.winddirection + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def forecast(self): + """Return the forecast array.""" + if self._forecast: + fcdata_out = [] + for data_in in self._forecast: + data_out = {} + data_out[ATTR_FORECAST_TIME] = data_in.forecastDate + data_out[ATTR_FORECAST_CONDITION] =\ + next((k for k, v in CONDITION_CLASSES.items() + if int(data_in.idWeatherType) in v), None) + data_out[ATTR_FORECAST_TEMP_LOW] = data_in.tMin + data_out[ATTR_FORECAST_TEMP] = data_in.tMax + data_out[ATTR_FORECAST_PRECIPITATION] = data_in.precipitaProb + + fcdata_out.append(data_out) + + return fcdata_out + + @property + def device_state_attributes(self): + """Return the state attributes.""" + data = dict() + + if self._description: + data[ATTR_WEATHER_DESCRIPTION] = self._description + + return data diff --git a/homeassistant/components/weather/openweathermap.py b/homeassistant/components/weather/openweathermap.py index 909f123b52c28b..46a0b3ecc14f3b 100644 --- a/homeassistant/components/weather/openweathermap.py +++ b/homeassistant/components/weather/openweathermap.py @@ -11,31 +11,36 @@ from homeassistant.components.weather import ( ATTR_FORECAST_CONDITION, ATTR_FORECAST_PRECIPITATION, ATTR_FORECAST_TEMP, - ATTR_FORECAST_TIME, PLATFORM_SCHEMA, WeatherEntity) + ATTR_FORECAST_TEMP_LOW, ATTR_FORECAST_TIME, PLATFORM_SCHEMA, WeatherEntity) from homeassistant.const import ( - CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, STATE_UNKNOWN, - TEMP_CELSIUS) + CONF_API_KEY, TEMP_CELSIUS, CONF_LATITUDE, CONF_LONGITUDE, CONF_MODE, + CONF_NAME, STATE_UNKNOWN) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -REQUIREMENTS = ['pyowm==2.8.0'] +REQUIREMENTS = ['pyowm==2.9.0'] _LOGGER = logging.getLogger(__name__) +ATTR_FORECAST_WIND_SPEED = 'wind_speed' +ATTR_FORECAST_WIND_BEARING = 'wind_bearing' + ATTRIBUTION = 'Data provided by OpenWeatherMap' +FORECAST_MODE = ['hourly', 'daily'] + DEFAULT_NAME = 'OpenWeatherMap' MIN_TIME_BETWEEN_FORECAST_UPDATES = timedelta(minutes=30) MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=10) CONDITION_CLASSES = { - 'cloudy': [804], + 'cloudy': [803, 804], 'fog': [701, 741], 'hail': [906], 'lightning': [210, 211, 212, 221], 'lightning-rainy': [200, 201, 202, 230, 231, 232], - 'partlycloudy': [801, 802, 803], + 'partlycloudy': [801, 802], 'pouring': [504, 314, 502, 503, 522], 'rainy': [300, 301, 302, 310, 311, 312, 313, 500, 501, 520, 521], 'snowy': [600, 601, 602, 611, 612, 620, 621, 622], @@ -51,6 +56,7 @@ vol.Required(CONF_API_KEY): cv.string, vol.Optional(CONF_LATITUDE): cv.latitude, vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_MODE, default='hourly'): vol.In(FORECAST_MODE), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, }) @@ -62,6 +68,7 @@ def setup_platform(hass, config, add_devices, discovery_info=None): longitude = config.get(CONF_LONGITUDE, round(hass.config.longitude, 5)) latitude = config.get(CONF_LATITUDE, round(hass.config.latitude, 5)) name = config.get(CONF_NAME) + mode = config.get(CONF_MODE) try: owm = pyowm.OWM(config.get(CONF_API_KEY)) @@ -69,20 +76,21 @@ def setup_platform(hass, config, add_devices, discovery_info=None): _LOGGER.error("Error while connecting to OpenWeatherMap") return False - data = WeatherData(owm, latitude, longitude) + data = WeatherData(owm, latitude, longitude, mode) add_devices([OpenWeatherMapWeather( - name, data, hass.config.units.temperature_unit)], True) + name, data, hass.config.units.temperature_unit, mode)], True) class OpenWeatherMapWeather(WeatherEntity): """Implementation of an OpenWeatherMap sensor.""" - def __init__(self, name, owm, temperature_unit): + def __init__(self, name, owm, temperature_unit, mode): """Initialize the sensor.""" self._name = name self._owm = owm self._temperature_unit = temperature_unit + self._mode = mode self.data = None self.forecast_data = None @@ -123,7 +131,7 @@ def humidity(self): @property def wind_speed(self): """Return the wind speed.""" - return self.data.get_wind().get('speed') + return round(self.data.get_wind().get('speed') * 3.6, 2) @property def wind_bearing(self): @@ -140,15 +148,39 @@ def forecast(self): """Return the forecast array.""" data = [] for entry in self.forecast_data.get_weathers(): - data.append({ - ATTR_FORECAST_TIME: entry.get_reference_time('unix') * 1000, - ATTR_FORECAST_TEMP: - entry.get_temperature('celsius').get('temp'), - ATTR_FORECAST_PRECIPITATION: entry.get_rain().get('3h'), - ATTR_FORECAST_CONDITION: - [k for k, v in CONDITION_CLASSES.items() - if entry.get_weather_code() in v][0] - }) + if self._mode == 'daily': + data.append({ + ATTR_FORECAST_TIME: + entry.get_reference_time('unix') * 1000, + ATTR_FORECAST_TEMP: + entry.get_temperature('celsius').get('day'), + ATTR_FORECAST_TEMP_LOW: + entry.get_temperature('celsius').get('night'), + ATTR_FORECAST_PRECIPITATION: + entry.get_rain().get('all'), + ATTR_FORECAST_WIND_SPEED: + entry.get_wind().get('speed'), + ATTR_FORECAST_WIND_BEARING: + entry.get_wind().get('deg'), + ATTR_FORECAST_CONDITION: + [k for k, v in CONDITION_CLASSES.items() + if entry.get_weather_code() in v][0] + }) + else: + data.append({ + ATTR_FORECAST_TIME: + entry.get_reference_time('unix') * 1000, + ATTR_FORECAST_TEMP: + entry.get_temperature('celsius').get('temp'), + ATTR_FORECAST_PRECIPITATION: + (round(entry.get_rain().get('3h'), 1) + if entry.get_rain().get('3h') is not None + and (round(entry.get_rain().get('3h'), 1) > 0) + else None), + ATTR_FORECAST_CONDITION: + [k for k, v in CONDITION_CLASSES.items() + if entry.get_weather_code() in v][0] + }) return data def update(self): @@ -166,11 +198,12 @@ def update(self): self.forecast_data = self._owm.forecast_data -class WeatherData(object): +class WeatherData: """Get the latest data from OpenWeatherMap.""" - def __init__(self, owm, latitude, longitude): + def __init__(self, owm, latitude, longitude, mode): """Initialize the data object.""" + self._mode = mode self.owm = owm self.latitude = latitude self.longitude = longitude @@ -193,8 +226,12 @@ def update_forecast(self): from pyowm.exceptions.api_call_error import APICallError try: - fcd = self.owm.three_hours_forecast_at_coords( - self.latitude, self.longitude) + if self._mode == 'daily': + fcd = self.owm.daily_forecast_at_coords( + self.latitude, self.longitude, 15) + else: + fcd = self.owm.three_hours_forecast_at_coords( + self.latitude, self.longitude) except APICallError: _LOGGER.error("Exception when calling OWM web API " "to update forecast") diff --git a/homeassistant/components/weather/yweather.py b/homeassistant/components/weather/yweather.py index f9befece5a4690..3f12195d6bfffc 100644 --- a/homeassistant/components/weather/yweather.py +++ b/homeassistant/components/weather/yweather.py @@ -175,7 +175,7 @@ def update(self): return -class YahooWeatherData(object): +class YahooWeatherData: """Handle the Yahoo! API object and limit updates.""" def __init__(self, woeid, temp_unit): diff --git a/homeassistant/components/websocket_api.py b/homeassistant/components/websocket_api.py index 4989f4f0db27ca..532f3672df478a 100644 --- a/homeassistant/components/websocket_api.py +++ b/homeassistant/components/websocket_api.py @@ -7,7 +7,7 @@ import asyncio from concurrent import futures from contextlib import suppress -from functools import partial +from functools import partial, wraps import json import logging @@ -18,7 +18,7 @@ from homeassistant.const import ( MATCH_ALL, EVENT_TIME_CHANGED, EVENT_HOMEASSISTANT_STOP, __version__) -from homeassistant.core import callback +from homeassistant.core import Context, callback from homeassistant.loader import bind_hass from homeassistant.remote import JSONEncoder from homeassistant.helpers import config_validation as cv @@ -26,7 +26,8 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.auth import validate_password from homeassistant.components.http.const import KEY_AUTHENTICATED -from homeassistant.components.http.ban import process_wrong_login +from homeassistant.components.http.ban import process_wrong_login, \ + process_success_login DOMAIN = 'websocket_api' @@ -38,6 +39,7 @@ ERR_ID_REUSE = 1 ERR_INVALID_FORMAT = 2 ERR_NOT_FOUND = 3 +ERR_UNKNOWN_COMMAND = 4 TYPE_AUTH = 'auth' TYPE_AUTH_INVALID = 'auth_invalid' @@ -60,7 +62,8 @@ AUTH_MESSAGE_SCHEMA = vol.Schema({ vol.Required('type'): TYPE_AUTH, - vol.Required('api_password'): str, + vol.Exclusive('api_password', 'auth'): str, + vol.Exclusive('access_token', 'auth'): str, }) # Minimal requirements of a message @@ -194,6 +197,23 @@ def async_register_command(hass, command, handler, schema): handlers[command] = (handler, schema) +def require_owner(func): + """Websocket decorator to require user to be an owner.""" + @wraps(func) + def with_owner(hass, connection, msg): + """Check owner and call function.""" + user = connection.request.get('hass_user') + + if user is None or not user.is_owner: + connection.to_write.put_nowait(error_message( + msg['id'], 'unauthorized', 'This command is for owners only.')) + return + + func(hass, connection, msg) + + return with_owner + + async def async_setup(hass, config): """Initialize the websocket API.""" hass.http.register_view(WebsocketAPIView) @@ -226,7 +246,6 @@ class WebsocketAPIView(HomeAssistantView): async def get(self, request): """Handle an incoming websocket connection.""" - # pylint: disable=no-self-use return await ActiveConnection(request.app['hass'], request).handle() @@ -243,6 +262,18 @@ def __init__(self, hass, request): self._handle_task = None self._writer_task = None + @property + def user(self): + """Return the user associated with the connection.""" + return self.request.get('hass_user') + + def context(self, msg): + """Return a context.""" + user = self.user + if user is None: + return Context() + return Context(user_id=user.id) + def debug(self, message1, message2=''): """Print a debug message.""" _LOGGER.debug("WS %s: %s %s", id(self.wsock), message1, message2) @@ -268,7 +299,7 @@ async def _writer(self): @callback def send_message_outside(self, message): - """Send a message to the client outside of the main task. + """Send a message to the client. Closes connection if the client is not reading the messages. @@ -314,22 +345,36 @@ def handle_hass_stop(event): authenticated = True else: + self.debug("Request auth") await self.wsock.send_json(auth_required_message()) msg = await wsock.receive_json() msg = AUTH_MESSAGE_SCHEMA(msg) - if validate_password(request, msg['api_password']): - authenticated = True - - else: - self.debug("Invalid password") - await self.wsock.send_json( - auth_invalid_message('Invalid password')) + if self.hass.auth.active and 'access_token' in msg: + self.debug("Received access_token") + refresh_token = \ + await self.hass.auth.async_validate_access_token( + msg['access_token']) + authenticated = refresh_token is not None + if authenticated: + request['hass_user'] = refresh_token.user + + elif ((not self.hass.auth.active or + self.hass.auth.support_legacy) and + 'api_password' in msg): + self.debug("Received api_password") + authenticated = validate_password( + request, msg['api_password']) if not authenticated: + self.debug("Authorization failed") + await self.wsock.send_json( + auth_invalid_message('Invalid access token or password')) await process_wrong_login(request) return wsock + self.debug("Auth OK") + await process_success_login(request) await self.wsock.send_json(auth_ok_message()) # ---------- AUTH PHASE OVER ---------- @@ -349,8 +394,11 @@ def handle_hass_stop(event): 'Identifier values have to increase.')) elif msg['type'] not in handlers: - # Unknown command - break + self.log_error( + 'Received invalid command: {}'.format(msg['type'])) + self.to_write.put_nowait(error_message( + cur_id, ERR_UNKNOWN_COMMAND, + 'Unknown command.')) else: handler, schema = handlers[msg['type']] @@ -384,7 +432,7 @@ def handle_hass_stop(event): if wsock.closed: self.debug("Connection closed by client") else: - _LOGGER.exception("Unexpected TypeError: %s", msg) + _LOGGER.exception("Unexpected TypeError: %s", err) except ValueError as err: msg = "Received invalid JSON" @@ -395,7 +443,7 @@ def handle_hass_stop(event): self._writer_task.cancel() except CANCELLATION_ERRORS: - self.debug("Connection cancelled by server") + self.debug("Connection cancelled") except asyncio.QueueFull: self.log_error("Client exceeded max pending messages [1]:", @@ -472,8 +520,13 @@ def handle_call_service(hass, connection, msg): """ async def call_service_helper(msg): """Call a service and fire complete message.""" + blocking = True + if (msg['domain'] == 'homeassistant' and + msg['service'] in ['restart', 'stop']): + blocking = False await hass.services.async_call( - msg['domain'], msg['service'], msg.get('service_data'), True) + msg['domain'], msg['service'], msg.get('service_data'), blocking, + connection.context(msg)) connection.send_message_outside(result_message(msg['id'])) hass.async_add_job(call_service_helper(msg)) diff --git a/homeassistant/components/wemo.py b/homeassistant/components/wemo.py index 9929b64be7dacc..27027cc9eb4ec5 100644 --- a/homeassistant/components/wemo.py +++ b/homeassistant/components/wemo.py @@ -14,7 +14,7 @@ from homeassistant.const import EVENT_HOMEASSISTANT_STOP -REQUIREMENTS = ['pywemo==0.4.25'] +REQUIREMENTS = ['pywemo==0.4.28'] DOMAIN = 'wemo' @@ -26,6 +26,7 @@ 'Insight': 'switch', 'LightSwitch': 'switch', 'Maker': 'switch', + 'Motion': 'binary_sensor', 'Sensor': 'binary_sensor', 'Socket': 'switch' } @@ -44,7 +45,6 @@ }, extra=vol.ALLOW_EXTRA) -# pylint: disable=unused-argument, too-many-function-args def setup(hass, config): """Set up for WeMo devices.""" import pywemo diff --git a/homeassistant/components/wink/__init__.py b/homeassistant/components/wink/__init__.py index eab67c18aedc36..c996572bf510e5 100644 --- a/homeassistant/components/wink/__init__.py +++ b/homeassistant/components/wink/__init__.py @@ -15,7 +15,7 @@ from homeassistant.components.http import HomeAssistantView from homeassistant.const import ( - ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID, CONF_EMAIL, CONF_PASSWORD, + ATTR_BATTERY_LEVEL, ATTR_ENTITY_ID, ATTR_NAME, CONF_EMAIL, CONF_PASSWORD, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, STATE_OFF, STATE_ON, __version__) from homeassistant.core import callback @@ -26,7 +26,7 @@ from homeassistant.helpers.event import track_time_interval from homeassistant.util.json import load_json, save_json -REQUIREMENTS = ['python-wink==1.7.3', 'pubnubsub-handler==1.0.2'] +REQUIREMENTS = ['python-wink==1.9.1', 'pubnubsub-handler==1.0.2'] _LOGGER = logging.getLogger(__name__) @@ -45,7 +45,6 @@ ATTR_REFRESH_TOKEN = 'refresh_token' ATTR_CLIENT_ID = 'client_id' ATTR_CLIENT_SECRET = 'client_secret' -ATTR_NAME = 'name' ATTR_PAIRING_MODE = 'pairing_mode' ATTR_KIDDE_RADIO_CODE = 'kidde_radio_code' ATTR_HUB_NAME = 'hub_name' @@ -53,7 +52,8 @@ WINK_AUTH_CALLBACK_PATH = '/auth/wink/callback' WINK_AUTH_START = '/auth/wink' WINK_CONFIG_FILE = '.wink.conf' -USER_AGENT = "Manufacturer/Home-Assistant%s python/3 Wink/3" % __version__ +USER_AGENT = "Manufacturer/Home-Assistant{} python/3 Wink/3".format( + __version__) DEFAULT_CONFIG = { 'client_id': 'CLIENT_ID_HERE', @@ -173,10 +173,9 @@ def wink_configuration_callback(callback_data): ATTR_CLIENT_SECRET: client_secret}) setup(hass, config) return - else: - error_msg = "Your input was invalid. Please try again." - _configurator = hass.data[DOMAIN]['configuring'][DOMAIN] - configurator.notify_errors(_configurator, error_msg) + error_msg = "Your input was invalid. Please try again." + _configurator = hass.data[DOMAIN]['configuring'][DOMAIN] + configurator.notify_errors(_configurator, error_msg) start_url = "{}{}".format(hass.config.api.base_url, WINK_AUTH_CALLBACK_PATH) @@ -210,7 +209,6 @@ def _request_oauth_completion(hass, config): "Failed to register, please try again.") return - # pylint: disable=unused-argument def wink_configuration_callback(callback_data): """Call setup again.""" setup(hass, config) @@ -453,7 +451,7 @@ def service_handle(service): _man = siren.wink.device_manufacturer() if (service.service != SERVICE_SET_AUTO_SHUTOFF and service.service != SERVICE_ENABLE_SIREN and - (_man != 'dome' and _man != 'wink')): + _man not in ('dome', 'wink')): _LOGGER.error("Service only valid for Dome or Wink sirens") return @@ -488,7 +486,7 @@ def service_handle(service): has_dome_or_wink_siren = False for siren in pywink.get_sirens(): _man = siren.device_manufacturer() - if _man == "dome" or _man == "wink": + if _man in ("dome", "wink"): has_dome_or_wink_siren = True _id = siren.object_id() + siren.name() if _id not in hass.data[DOMAIN]['unique_ids']: diff --git a/homeassistant/components/wirelesstag.py b/homeassistant/components/wirelesstag.py new file mode 100644 index 00000000000000..0f8f47f5100c6d --- /dev/null +++ b/homeassistant/components/wirelesstag.py @@ -0,0 +1,256 @@ +""" +Wireless Sensor Tags platform support. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/wirelesstag/ +""" +import logging + +from requests.exceptions import HTTPError, ConnectTimeout +import voluptuous as vol +from homeassistant.const import ( + ATTR_BATTERY_LEVEL, ATTR_VOLTAGE, CONF_USERNAME, CONF_PASSWORD) +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.dispatcher import ( + dispatcher_send) + +REQUIREMENTS = ['wirelesstagpy==0.3.0'] + +_LOGGER = logging.getLogger(__name__) + + +# straight of signal in dBm +ATTR_TAG_SIGNAL_STRAIGHT = 'signal_straight' +# indicates if tag is out of range or not +ATTR_TAG_OUT_OF_RANGE = 'out_of_range' +# number in percents from max power of tag receiver +ATTR_TAG_POWER_CONSUMPTION = 'power_consumption' + + +NOTIFICATION_ID = 'wirelesstag_notification' +NOTIFICATION_TITLE = "Wireless Sensor Tag Setup" + +DOMAIN = 'wirelesstag' +DEFAULT_ENTITY_NAMESPACE = 'wirelesstag' + +WIRELESSTAG_TYPE_13BIT = 13 +WIRELESSTAG_TYPE_ALSPRO = 26 +WIRELESSTAG_TYPE_WATER = 32 +WIRELESSTAG_TYPE_WEMO_DEVICE = 82 + +SIGNAL_TAG_UPDATE = 'wirelesstag.tag_info_updated_{}' +SIGNAL_BINARY_EVENT_UPDATE = 'wirelesstag.binary_event_updated_{}_{}' + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + }), +}, extra=vol.ALLOW_EXTRA) + + +class WirelessTagPlatform: + """Principal object to manage all registered in HA tags.""" + + def __init__(self, hass, api): + """Designated initializer for wirelesstags platform.""" + self.hass = hass + self.api = api + self.tags = {} + + def load_tags(self): + """Load tags from remote server.""" + self.tags = self.api.load_tags() + return self.tags + + def arm(self, switch): + """Arm entity sensor monitoring.""" + func_name = 'arm_{}'.format(switch.sensor_type) + arm_func = getattr(self.api, func_name) + if arm_func is not None: + arm_func(switch.tag_id) + + def disarm(self, switch): + """Disarm entity sensor monitoring.""" + func_name = 'disarm_{}'.format(switch.sensor_type) + disarm_func = getattr(self.api, func_name) + if disarm_func is not None: + disarm_func(switch.tag_id) + + # pylint: disable=no-self-use + def make_push_notitication(self, name, url, content): + """Factory for notification config.""" + from wirelesstagpy import NotificationConfig + return NotificationConfig(name, { + 'url': url, 'verb': 'POST', + 'content': content, 'disabled': False, 'nat': True}) + + def install_push_notifications(self, binary_sensors): + """Setup local push notification from tag manager.""" + _LOGGER.info("Registering local push notifications.") + configs = [] + + binary_url = self.binary_event_callback_url + for event in binary_sensors: + for state, name in event.binary_spec.items(): + content = ('{"type": "' + event.device_class + + '", "id":{' + str(event.tag_id_index_template) + + '}, "state": \"' + state + '\"}') + config = self.make_push_notitication(name, binary_url, content) + configs.append(config) + + content = ("{\"name\":\"{0}\",\"id\":{1},\"temp\":{2}," + + "\"cap\":{3},\"lux\":{4}}") + update_url = self.update_callback_url + update_config = self.make_push_notitication( + 'update', update_url, content) + configs.append(update_config) + + result = self.api.install_push_notification(0, configs, True) + if not result: + self.hass.components.persistent_notification.create( + "Error: failed to install local push notifications
", + title="Wireless Sensor Tag Setup Local Push Notifications", + notification_id="wirelesstag_failed_push_notification") + else: + _LOGGER.info("Installed push notifications for all tags.") + + @property + def update_callback_url(self): + """Return url for local push notifications(update event).""" + return '{}/api/events/wirelesstag_update_tags'.format( + self.hass.config.api.base_url) + + @property + def binary_event_callback_url(self): + """Return url for local push notifications(binary event).""" + return '{}/api/events/wirelesstag_binary_event'.format( + self.hass.config.api.base_url) + + def handle_update_tags_event(self, event): + """Main entry to handle push event from wireless tag manager.""" + _LOGGER.info("push notification for update arrived: %s", event) + dispatcher_send( + self.hass, + SIGNAL_TAG_UPDATE.format(event.data.get('id')), + event) + + def handle_binary_event(self, event): + """Handle push notifications for binary (on/off) events.""" + _LOGGER.info("Push notification for binary event arrived: %s", event) + try: + tag_id = event.data.get('id') + event_type = event.data.get('type') + dispatcher_send( + self.hass, + SIGNAL_BINARY_EVENT_UPDATE.format(tag_id, event_type), + event) + except Exception as ex: # pylint: disable=broad-except + _LOGGER.error("Unable to handle binary event:\ + %s error: %s", str(event), str(ex)) + + +def setup(hass, config): + """Set up the Wireless Sensor Tag component.""" + conf = config[DOMAIN] + username = conf.get(CONF_USERNAME) + password = conf.get(CONF_PASSWORD) + + try: + from wirelesstagpy import (WirelessTags, WirelessTagsException) + wirelesstags = WirelessTags(username=username, password=password) + + platform = WirelessTagPlatform(hass, wirelesstags) + platform.load_tags() + hass.data[DOMAIN] = platform + except (ConnectTimeout, HTTPError, WirelessTagsException) as ex: + _LOGGER.error("Unable to connect to wirelesstag.net service: %s", + str(ex)) + hass.components.persistent_notification.create( + "Error: {}
" + "Please restart hass after fixing this." + "".format(ex), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + + # listen to custom events + hass.bus.listen('wirelesstag_update_tags', + hass.data[DOMAIN].handle_update_tags_event) + hass.bus.listen('wirelesstag_binary_event', + hass.data[DOMAIN].handle_binary_event) + + return True + + +class WirelessTagBaseSensor(Entity): + """Base class for HA implementation for Wireless Sensor Tag.""" + + def __init__(self, api, tag): + """Initialize a base sensor for Wireless Sensor Tag platform.""" + self._api = api + self._tag = tag + self._uuid = self._tag.uuid + self.tag_id = self._tag.tag_id + self._name = self._tag.name + self._state = None + + @property + def should_poll(self): + """Return the polling state.""" + return True + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def principal_value(self): + """Return base value. + + Subclasses need override based on type of sensor. + """ + return 0 + + def updated_state_value(self): + """Default implementation formats princial value.""" + return self.decorate_value(self.principal_value) + + # pylint: disable=no-self-use + def decorate_value(self, value): + """Decorate input value to be well presented for end user.""" + return '{:.1f}'.format(value) + + @property + def available(self): + """Return True if entity is available.""" + return self._tag.is_alive + + def update(self): + """Update state.""" + if not self.should_poll: + return + + updated_tags = self._api.load_tags() + updated_tag = updated_tags[self._uuid] + if updated_tag is None: + _LOGGER.error('Unable to update tag: "%s"', self.name) + return + + self._tag = updated_tag + self._state = self.updated_state_value() + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return { + ATTR_BATTERY_LEVEL: self._tag.battery_remaining, + ATTR_VOLTAGE: '{:.2f}V'.format(self._tag.battery_volts), + ATTR_TAG_SIGNAL_STRAIGHT: '{}dBm'.format( + self._tag.signal_straight), + ATTR_TAG_OUT_OF_RANGE: not self._tag.is_in_range, + ATTR_TAG_POWER_CONSUMPTION: '{:.2f}%'.format( + self._tag.power_consumption) + } diff --git a/homeassistant/components/xiaomi_aqara.py b/homeassistant/components/xiaomi_aqara.py index 48c54cdecff90a..2090f5227093dc 100644 --- a/homeassistant/components/xiaomi_aqara.py +++ b/homeassistant/components/xiaomi_aqara.py @@ -23,7 +23,7 @@ from homeassistant.util.dt import utcnow from homeassistant.util import slugify -REQUIREMENTS = ['PyXiaomiGateway==0.9.0'] +REQUIREMENTS = ['PyXiaomiGateway==0.9.5'] _LOGGER = logging.getLogger(__name__) @@ -36,6 +36,7 @@ CONF_GATEWAYS = 'gateways' CONF_INTERFACE = 'interface' CONF_KEY = 'key' +CONF_DISABLE = 'disable' DOMAIN = 'xiaomi_aqara' @@ -73,6 +74,7 @@ vol.All(cv.string, vol.Length(min=16, max=16)), vol.Optional(CONF_HOST): cv.string, vol.Optional(CONF_PORT, default=9898): cv.port, + vol.Optional(CONF_DISABLE, default=False): cv.boolean, }) @@ -137,7 +139,8 @@ def xiaomi_gw_discovered(service, discovery_info): xiaomi.listen() _LOGGER.debug("Gateways discovered. Listening for broadcasts") - for component in ['binary_sensor', 'sensor', 'switch', 'light', 'cover']: + for component in ['binary_sensor', 'sensor', 'switch', 'light', 'cover', + 'lock']: discovery.load_platform(hass, component, DOMAIN, {}, config) def stop_xiaomi(event): diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 9b66c4c6deda46..030e342847d719 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -16,9 +16,9 @@ from homeassistant.util import slugify REQUIREMENTS = [ - 'bellows==0.5.2', - 'zigpy==0.0.3', - 'zigpy-xbee==0.0.2', + 'bellows==0.6.0', + 'zigpy==0.1.0', + 'zigpy-xbee==0.1.1', ] DOMAIN = 'zha' @@ -151,6 +151,11 @@ def device_joined(self, device): # Wait for device_initialized, instead pass + def raw_device_initialized(self, device): + """Handle a device initialization without quirks loaded.""" + # Wait for device_initialized, instead + pass + def device_initialized(self, device): """Handle device joined and basic information discovered.""" self._hass.async_add_job(self.async_device_initialized(device, True)) @@ -256,11 +261,16 @@ async def _attempt_single_cluster_device(self, endpoint, cluster, """Try to set up an entity from a "bare" cluster.""" if cluster.cluster_id in profile_clusters: return - # pylint: disable=unidiomatic-typecheck - if type(cluster) not in device_classes: + + component = None + for cluster_type, candidate_component in device_classes.items(): + if isinstance(cluster, cluster_type): + component = candidate_component + break + + if component is None: return - component = device_classes[type(cluster)] cluster_key = "{}-{}".format(device_key, cluster.cluster_id) discovery_info = { 'application_listener': self, @@ -319,7 +329,7 @@ def __init__(self, endpoint, in_clusters, out_clusters, manufacturer, self._endpoint = endpoint self._in_clusters = in_clusters self._out_clusters = out_clusters - self._state = ha_const.STATE_UNKNOWN + self._state = None self._unique_id = unique_id # Normally the entity itself is the listener. Sub-classes may set this @@ -410,7 +420,7 @@ def get_discovery_info(hass, discovery_info): return all_discovery_info.get(discovery_key, None) -async def safe_read(cluster, attributes): +async def safe_read(cluster, attributes, allow_cache=True): """Swallow all exceptions from network read. If we throw during initialization, setup fails. Rather have an entity that @@ -420,7 +430,7 @@ async def safe_read(cluster, attributes): try: result, _ = await cluster.read_attributes( attributes, - allow_cache=True, + allow_cache=allow_cache, ) return result except Exception: # pylint: disable=broad-except diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py index 36eb4d55c97231..37c7f5592a02e8 100644 --- a/homeassistant/components/zha/const.py +++ b/homeassistant/components/zha/const.py @@ -47,6 +47,10 @@ def populate_data(): zcl.clusters.general.OnOff: 'switch', zcl.clusters.measurement.RelativeHumidity: 'sensor', zcl.clusters.measurement.TemperatureMeasurement: 'sensor', + zcl.clusters.measurement.PressureMeasurement: 'sensor', + zcl.clusters.measurement.IlluminanceMeasurement: 'sensor', + zcl.clusters.smartenergy.Metering: 'sensor', + zcl.clusters.homeautomation.ElectricalMeasurement: 'sensor', zcl.clusters.security.IasZone: 'binary_sensor', zcl.clusters.hvac.Fan: 'fan', }) diff --git a/homeassistant/components/zigbee.py b/homeassistant/components/zigbee.py index 3a84e963841124..67bdf74425155c 100644 --- a/homeassistant/components/zigbee.py +++ b/homeassistant/components/zigbee.py @@ -124,7 +124,7 @@ def frame_is_relevant(entity, frame): return True -class ZigBeeConfig(object): +class ZigBeeConfig: """Handle the fetching of configuration from the config file.""" def __init__(self, config): diff --git a/homeassistant/components/zone/.translations/bg.json b/homeassistant/components/zone/.translations/bg.json new file mode 100644 index 00000000000000..5770058c5ebc4f --- /dev/null +++ b/homeassistant/components/zone/.translations/bg.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "name_exists": "\u0418\u043c\u0435\u0442\u043e \u0432\u0435\u0447\u0435 \u0441\u044a\u0449\u0435\u0441\u0442\u0432\u0443\u0432\u0430" + }, + "step": { + "init": { + "data": { + "icon": "\u0418\u043a\u043e\u043d\u0430", + "latitude": "\u0428\u0438\u0440\u0438\u043d\u0430", + "longitude": "\u0414\u044a\u043b\u0436\u0438\u043d\u0430", + "name": "\u0418\u043c\u0435", + "passive": "\u041f\u0430\u0441\u0438\u0432\u043d\u0430", + "radius": "\u0420\u0430\u0434\u0438\u0443\u0441" + }, + "title": "\u041e\u043f\u0440\u0435\u0434\u0435\u043b\u0435\u0442\u0435 \u043f\u0430\u0440\u0430\u043c\u0435\u0442\u0440\u0438\u0442\u0435 \u043d\u0430 \u0437\u043e\u043d\u0430\u0442\u0430" + } + }, + "title": "\u0417\u043e\u043d\u0430" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/ca.json b/homeassistant/components/zone/.translations/ca.json new file mode 100644 index 00000000000000..1676c8f390627a --- /dev/null +++ b/homeassistant/components/zone/.translations/ca.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "name_exists": "El nom ja existeix" + }, + "step": { + "init": { + "data": { + "icon": "Icona", + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Nom", + "passive": "Passiu", + "radius": "Radi" + }, + "title": "Defineix els par\u00e0metres de la zona" + } + }, + "title": "Zona" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/cs.json b/homeassistant/components/zone/.translations/cs.json new file mode 100644 index 00000000000000..a521377e5e0a59 --- /dev/null +++ b/homeassistant/components/zone/.translations/cs.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "name_exists": "N\u00e1zev ji\u017e existuje" + }, + "step": { + "init": { + "data": { + "icon": "Ikona", + "latitude": "Zem\u011bpisn\u00e1 \u0161\u00ed\u0159ka", + "longitude": "Zem\u011bpisn\u00e1 d\u00e9lka", + "name": "N\u00e1zev", + "passive": "Pasivn\u00ed", + "radius": "Polom\u011br" + }, + "title": "Definujte parametry z\u00f3ny" + } + }, + "title": "Z\u00f3na" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/es-419.json b/homeassistant/components/zone/.translations/es-419.json new file mode 100644 index 00000000000000..b15be44b7b1ebb --- /dev/null +++ b/homeassistant/components/zone/.translations/es-419.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "name_exists": "El nombre ya existe" + }, + "step": { + "init": { + "data": { + "icon": "Icono", + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Nombre", + "passive": "Pasivo", + "radius": "Radio" + }, + "title": "Definir par\u00e1metros de zona" + } + }, + "title": "Zona" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/fr.json b/homeassistant/components/zone/.translations/fr.json new file mode 100644 index 00000000000000..eb02aba7b50c05 --- /dev/null +++ b/homeassistant/components/zone/.translations/fr.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "name_exists": "Ce nom est d\u00e9j\u00e0 utilis\u00e9" + }, + "step": { + "init": { + "data": { + "icon": "Ic\u00f4ne", + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Nom", + "passive": "Passif", + "radius": "Rayon" + }, + "title": "D\u00e9finir les param\u00e8tres de la zone" + } + }, + "title": "Zone" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/hu.json b/homeassistant/components/zone/.translations/hu.json new file mode 100644 index 00000000000000..0181f688c27d0d --- /dev/null +++ b/homeassistant/components/zone/.translations/hu.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "name_exists": "A n\u00e9v m\u00e1r l\u00e9tezik" + }, + "step": { + "init": { + "data": { + "icon": "Ikon", + "latitude": "Sz\u00e9less\u00e9g", + "longitude": "Hossz\u00fas\u00e1g", + "name": "N\u00e9v", + "passive": "Passz\u00edv", + "radius": "Sug\u00e1r" + }, + "title": "Z\u00f3na param\u00e9terek megad\u00e1sa" + } + }, + "title": "Z\u00f3na" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/it.json b/homeassistant/components/zone/.translations/it.json new file mode 100644 index 00000000000000..4490124510fa4d --- /dev/null +++ b/homeassistant/components/zone/.translations/it.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "name_exists": "Il nome \u00e8 gi\u00e0 esistente" + }, + "step": { + "init": { + "data": { + "icon": "Icona", + "latitude": "Latitudine", + "longitude": "Logitudine", + "name": "Nome", + "passive": "Passiva", + "radius": "Raggio" + }, + "title": "Imposta i parametri della zona" + } + }, + "title": "Zona" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/ja.json b/homeassistant/components/zone/.translations/ja.json new file mode 100644 index 00000000000000..093f5ad99385aa --- /dev/null +++ b/homeassistant/components/zone/.translations/ja.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "init": { + "data": { + "latitude": "\u7def\u5ea6", + "longitude": "\u7d4c\u5ea6", + "name": "\u540d\u524d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/ko.json b/homeassistant/components/zone/.translations/ko.json index 364f8f3cc77f3a..421f079a67ea48 100644 --- a/homeassistant/components/zone/.translations/ko.json +++ b/homeassistant/components/zone/.translations/ko.json @@ -13,7 +13,7 @@ "passive": "\uc790\ub3d9\ud654 \uc804\uc6a9", "radius": "\ubc18\uacbd" }, - "title": "\uad6c\uc5ed \ub9e4\uac1c \ubcc0\uc218 \uc815\uc758" + "title": "\uad6c\uc5ed \uc124\uc815" } }, "title": "\uad6c\uc5ed" diff --git a/homeassistant/components/zone/.translations/pt-BR.json b/homeassistant/components/zone/.translations/pt-BR.json new file mode 100644 index 00000000000000..f2a41b0b26785c --- /dev/null +++ b/homeassistant/components/zone/.translations/pt-BR.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "name_exists": "O nome j\u00e1 existe" + }, + "step": { + "init": { + "data": { + "icon": "\u00cdcone", + "latitude": "Latitude", + "longitude": "Longitude", + "name": "Nome", + "passive": "Passivo", + "radius": "Raio" + }, + "title": "Definir par\u00e2metros da zona" + } + }, + "title": "Zona" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/pt.json b/homeassistant/components/zone/.translations/pt.json index a4ced557805661..2c3292e58c192d 100644 --- a/homeassistant/components/zone/.translations/pt.json +++ b/homeassistant/components/zone/.translations/pt.json @@ -12,7 +12,8 @@ "name": "Nome", "passive": "Passivo", "radius": "Raio" - } + }, + "title": "Definir os par\u00e2metros da zona" } }, "title": "Zona" diff --git a/homeassistant/components/zone/.translations/sl.json b/homeassistant/components/zone/.translations/sl.json new file mode 100644 index 00000000000000..1885cb5d2c86bd --- /dev/null +++ b/homeassistant/components/zone/.translations/sl.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "name_exists": "Ime \u017ee obstaja" + }, + "step": { + "init": { + "data": { + "icon": "Ikona", + "latitude": "Zemljepisna \u0161irina", + "longitude": "Zemljepisna dol\u017eina", + "name": "Ime", + "passive": "Pasivno", + "radius": "Radij" + }, + "title": "Dolo\u010dite parametre obmo\u010dja" + } + }, + "title": "Obmo\u010dje" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/sv.json b/homeassistant/components/zone/.translations/sv.json new file mode 100644 index 00000000000000..55c5bcf712721c --- /dev/null +++ b/homeassistant/components/zone/.translations/sv.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "name_exists": "Namnet finns redan" + }, + "step": { + "init": { + "data": { + "icon": "Ikon", + "latitude": "Latitud", + "longitude": "Longitud", + "name": "Namn", + "passive": "Passiv", + "radius": "Radie" + }, + "title": "Definiera zonparametrar" + } + }, + "title": "Zon" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/vi.json b/homeassistant/components/zone/.translations/vi.json new file mode 100644 index 00000000000000..7217944bd6b631 --- /dev/null +++ b/homeassistant/components/zone/.translations/vi.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "name_exists": "T\u00ean \u0111\u00e3 t\u1ed3n t\u1ea1i" + }, + "step": { + "init": { + "data": { + "icon": "Bi\u1ec3u t\u01b0\u1ee3ng", + "latitude": "V\u0129 \u0111\u1ed9", + "longitude": "Kinh \u0111\u1ed9", + "name": "T\u00ean", + "passive": "Th\u1ee5 \u0111\u1ed9ng", + "radius": "B\u00e1n k\u00ednh" + }, + "title": "X\u00e1c \u0111\u1ecbnh tham s\u1ed1 v\u00f9ng" + } + }, + "title": "V\u00f9ng" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/.translations/zh-Hant.json b/homeassistant/components/zone/.translations/zh-Hant.json new file mode 100644 index 00000000000000..12c1141397d7ef --- /dev/null +++ b/homeassistant/components/zone/.translations/zh-Hant.json @@ -0,0 +1,21 @@ +{ + "config": { + "error": { + "name_exists": "\u8a72\u540d\u7a31\u5df2\u5b58\u5728" + }, + "step": { + "init": { + "data": { + "icon": "\u5716\u793a", + "latitude": "\u7def\u5ea6", + "longitude": "\u7d93\u5ea6", + "name": "\u540d\u7a31", + "passive": "\u88ab\u52d5", + "radius": "\u534a\u5f91" + }, + "title": "\u5b9a\u7fa9\u5340\u57df\u53c3\u6578" + } + }, + "title": "\u5340\u57df" + } +} \ No newline at end of file diff --git a/homeassistant/components/zone/__init__.py b/homeassistant/components/zone/__init__.py index d3628fd57f3bbe..ee19e00266c7fc 100644 --- a/homeassistant/components/zone/__init__.py +++ b/homeassistant/components/zone/__init__.py @@ -45,27 +45,25 @@ async def async_setup(hass, config): """Setup configured zones as well as home assistant zone if necessary.""" - if DOMAIN not in hass.data: - hass.data[DOMAIN] = {} + hass.data[DOMAIN] = {} + entities = set() zone_entries = configured_zones(hass) for _, entry in config_per_platform(config, DOMAIN): - name = slugify(entry[CONF_NAME]) - if name not in zone_entries: + if slugify(entry[CONF_NAME]) not in zone_entries: zone = Zone(hass, entry[CONF_NAME], entry[CONF_LATITUDE], entry[CONF_LONGITUDE], entry.get(CONF_RADIUS), entry.get(CONF_ICON), entry.get(CONF_PASSIVE)) zone.entity_id = async_generate_entity_id( - ENTITY_ID_FORMAT, entry[CONF_NAME], None, hass) + ENTITY_ID_FORMAT, entry[CONF_NAME], entities) hass.async_add_job(zone.async_update_ha_state()) - hass.data[DOMAIN][name] = zone + entities.add(zone.entity_id) - if HOME_ZONE not in hass.data[DOMAIN] and HOME_ZONE not in zone_entries: - name = hass.config.location_name - zone = Zone(hass, name, hass.config.latitude, hass.config.longitude, + if ENTITY_ID_HOME not in entities and HOME_ZONE not in zone_entries: + zone = Zone(hass, hass.config.location_name, + hass.config.latitude, hass.config.longitude, DEFAULT_RADIUS, ICON_HOME, False) zone.entity_id = ENTITY_ID_HOME hass.async_add_job(zone.async_update_ha_state()) - hass.data[DOMAIN][slugify(name)] = zone return True @@ -75,8 +73,8 @@ async def async_setup_entry(hass, config_entry): entry = config_entry.data name = entry[CONF_NAME] zone = Zone(hass, name, entry[CONF_LATITUDE], entry[CONF_LONGITUDE], - entry.get(CONF_RADIUS), entry.get(CONF_ICON), - entry.get(CONF_PASSIVE)) + entry.get(CONF_RADIUS, DEFAULT_RADIUS), entry.get(CONF_ICON), + entry.get(CONF_PASSIVE, DEFAULT_PASSIVE)) zone.entity_id = async_generate_entity_id( ENTITY_ID_FORMAT, name, None, hass) hass.async_add_job(zone.async_update_ha_state()) diff --git a/homeassistant/components/zone/config_flow.py b/homeassistant/components/zone/config_flow.py index 5ec955a48d90c0..01577de4c8f0a1 100644 --- a/homeassistant/components/zone/config_flow.py +++ b/homeassistant/components/zone/config_flow.py @@ -29,6 +29,10 @@ def __init__(self): """Initialize zone configuration flow.""" pass + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + return await self.async_step_init(user_input) + async def async_step_init(self, user_input=None): """Handle a flow start.""" errors = {} diff --git a/homeassistant/components/zoneminder.py b/homeassistant/components/zoneminder.py index 86531401774198..471c1c6e82cad4 100644 --- a/homeassistant/components/zoneminder.py +++ b/homeassistant/components/zoneminder.py @@ -67,7 +67,6 @@ def setup(hass, config): return login() -# pylint: disable=no-member def login(): """Login to the ZoneMinder API.""" _LOGGER.debug("Attempting to login to ZoneMinder") @@ -118,13 +117,11 @@ def _zm_request(method, api_url, data=None): 'decode "%s"', req.text) -# pylint: disable=no-member def get_state(api_url): """Get a state from the ZoneMinder API service.""" return _zm_request('get', api_url) -# pylint: disable=no-member def change_state(api_url, post_data): """Update a state using the Zoneminder API.""" return _zm_request('post', api_url, data=post_data) diff --git a/homeassistant/components/zwave/__init__.py b/homeassistant/components/zwave/__init__.py index 01b17023c12519..8cf69e727027f6 100644 --- a/homeassistant/components/zwave/__init__.py +++ b/homeassistant/components/zwave/__init__.py @@ -11,15 +11,16 @@ import voluptuous as vol -from homeassistant.core import CoreState +from homeassistant.core import callback, CoreState from homeassistant.loader import get_platform from homeassistant.helpers import discovery from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.entity_registry import async_get_registry from homeassistant.const import ( ATTR_ENTITY_ID, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers.entity_values import EntityValues -from homeassistant.helpers.event import track_time_change +from homeassistant.helpers.event import async_track_time_change from homeassistant.util import convert import homeassistant.util.dt as dt_util import homeassistant.helpers.config_validation as cv @@ -31,9 +32,10 @@ from .node_entity import ZWaveBaseEntity, ZWaveNodeEntity from . import workaround from .discovery_schemas import DISCOVERY_SCHEMAS -from .util import check_node_schema, check_value_schema, node_name +from .util import (check_node_schema, check_value_schema, node_name, + check_has_unique_id, is_node_parsed) -REQUIREMENTS = ['pydispatcher==2.0.5', 'python_openzwave==0.4.3'] +REQUIREMENTS = ['pydispatcher==2.0.5', 'python_openzwave==0.4.9'] _LOGGER = logging.getLogger(__name__) @@ -216,8 +218,7 @@ async def async_setup_platform(hass, config, async_add_devices, return True -# pylint: disable=R0914 -def setup(hass, config): +async def async_setup(hass, config): """Set up Z-Wave. Will automatically load components to support devices found on the network. @@ -285,7 +286,7 @@ def value_added(node, value): continue values = ZWaveDeviceEntityValues( - hass, schema, value, config, device_config) + hass, schema, value, config, device_config, registry) # We create a new list and update the reference here so that # the list can be safely iterated over in the main thread @@ -293,6 +294,7 @@ def value_added(node, value): hass.data[DATA_ENTITY_VALUES] = new_values component = EntityComponent(_LOGGER, DOMAIN, hass) + registry = await async_get_registry(hass) def node_added(node): """Handle a new node on the network.""" @@ -313,30 +315,22 @@ def _add_node_to_component(): _add_node_to_component() return - async def _check_node_ready(): - """Wait for node to be parsed.""" - start_time = dt_util.utcnow() - while True: - waited = int((dt_util.utcnow()-start_time).total_seconds()) - - if entity.unique_id: - _LOGGER.info("Z-Wave node %d ready after %d seconds", - entity.node_id, waited) - break - elif waited >= const.NODE_READY_WAIT_SECS: - # Wait up to NODE_READY_WAIT_SECS seconds for the Z-Wave - # node to be ready. - _LOGGER.warning( - "Z-Wave node %d not ready after %d seconds, " - "continuing anyway", - entity.node_id, waited) - break - else: - await asyncio.sleep(1, loop=hass.loop) + @callback + def _on_ready(sec): + _LOGGER.info("Z-Wave node %d ready after %d seconds", + entity.node_id, sec) + hass.async_add_job(_add_node_to_component) + @callback + def _on_timeout(sec): + _LOGGER.warning( + "Z-Wave node %d not ready after %d seconds, " + "continuing anyway", + entity.node_id, sec) hass.async_add_job(_add_node_to_component) - hass.add_job(_check_node_ready) + hass.add_job(check_has_unique_id, entity, _on_ready, _on_timeout, + hass.loop) def network_ready(): """Handle the query of all awake nodes.""" @@ -709,9 +703,9 @@ def _finalize_start(): # Setup autoheal if autoheal: _LOGGER.info("Z-Wave network autoheal is enabled") - track_time_change(hass, heal_network, hour=0, minute=0, second=0) + async_track_time_change(hass, heal_network, hour=0, minute=0, second=0) - hass.bus.listen_once(EVENT_HOMEASSISTANT_START, start_zwave) + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, start_zwave) return True @@ -720,7 +714,7 @@ class ZWaveDeviceEntityValues(): """Manages entity access to the underlying zwave value objects.""" def __init__(self, hass, schema, primary_value, zwave_config, - device_config): + device_config, registry): """Initialize the values object with the passed entity schema.""" self._hass = hass self._zwave_config = zwave_config @@ -729,6 +723,7 @@ def __init__(self, hass, schema, primary_value, zwave_config, self._values = {} self._entity = None self._workaround_ignore = False + self._registry = registry for name in self._schema[const.DISC_VALUES].keys(): self._values[name] = None @@ -801,9 +796,13 @@ def _check_entity_ready(self): workaround_component, component) component = workaround_component - value_name = _value_name(self.primary) - generated_id = generate_entity_id(component + '.{}', value_name, []) - node_config = self._device_config.get(generated_id) + entity_id = self._registry.async_get_entity_id( + component, DOMAIN, + compute_value_unique_id(self._node, self.primary)) + if entity_id is None: + value_name = _value_name(self.primary) + entity_id = generate_entity_id(component + '.{}', value_name, []) + node_config = self._device_config.get(entity_id) # Configure node _LOGGER.debug("Adding Node_id=%s Generic_command_class=%s, " @@ -816,7 +815,7 @@ def _check_entity_ready(self): if node_config.get(CONF_IGNORED): _LOGGER.info( - "Ignoring entity %s due to device settings", generated_id) + "Ignoring entity %s due to device settings", entity_id) # No entity will be created for this value self._workaround_ignore = True return @@ -839,13 +838,35 @@ def _check_entity_ready(self): dict_id = id(self) + @callback + def _on_ready(sec): + _LOGGER.info( + "Z-Wave entity %s (node_id: %d) ready after %d seconds", + device.name, self._node.node_id, sec) + self._hass.async_add_job(discover_device, component, device, + dict_id) + + @callback + def _on_timeout(sec): + _LOGGER.warning( + "Z-Wave entity %s (node_id: %d) not ready after %d seconds, " + "continuing anyway", + device.name, self._node.node_id, sec) + self._hass.async_add_job(discover_device, component, device, + dict_id) + async def discover_device(component, device, dict_id): """Put device in a dictionary and call discovery on it.""" self._hass.data[DATA_DEVICES][dict_id] = device await discovery.async_load_platform( self._hass, component, DOMAIN, {const.DISCOVERY_DEVICE: dict_id}, self._zwave_config) - self._hass.add_job(discover_device, component, device, dict_id) + + if device.unique_id: + self._hass.add_job(discover_device, component, device, dict_id) + else: + self._hass.add_job(check_has_unique_id, device, _on_ready, + _on_timeout, self._hass.loop) class ZWaveDeviceEntity(ZWaveBaseEntity): @@ -862,8 +883,7 @@ def __init__(self, values, domain): self.values.primary.set_change_verified(False) self._name = _value_name(self.values.primary) - self._unique_id = "{}-{}".format(self.node.node_id, - self.values.primary.object_id) + self._unique_id = self._compute_unique_id() self._update_attributes() dispatcher.connect( @@ -894,6 +914,11 @@ async def async_added_to_hass(self): def _update_attributes(self): """Update the node attributes. May only be used inside callback.""" self.node_id = self.node.node_id + self._name = _value_name(self.values.primary) + if not self._unique_id: + self._unique_id = self._compute_unique_id() + if self._unique_id: + self.try_remove_and_add() if self.values.power: self.power_consumption = round( @@ -940,3 +965,15 @@ def refresh_from_network(self): for value in self.values: if value is not None: self.node.refresh_value(value.value_id) + + def _compute_unique_id(self): + if (is_node_parsed(self.node) and + self.values.primary.label != "Unknown") or \ + self.node.is_ready: + return compute_value_unique_id(self.node, self.values.primary) + return None + + +def compute_value_unique_id(node, value): + """Compute unique_id a value would get if it were to get one.""" + return "{}-{}".format(node.node_id, value.object_id) diff --git a/homeassistant/components/zwave/const.py b/homeassistant/components/zwave/const.py index 3e503e4d9a4d7e..0228e64cf6ef41 100644 --- a/homeassistant/components/zwave/const.py +++ b/homeassistant/components/zwave/const.py @@ -345,7 +345,6 @@ DISC_TYPE = "type" DISC_VALUES = "values" -# noqa # https://github.com/OpenZWave/open-zwave/blob/67f180eb565f0054f517ff395c71ecd706f6a837/cpp/src/command_classes/Alarm.cpp#L49 # See also: # https://github.com/OpenZWave/open-zwave/blob/67f180eb565f0054f517ff395c71ecd706f6a837/cpp/src/command_classes/Alarm.cpp#L275 diff --git a/homeassistant/components/zwave/discovery_schemas.py b/homeassistant/components/zwave/discovery_schemas.py index d38fbc7079c306..2a4e42ab92c21d 100644 --- a/homeassistant/components/zwave/discovery_schemas.py +++ b/homeassistant/components/zwave/discovery_schemas.py @@ -37,7 +37,9 @@ const.DISC_OPTIONAL: True, }})}, {const.DISC_COMPONENT: 'climate', - const.DISC_GENERIC_DEVICE_CLASS: [const.GENERIC_TYPE_THERMOSTAT], + const.DISC_GENERIC_DEVICE_CLASS: [ + const.GENERIC_TYPE_THERMOSTAT, + const.GENERIC_TYPE_SENSOR_MULTILEVEL], const.DISC_VALUES: dict(DEFAULT_VALUES_SCHEMA, **{ const.DISC_PRIMARY: { const.DISC_COMMAND_CLASS: [ @@ -173,6 +175,7 @@ {const.DISC_COMPONENT: 'lock', const.DISC_GENERIC_DEVICE_CLASS: [const.GENERIC_TYPE_ENTRY_CONTROL], const.DISC_SPECIFIC_DEVICE_CLASS: [ + const.SPECIFIC_TYPE_DOOR_LOCK, const.SPECIFIC_TYPE_ADVANCED_DOOR_LOCK, const.SPECIFIC_TYPE_SECURE_KEYPAD_DOOR_LOCK, const.SPECIFIC_TYPE_SECURE_LOCKBOX], @@ -213,6 +216,7 @@ }})}, {const.DISC_COMPONENT: 'switch', const.DISC_GENERIC_DEVICE_CLASS: [ + const.GENERIC_TYPE_METER, const.GENERIC_TYPE_SENSOR_ALARM, const.GENERIC_TYPE_SENSOR_BINARY, const.GENERIC_TYPE_SWITCH_BINARY, diff --git a/homeassistant/components/zwave/node_entity.py b/homeassistant/components/zwave/node_entity.py index bcddcb0b800547..2c6d26802bd141 100644 --- a/homeassistant/components/zwave/node_entity.py +++ b/homeassistant/components/zwave/node_entity.py @@ -9,7 +9,7 @@ ATTR_NODE_ID, COMMAND_CLASS_WAKE_UP, ATTR_SCENE_ID, ATTR_SCENE_DATA, ATTR_BASIC_LEVEL, EVENT_NODE_EVENT, EVENT_SCENE_ACTIVATED, COMMAND_CLASS_CENTRAL_SCENE) -from .util import node_name +from .util import node_name, is_node_parsed _LOGGER = logging.getLogger(__name__) @@ -65,6 +65,15 @@ def do_update(): self._update_scheduled = True self.hass.loop.call_later(0.1, do_update) + def try_remove_and_add(self): + """Remove this entity and add it back.""" + async def _async_remove_and_add(): + await self.async_remove() + self.entity_id = None + await self.platform.async_add_entities([self]) + if self.hass and self.platform: + self.hass.add_job(_async_remove_and_add) + class ZWaveNodeEntity(ZWaveBaseEntity): """Representation of a Z-Wave node.""" @@ -151,6 +160,9 @@ def node_changed(self): if not self._unique_id: self._unique_id = self._compute_unique_id() + if self._unique_id: + # Node info parsed. Remove and re-add + self.try_remove_and_add() self.maybe_schedule_update() @@ -243,6 +255,6 @@ def device_state_attributes(self): return attrs def _compute_unique_id(self): - if self._manufacturer_name and self._product_name: + if is_node_parsed(self.node) or self.node.is_ready: return 'node-{}'.format(self.node_id) return None diff --git a/homeassistant/components/zwave/util.py b/homeassistant/components/zwave/util.py index 8c74b731ad6b04..312d72575a94ae 100644 --- a/homeassistant/components/zwave/util.py +++ b/homeassistant/components/zwave/util.py @@ -1,6 +1,9 @@ """Zwave util methods.""" +import asyncio import logging +import homeassistant.util.dt as dt_util + from . import const _LOGGER = logging.getLogger(__name__) @@ -65,5 +68,27 @@ def check_value_schema(value, schema): def node_name(node): """Return the name of the node.""" - return node.name or '{} {}'.format( - node.manufacturer_name, node.product_name) + if is_node_parsed(node): + return node.name or '{} {}'.format( + node.manufacturer_name, node.product_name) + return 'Unknown Node {}'.format(node.node_id) + + +async def check_has_unique_id(entity, ready_callback, timeout_callback, loop): + """Wait for entity to have unique_id.""" + start_time = dt_util.utcnow() + while True: + waited = int((dt_util.utcnow()-start_time).total_seconds()) + if entity.unique_id: + ready_callback(waited) + return + if waited >= const.NODE_READY_WAIT_SECS: + # Wait up to NODE_READY_WAIT_SECS seconds for unique_id to appear. + timeout_callback(waited) + return + await asyncio.sleep(1, loop=loop) + + +def is_node_parsed(node): + """Check whether the node has been parsed or still waiting to be parsed.""" + return bool((node.manufacturer_name and node.product_name) or node.name) diff --git a/homeassistant/config.py b/homeassistant/config.py index 5c432490f6aacd..6120a20fd63473 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -7,20 +7,22 @@ import re import shutil # pylint: disable=unused-import -from typing import Any, List, Tuple # NOQA - +from typing import ( # noqa: F401 + Any, Tuple, Optional, Dict, List, Union, Callable) +from types import ModuleType import voluptuous as vol from voluptuous.humanize import humanize_error from homeassistant import auth +from homeassistant.auth import providers as auth_providers from homeassistant.const import ( ATTR_FRIENDLY_NAME, ATTR_HIDDEN, ATTR_ASSUMED_STATE, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_PACKAGES, CONF_UNIT_SYSTEM, CONF_TIME_ZONE, CONF_ELEVATION, CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL, CONF_TEMPERATURE_UNIT, TEMP_CELSIUS, __version__, CONF_CUSTOMIZE, CONF_CUSTOMIZE_DOMAIN, CONF_CUSTOMIZE_GLOB, - CONF_WHITELIST_EXTERNAL_DIRS, CONF_AUTH_PROVIDERS) -from homeassistant.core import callback, DOMAIN as CONF_CORE + CONF_WHITELIST_EXTERNAL_DIRS, CONF_AUTH_PROVIDERS, CONF_TYPE) +from homeassistant.core import callback, DOMAIN as CONF_CORE, HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import get_component, get_platform from homeassistant.util.yaml import load_yaml, SECRET_YAML @@ -60,7 +62,7 @@ (CONF_TIME_ZONE, 'UTC', 'time_zone', 'Pick yours from here: http://en.wiki' 'pedia.org/wiki/List_of_tz_database_time_zones'), (CONF_CUSTOMIZE, '!include customize.yaml', None, 'Customization file'), -) # type: Tuple[Tuple[str, Any, Any, str], ...] +) # type: Tuple[Tuple[str, Any, Any, Optional[str]], ...] DEFAULT_CONFIG = """ # Show links to resources in log and frontend introduction: @@ -159,7 +161,12 @@ vol.All(cv.ensure_list, [vol.IsDir()]), vol.Optional(CONF_PACKAGES, default={}): PACKAGES_CONFIG_SCHEMA, vol.Optional(CONF_AUTH_PROVIDERS): - vol.All(cv.ensure_list, [auth.AUTH_PROVIDER_SCHEMA]) + vol.All(cv.ensure_list, + [auth_providers.AUTH_PROVIDER_SCHEMA.extend({ + CONF_TYPE: vol.NotIn(['insecure_example'], + 'The insecure_example auth provider' + ' is for testing only.') + })]) }) @@ -167,10 +174,11 @@ def get_default_config_dir() -> str: """Put together the default configuration directory based on the OS.""" data_dir = os.getenv('APPDATA') if os.name == "nt" \ else os.path.expanduser('~') - return os.path.join(data_dir, CONFIG_DIR_NAME) + return os.path.join(data_dir, CONFIG_DIR_NAME) # type: ignore -def ensure_config_exists(config_dir: str, detect_location: bool = True) -> str: +def ensure_config_exists(config_dir: str, detect_location: bool = True)\ + -> Optional[str]: """Ensure a configuration file exists in given configuration directory. Creating a default one if needed. @@ -186,7 +194,8 @@ def ensure_config_exists(config_dir: str, detect_location: bool = True) -> str: return config_path -def create_default_config(config_dir, detect_location=True): +def create_default_config(config_dir: str, detect_location: bool = True)\ + -> Optional[str]: """Create a default configuration file in given configuration directory. Return path to new config file if success, None if failed. @@ -268,7 +277,7 @@ def create_default_config(config_dir, detect_location=True): return None -async def async_hass_config_yaml(hass): +async def async_hass_config_yaml(hass: HomeAssistant) -> Dict: """Load YAML from a Home Assistant configuration file. This function allow a component inside the asyncio loop to reload its @@ -276,26 +285,26 @@ async def async_hass_config_yaml(hass): This method is a coroutine. """ - def _load_hass_yaml_config(): + def _load_hass_yaml_config() -> Dict: path = find_config_file(hass.config.config_dir) - conf = load_yaml_config_file(path) - return conf - - conf = await hass.async_add_job(_load_hass_yaml_config) - return conf + if path is None: + raise HomeAssistantError( + "Config file not found in: {}".format(hass.config.config_dir)) + return load_yaml_config_file(path) + return await hass.async_add_executor_job(_load_hass_yaml_config) -def find_config_file(config_dir): - """Look in given directory for supported configuration files. - Async friendly. - """ +def find_config_file(config_dir: Optional[str]) -> Optional[str]: + """Look in given directory for supported configuration files.""" + if config_dir is None: + return None config_path = os.path.join(config_dir, YAML_CONFIG_FILE) return config_path if os.path.isfile(config_path) else None -def load_yaml_config_file(config_path): +def load_yaml_config_file(config_path: str) -> Dict[Any, Any]: """Parse a YAML configuration file. This method needs to run in an executor. @@ -318,7 +327,7 @@ def load_yaml_config_file(config_path): return conf_dict -def process_ha_config_upgrade(hass): +def process_ha_config_upgrade(hass: HomeAssistant) -> None: """Upgrade configuration if necessary. This method needs to run in an executor. @@ -355,7 +364,8 @@ def process_ha_config_upgrade(hass): @callback -def async_log_exception(ex, domain, config, hass): +def async_log_exception(ex: vol.Invalid, domain: str, config: Dict, + hass: HomeAssistant) -> None: """Log an error for configuration validation. This method must be run in the event loop. @@ -366,7 +376,7 @@ def async_log_exception(ex, domain, config, hass): @callback -def _format_config_error(ex, domain, config): +def _format_config_error(ex: vol.Invalid, domain: str, config: Dict) -> str: """Generate log exception for configuration validation. This method must be run in the event loop. @@ -391,7 +401,8 @@ def _format_config_error(ex, domain, config): return message -async def async_process_ha_core_config(hass, config): +async def async_process_ha_core_config( + hass: HomeAssistant, config: Dict) -> None: """Process the [homeassistant] section from the configuration. This method is a coroutine. @@ -400,12 +411,12 @@ async def async_process_ha_core_config(hass, config): # Only load auth during startup. if not hasattr(hass, 'auth'): - hass.auth = await auth.auth_manager_from_config( - hass, config.get(CONF_AUTH_PROVIDERS, [])) + setattr(hass, 'auth', await auth.auth_manager_from_config( + hass, config.get(CONF_AUTH_PROVIDERS, []))) hac = hass.config - def set_time_zone(time_zone_str): + def set_time_zone(time_zone_str: Optional[str]) -> None: """Help to set the time zone.""" if time_zone_str is None: return @@ -425,11 +436,10 @@ def set_time_zone(time_zone_str): if key in config: setattr(hac, attr, config[key]) - if CONF_TIME_ZONE in config: - set_time_zone(config.get(CONF_TIME_ZONE)) + set_time_zone(config.get(CONF_TIME_ZONE)) # Init whitelist external dir - hac.whitelist_external_dirs = set((hass.config.path('www'),)) + hac.whitelist_external_dirs = {hass.config.path('www')} if CONF_WHITELIST_EXTERNAL_DIRS in config: hac.whitelist_external_dirs.update( set(config[CONF_WHITELIST_EXTERNAL_DIRS])) @@ -479,12 +489,12 @@ def set_time_zone(time_zone_str): hac.time_zone, hac.elevation): return - discovered = [] + discovered = [] # type: List[Tuple[str, Any]] # If we miss some of the needed values, auto detect them if None in (hac.latitude, hac.longitude, hac.units, hac.time_zone): - info = await hass.async_add_job( + info = await hass.async_add_executor_job( loc_util.detect_location_info) if info is None: @@ -510,7 +520,7 @@ def set_time_zone(time_zone_str): if hac.elevation is None and hac.latitude is not None and \ hac.longitude is not None: - elevation = await hass.async_add_job( + elevation = await hass.async_add_executor_job( loc_util.elevation, hac.latitude, hac.longitude) hac.elevation = elevation discovered.append(('elevation', elevation)) @@ -521,7 +531,8 @@ def set_time_zone(time_zone_str): ", ".join('{}: {}'.format(key, val) for key, val in discovered)) -def _log_pkg_error(package, component, config, message): +def _log_pkg_error( + package: str, component: str, config: Dict, message: str) -> None: """Log an error while merging packages.""" message = "Package {} setup failed. Component {} {}".format( package, component, message) @@ -534,12 +545,13 @@ def _log_pkg_error(package, component, config, message): _LOGGER.error(message) -def _identify_config_schema(module): +def _identify_config_schema(module: ModuleType) -> \ + Tuple[Optional[str], Optional[Dict]]: """Extract the schema and identify list or dict based.""" try: - schema = module.CONFIG_SCHEMA.schema[module.DOMAIN] + schema = module.CONFIG_SCHEMA.schema[module.DOMAIN] # type: ignore except (AttributeError, KeyError): - return (None, None) + return None, None t_schema = str(schema) if t_schema.startswith('{'): return ('dict', schema) @@ -548,8 +560,32 @@ def _identify_config_schema(module): return '', schema -def merge_packages_config(hass, config, packages, - _log_pkg_error=_log_pkg_error): +def _recursive_merge( + conf: Dict[str, Any], package: Dict[str, Any]) -> Union[bool, str]: + """Merge package into conf, recursively.""" + error = False # type: Union[bool, str] + for key, pack_conf in package.items(): + if isinstance(pack_conf, dict): + if not pack_conf: + continue + conf[key] = conf.get(key, OrderedDict()) + error = _recursive_merge(conf=conf[key], package=pack_conf) + + elif isinstance(pack_conf, list): + if not pack_conf: + continue + conf[key] = cv.ensure_list(conf.get(key)) + conf[key].extend(cv.ensure_list(pack_conf)) + + else: + if conf.get(key) is not None: + return key + conf[key] = pack_conf + return error + + +def merge_packages_config(hass: HomeAssistant, config: Dict, packages: Dict, + _log_pkg_error: Callable = _log_pkg_error) -> Dict: """Merge packages into the top-level configuration. Mutate config.""" # pylint: disable=too-many-nested-blocks PACKAGES_CONFIG_SCHEMA(packages) @@ -580,46 +616,41 @@ def merge_packages_config(hass, config, packages, config[comp_name].extend(cv.ensure_list(comp_conf)) continue - if merge_type == 'dict': - if comp_conf is None: - comp_conf = OrderedDict() - - if not isinstance(comp_conf, dict): - _log_pkg_error( - pack_name, comp_name, config, - "cannot be merged. Expected a dict.") - continue - - if comp_name not in config: - config[comp_name] = OrderedDict() - - if not isinstance(config[comp_name], dict): - _log_pkg_error( - pack_name, comp_name, config, - "cannot be merged. Dict expected in main config.") - continue - - for key, val in comp_conf.items(): - if key in config[comp_name]: - _log_pkg_error(pack_name, comp_name, config, - "duplicate key '{}'".format(key)) - continue - config[comp_name][key] = val - continue + if comp_conf is None: + comp_conf = OrderedDict() - # The last merge type are sections that may occur only once - if comp_name in config: + if not isinstance(comp_conf, dict): _log_pkg_error( - pack_name, comp_name, config, "may occur only once" - " and it already exist in your main configuration") + pack_name, comp_name, config, + "cannot be merged. Expected a dict.") continue - config[comp_name] = comp_conf + + if comp_name not in config or config[comp_name] is None: + config[comp_name] = OrderedDict() + + if not isinstance(config[comp_name], dict): + _log_pkg_error( + pack_name, comp_name, config, + "cannot be merged. Dict expected in main config.") + continue + if not isinstance(comp_conf, dict): + _log_pkg_error( + pack_name, comp_name, config, + "cannot be merged. Dict expected in package.") + continue + + error = _recursive_merge(conf=config[comp_name], + package=comp_conf) + if error: + _log_pkg_error(pack_name, comp_name, config, + "has duplicate key '{}'".format(error)) return config @callback -def async_process_component_config(hass, config, domain): +def async_process_component_config( + hass: HomeAssistant, config: Dict, domain: str) -> Optional[Dict]: """Check component configuration and return processed configuration. Returns None on error. @@ -630,7 +661,7 @@ def async_process_component_config(hass, config, domain): if hasattr(component, 'CONFIG_SCHEMA'): try: - config = component.CONFIG_SCHEMA(config) + config = component.CONFIG_SCHEMA(config) # type: ignore except vol.Invalid as ex: async_log_exception(ex, domain, config, hass) return None @@ -640,7 +671,8 @@ def async_process_component_config(hass, config, domain): for p_name, p_config in config_per_platform(config, domain): # Validate component specific platform schema try: - p_validated = component.PLATFORM_SCHEMA(p_config) + p_validated = component.PLATFORM_SCHEMA( # type: ignore + p_config) except vol.Invalid as ex: async_log_exception(ex, domain, config, hass) continue @@ -661,7 +693,8 @@ def async_process_component_config(hass, config, domain): if hasattr(platform, 'PLATFORM_SCHEMA'): # pylint: disable=no-member try: - p_validated = platform.PLATFORM_SCHEMA(p_validated) + p_validated = platform.PLATFORM_SCHEMA( # type: ignore + p_validated) except vol.Invalid as ex: async_log_exception(ex, '{}.{}'.format(domain, p_name), p_validated, hass) @@ -679,14 +712,14 @@ def async_process_component_config(hass, config, domain): return config -async def async_check_ha_config_file(hass): +async def async_check_ha_config_file(hass: HomeAssistant) -> Optional[str]: """Check if Home Assistant configuration file is valid. This method is a coroutine. """ from homeassistant.scripts.check_config import check_ha_config_file - res = await hass.async_add_job( + res = await hass.async_add_executor_job( check_ha_config_file, hass) if not res.errors: @@ -695,7 +728,9 @@ async def async_check_ha_config_file(hass): @callback -def async_notify_setup_error(hass, component, display_link=False): +def async_notify_setup_error( + hass: HomeAssistant, component: str, + display_link: bool = False) -> None: """Print a persistent notification. This method must be run in the event loop. diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 1350cd7d76a5f0..b2e8389e4494e2 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -24,20 +24,24 @@ dependencies and install the requirements of the component. At a minimum, each config flow will have to define a version number and the -'init' step. +'user' step. @config_entries.HANDLERS.register(DOMAIN) - class ExampleConfigFlow(config_entries.FlowHandler): + class ExampleConfigFlow(data_entry_flow.FlowHandler): VERSION = 1 - async def async_step_init(self, user_input=None): + async def async_step_user(self, user_input=None): … -The 'init' step is the first step of a flow and is called when a user +The 'user' step is the first step of a flow and is called when a user starts a new flow. Each step has three different possible results: "Show Form", "Abort" and "Create Entry". +> Note: prior 0.76, the default step is 'init' step, some config flows still +keep 'init' step to avoid break localization. All new config flow should use +'user' step. + ### Show Form This will show a form to the user to fill in. You define the current step, @@ -50,7 +54,7 @@ async def async_step_init(self, user_input=None): data_schema[vol.Required('password')] = str return self.async_show_form( - step_id='init', + step_id='user', title='Account Info', data_schema=vol.Schema(data_schema) ) @@ -97,10 +101,10 @@ async def async_step_init(self, user_input=None): You might want to initialize a config flow programmatically. For example, if we discover a device on the network that requires user interaction to finish setup. To do so, pass a source parameter and optional user input to the init -step: +method: await hass.config_entries.flow.async_init( - 'hue', source='discovery', data=discovery_info) + 'hue', context={'source': 'discovery'}, data=discovery_info) The config flow handler will need to add a step to support the source. The step should follow the same return values as a normal step. @@ -112,27 +116,39 @@ async def async_step_discovery(info): """ import logging -import os import uuid +from typing import Set, Optional, List # noqa pylint: disable=unused-import -from . import data_entry_flow -from .core import callback -from .exceptions import HomeAssistantError -from .setup import async_setup_component, async_process_deps_reqs -from .util.json import load_json, save_json -from .util.decorator import Registry +from homeassistant import data_entry_flow +from homeassistant.core import callback, HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component, async_process_deps_reqs +from homeassistant.util.decorator import Registry _LOGGER = logging.getLogger(__name__) + +SOURCE_USER = 'user' +SOURCE_DISCOVERY = 'discovery' +SOURCE_IMPORT = 'import' + HANDLERS = Registry() # Components that have config flows. In future we will auto-generate this list. FLOWS = [ + 'cast', 'deconz', + 'homematicip_cloud', 'hue', + 'nest', + 'sonos', 'zone', ] +STORAGE_KEY = 'core.config_entries' +STORAGE_VERSION = 1 + +# Deprecated since 0.73 PATH_CONFIG = '.config_entries.json' SAVE_DELAY = 1 @@ -143,7 +159,12 @@ async def async_step_discovery(info): ENTRY_STATE_FAILED_UNLOAD = 'failed_unload' DISCOVERY_NOTIFICATION_ID = 'config_entry_discovery' -DISCOVERY_SOURCES = (data_entry_flow.SOURCE_DISCOVERY,) +DISCOVERY_SOURCES = ( + SOURCE_DISCOVERY, + SOURCE_IMPORT, +) + +EVENT_FLOW_DISCOVERED = 'config_entry_discovered' class ConfigEntry: @@ -152,8 +173,9 @@ class ConfigEntry: __slots__ = ('entry_id', 'version', 'domain', 'title', 'data', 'source', 'state') - def __init__(self, version, domain, title, data, source, entry_id=None, - state=ENTRY_STATE_NOT_LOADED): + def __init__(self, version: str, domain: str, title: str, data: dict, + source: str, entry_id: Optional[str] = None, + state: str = ENTRY_STATE_NOT_LOADED) -> None: """Initialize a config entry.""" # Unique id of the config entry self.entry_id = entry_id or uuid.uuid4().hex @@ -176,7 +198,8 @@ def __init__(self, version, domain, title, data, source, entry_id=None, # State of the entry (LOADED, NOT_LOADED) self.state = state - async def async_setup(self, hass, *, component=None): + async def async_setup( + self, hass: HomeAssistant, *, component=None) -> None: """Set up an entry.""" if component is None: component = getattr(hass.components, self.domain) @@ -256,19 +279,19 @@ class ConfigEntries: An instance of this object is available via `hass.config_entries`. """ - def __init__(self, hass, hass_config): + def __init__(self, hass: HomeAssistant, hass_config: dict) -> None: """Initialize the entry manager.""" self.hass = hass self.flow = data_entry_flow.FlowManager( hass, self._async_create_flow, self._async_finish_flow) self._hass_config = hass_config - self._entries = None - self._sched_save = None + self._entries = [] # type: List[ConfigEntry] + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) @callback - def async_domains(self): + def async_domains(self) -> List[str]: """Return domains for which we have entries.""" - seen = set() + seen = set() # type: Set[str] result = [] for entry in self._entries: @@ -279,7 +302,7 @@ def async_domains(self): return result @callback - def async_entries(self, domain=None): + def async_entries(self, domain: str = None) -> List[ConfigEntry]: """Return all entries or entries for a specific domain.""" if domain is None: return list(self._entries) @@ -297,7 +320,7 @@ async def async_remove(self, entry_id): raise UnknownEntry entry = self._entries.pop(found) - self._async_schedule_save() + await self._async_schedule_save() unloaded = await entry.async_unload(self.hass) @@ -305,15 +328,19 @@ async def async_remove(self, entry_id): 'require_restart': not unloaded } - async def async_load(self): - """Load the config.""" - path = self.hass.config.path(PATH_CONFIG) - if not os.path.isfile(path): + async def async_load(self) -> None: + """Handle loading the config.""" + # Migrating for config entries stored before 0.73 + config = await self.hass.helpers.storage.async_migrator( + self.hass.config.path(PATH_CONFIG), self._store, + old_conf_migrate_func=_old_conf_migrator + ) + + if config is None: self._entries = [] return - entries = await self.hass.async_add_job(load_json, path) - self._entries = [ConfigEntry(**entry) for entry in entries] + self._entries = [ConfigEntry(**entry) for entry in config['entries']] async def async_forward_entry_setup(self, entry, component): """Forward the setup of an entry to a different component. @@ -345,17 +372,26 @@ async def async_forward_entry_unload(self, entry, component): return await entry.async_unload( self.hass, component=getattr(self.hass.components, component)) - async def _async_finish_flow(self, result): + async def _async_finish_flow(self, context, result): """Finish a config flow and add an entry.""" + # If no discovery config entries in progress, remove notification. + if not any(ent['context']['source'] in DISCOVERY_SOURCES for ent + in self.hass.config_entries.flow.async_progress()): + self.hass.components.persistent_notification.async_dismiss( + DISCOVERY_NOTIFICATION_ID) + + if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + return None + entry = ConfigEntry( version=result['version'], domain=result['handler'], title=result['title'], data=result['data'], - source=result['source'], + source=context['source'], ) self._entries.append(entry) - self._async_schedule_save() + await self._async_schedule_save() # Setup entry if entry.domain in self.hass.config.components: @@ -367,34 +403,31 @@ async def _async_finish_flow(self, result): self.hass, entry.domain, self._hass_config) # Return Entry if they not from a discovery request - if result['source'] not in DISCOVERY_SOURCES: + if context['source'] not in DISCOVERY_SOURCES: return entry - # If no discovery config entries in progress, remove notification. - if not any(ent['source'] in DISCOVERY_SOURCES for ent - in self.hass.config_entries.flow.async_progress()): - self.hass.components.persistent_notification.async_dismiss( - DISCOVERY_NOTIFICATION_ID) - return entry - async def _async_create_flow(self, handler, *, source, data): + async def _async_create_flow(self, handler_key, *, context, data): """Create a flow for specified handler. Handler key is the domain of the component that we want to setup. """ - component = getattr(self.hass.components, handler) - handler = HANDLERS.get(handler) + component = getattr(self.hass.components, handler_key) + handler = HANDLERS.get(handler_key) if handler is None: raise data_entry_flow.UnknownHandler + source = context['source'] + # Make sure requirements and dependencies of component are resolved await async_process_deps_reqs( self.hass, self._hass_config, handler, component) # Create notification. if source in DISCOVERY_SOURCES: + self.hass.bus.async_fire(EVENT_FLOW_DISCOVERED) self.hass.components.persistent_notification.async_create( title='New devices discovered', message=("We have discovered new devices on your network. " @@ -402,22 +435,18 @@ async def _async_create_flow(self, handler, *, source, data): notification_id=DISCOVERY_NOTIFICATION_ID ) - return handler() + flow = handler() + flow.init_step = source + return flow - @callback - def _async_schedule_save(self): - """Schedule saving the entity registry.""" - if self._sched_save is not None: - self._sched_save.cancel() - - self._sched_save = self.hass.loop.call_later( - SAVE_DELAY, self.hass.async_add_job, self._async_save - ) - - async def _async_save(self): + async def _async_schedule_save(self): """Save the entity registry to a file.""" - self._sched_save = None - data = [entry.as_dict() for entry in self._entries] + data = { + 'entries': [entry.as_dict() for entry in self._entries] + } + await self._store.async_save(data, delay=SAVE_DELAY) + - await self.hass.async_add_job( - save_json, self.hass.config.path(PATH_CONFIG), data) +async def _old_conf_migrator(old_config): + """Migrate the pre-0.73 config format to the latest version.""" + return {'entries': old_config} diff --git a/homeassistant/const.py b/homeassistant/const.py index 8de8922e4e08a5..14054e44663eab 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,8 +1,8 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 69 -PATCH_VERSION = '1' +MINOR_VERSION = 76 +PATCH_VERSION = '2' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) @@ -221,8 +221,8 @@ # IDs ATTR_ID = 'id' -# Data for a SERVICE_EXECUTED event -ATTR_SERVICE_CALL_ID = 'service_call_id' +# Name +ATTR_NAME = 'name' # Contains one string or a list of strings, each being an entity id ATTR_ENTITY_ID = 'entity_id' @@ -251,6 +251,7 @@ # Location of the device/sensor ATTR_LOCATION = 'location' +ATTR_BATTERY_CHARGING = 'battery_charging' ATTR_BATTERY_LEVEL = 'battery_level' ATTR_WAKEUP = 'wake_up_interval' diff --git a/homeassistant/core.py b/homeassistant/core.py index feb8d331ae8131..2b7a2479471d61 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -4,9 +4,9 @@ Home Assistant is a Home Automation framework for observing the state of entities and react to changes. """ -# pylint: disable=unused-import, too-many-lines import asyncio from concurrent.futures import ThreadPoolExecutor +import datetime import enum import logging import os @@ -15,17 +15,22 @@ import sys import threading from time import monotonic +import uuid from types import MappingProxyType -from typing import Optional, Any, Callable, List # NOQA +# pylint: disable=unused-import +from typing import ( # NOQA + Optional, Any, Callable, List, TypeVar, Dict, Coroutine, Set, + TYPE_CHECKING, Awaitable, Iterator) from async_timeout import timeout +import attr import voluptuous as vol from voluptuous.humanize import humanize_error from homeassistant.const import ( ATTR_DOMAIN, ATTR_FRIENDLY_NAME, ATTR_NOW, ATTR_SERVICE, - ATTR_SERVICE_CALL_ID, ATTR_SERVICE_DATA, EVENT_CALL_SERVICE, + ATTR_SERVICE_DATA, EVENT_CALL_SERVICE, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, EVENT_SERVICE_EXECUTED, EVENT_SERVICE_REGISTERED, EVENT_STATE_CHANGED, EVENT_TIME_CHANGED, MATCH_ALL, EVENT_HOMEASSISTANT_CLOSE, @@ -36,11 +41,22 @@ from homeassistant.util.async_ import ( run_coroutine_threadsafe, run_callback_threadsafe, fire_coroutine_threadsafe) -import homeassistant.util as util +from homeassistant import util import homeassistant.util.dt as dt_util -import homeassistant.util.location as location +from homeassistant.util import location from homeassistant.util.unit_system import UnitSystem, METRIC_SYSTEM # NOQA +# Typing imports that create a circular dependency +# pylint: disable=using-constant-test +if TYPE_CHECKING: + from homeassistant.config_entries import ConfigEntries # noqa + +# pylint: disable=invalid-name +T = TypeVar('T') +CALLABLE_T = TypeVar('CALLABLE_T', bound=Callable) +CALLBACK_TYPE = Callable[[], None] +# pylint: enable=invalid-name + DOMAIN = 'homeassistant' # How long we wait for the result of a service call @@ -70,20 +86,19 @@ def valid_state(state: str) -> bool: return len(state) < 256 -def callback(func: Callable[..., None]) -> Callable[..., None]: +def callback(func: CALLABLE_T) -> CALLABLE_T: """Annotation to mark method as safe to call from within the event loop.""" - # pylint: disable=protected-access - func._hass_callback = True + setattr(func, '_hass_callback', True) return func def is_callback(func: Callable[..., Any]) -> bool: """Check if function is safe to be called in the event loop.""" - return '_hass_callback' in getattr(func, '__dict__', {}) + return getattr(func, '_hass_callback', False) is True @callback -def async_loop_exception_handler(loop, context): +def async_loop_exception_handler(_: Any, context: Dict) -> None: """Handle all exception inside the core loop.""" kwargs = {} exception = context.get('exception') @@ -91,7 +106,8 @@ def async_loop_exception_handler(loop, context): kwargs['exc_info'] = (type(exception), exception, exception.__traceback__) - _LOGGER.error("Error doing job: %s", context['message'], **kwargs) + _LOGGER.error( # type: ignore + "Error doing job: %s", context['message'], **kwargs) class CoreState(enum.Enum): @@ -104,27 +120,29 @@ class CoreState(enum.Enum): def __str__(self) -> str: """Return the event.""" - return self.value + return self.value # type: ignore -class HomeAssistant(object): +class HomeAssistant: """Root object of the Home Assistant home automation.""" - def __init__(self, loop=None): + def __init__( + self, + loop: Optional[asyncio.events.AbstractEventLoop] = None) -> None: """Initialize new Home Assistant object.""" if sys.platform == 'win32': self.loop = loop or asyncio.ProactorEventLoop() else: self.loop = loop or asyncio.get_event_loop() - executor_opts = {'max_workers': None} + executor_opts = {'max_workers': None} # type: Dict[str, Any] if sys.version_info[:2] >= (3, 6): executor_opts['thread_name_prefix'] = 'SyncWorker' self.executor = ThreadPoolExecutor(**executor_opts) self.loop.set_default_executor(self.executor) self.loop.set_exception_handler(async_loop_exception_handler) - self._pending_tasks = [] + self._pending_tasks = [] # type: list self._track_task = True self.bus = EventBus(self) self.services = ServiceRegistry(self) @@ -133,16 +151,17 @@ def __init__(self, loop=None): self.components = loader.Components(self) self.helpers = loader.Helpers(self) # This is a dictionary that any component can store any data on. - self.data = {} + self.data = {} # type: dict self.state = CoreState.not_running - self.exit_code = None + self.exit_code = 0 # type: int + self.config_entries = None # type: Optional[ConfigEntries] @property def is_running(self) -> bool: """Return if Home Assistant is running.""" return self.state in (CoreState.starting, CoreState.running) - def start(self) -> None: + def start(self) -> int: """Start home assistant.""" # Register the async start fire_coroutine_threadsafe(self.async_start(), self.loop) @@ -152,15 +171,15 @@ def start(self) -> None: # Block until stopped _LOGGER.info("Starting Home Assistant core loop") self.loop.run_forever() - return self.exit_code except KeyboardInterrupt: self.loop.call_soon_threadsafe( self.loop.create_task, self.async_stop()) self.loop.run_forever() finally: self.loop.close() + return self.exit_code - async def async_start(self): + async def async_start(self) -> None: """Finalize startup from inside the event loop. This method is a coroutine. @@ -168,14 +187,13 @@ async def async_start(self): _LOGGER.info("Starting Home Assistant") self.state = CoreState.starting - # pylint: disable=protected-access - self.loop._thread_ident = threading.get_ident() + setattr(self.loop, '_thread_ident', threading.get_ident()) self.bus.async_fire(EVENT_HOMEASSISTANT_START) try: # Only block for EVENT_HOMEASSISTANT_START listener self.async_stop_track_tasks() - with timeout(TIMEOUT_EVENT_START, loop=self.loop): + with timeout(TIMEOUT_EVENT_START): await self.async_block_till_done() except asyncio.TimeoutError: _LOGGER.warning( @@ -185,7 +203,7 @@ async def async_start(self): ', '.join(self.config.components)) # Allow automations to set up the start triggers before changing state - await asyncio.sleep(0, loop=self.loop) + await asyncio.sleep(0) self.state = CoreState.running _async_create_timer(self) @@ -200,8 +218,11 @@ def add_job(self, target: Callable[..., None], *args: Any) -> None: self.loop.call_soon_threadsafe(self.async_add_job, target, *args) @callback - def async_add_job(self, target: Callable[..., None], *args: Any) -> None: - """Add a job from within the eventloop. + def async_add_job( + self, + target: Callable[..., Any], + *args: Any) -> Optional[asyncio.Future]: + """Add a job from within the event loop. This method must be run in the event loop. @@ -211,13 +232,14 @@ def async_add_job(self, target: Callable[..., None], *args: Any) -> None: task = None if asyncio.iscoroutine(target): - task = self.loop.create_task(target) + task = self.loop.create_task(target) # type: ignore elif is_callback(target): self.loop.call_soon(target, *args) elif asyncio.iscoroutinefunction(target): task = self.loop.create_task(target(*args)) else: - task = self.loop.run_in_executor(None, target, *args) + task = self.loop.run_in_executor( # type: ignore + None, target, *args) # If a task is scheduled if self._track_task and task is not None: @@ -226,12 +248,42 @@ def async_add_job(self, target: Callable[..., None], *args: Any) -> None: return task @callback - def async_track_tasks(self): + def async_create_task(self, target: Coroutine) -> asyncio.tasks.Task: + """Create a task from within the eventloop. + + This method must be run in the event loop. + + target: target to call. + """ + task = self.loop.create_task(target) # type: asyncio.tasks.Task + + if self._track_task: + self._pending_tasks.append(task) + + return task + + @callback + def async_add_executor_job( + self, + target: Callable[..., T], + *args: Any) -> Awaitable[T]: + """Add an executor job from within the event loop.""" + task = self.loop.run_in_executor( + None, target, *args) + + # If a task is scheduled + if self._track_task: + self._pending_tasks.append(task) + + return task + + @callback + def async_track_tasks(self) -> None: """Track tasks so you can wait for all tasks to be done.""" self._track_task = True @callback - def async_stop_track_tasks(self): + def async_stop_track_tasks(self) -> None: """Stop track tasks so you can't wait for all tasks to be done.""" self._track_task = False @@ -254,25 +306,25 @@ def block_till_done(self) -> None: run_coroutine_threadsafe( self.async_block_till_done(), loop=self.loop).result() - async def async_block_till_done(self): + async def async_block_till_done(self) -> None: """Block till all pending work is done.""" # To flush out any call_soon_threadsafe - await asyncio.sleep(0, loop=self.loop) + await asyncio.sleep(0) while self._pending_tasks: pending = [task for task in self._pending_tasks if not task.done()] self._pending_tasks.clear() if pending: - await asyncio.wait(pending, loop=self.loop) + await asyncio.wait(pending) else: - await asyncio.sleep(0, loop=self.loop) + await asyncio.sleep(0) def stop(self) -> None: """Stop Home Assistant and shuts down all threads.""" fire_coroutine_threadsafe(self.async_stop(), self.loop) - async def async_stop(self, exit_code=0) -> None: + async def async_stop(self, exit_code: int = 0) -> None: """Stop Home Assistant and shuts down all threads. This method is a coroutine. @@ -293,31 +345,55 @@ async def async_stop(self, exit_code=0) -> None: self.loop.stop() +@attr.s(slots=True, frozen=True) +class Context: + """The context that triggered something.""" + + user_id = attr.ib( + type=str, + default=None, + ) + id = attr.ib( + type=str, + default=attr.Factory(lambda: uuid.uuid4().hex), + ) + + def as_dict(self) -> dict: + """Return a dictionary representation of the context.""" + return { + 'id': self.id, + 'user_id': self.user_id, + } + + class EventOrigin(enum.Enum): """Represent the origin of an event.""" local = 'LOCAL' remote = 'REMOTE' - def __str__(self): + def __str__(self) -> str: """Return the event.""" - return self.value + return self.value # type: ignore -class Event(object): +class Event: """Representation of an event within the bus.""" - __slots__ = ['event_type', 'data', 'origin', 'time_fired'] + __slots__ = ['event_type', 'data', 'origin', 'time_fired', 'context'] - def __init__(self, event_type, data=None, origin=EventOrigin.local, - time_fired=None): + def __init__(self, event_type: str, data: Optional[Dict] = None, + origin: EventOrigin = EventOrigin.local, + time_fired: Optional[int] = None, + context: Optional[Context] = None) -> None: """Initialize a new event.""" self.event_type = event_type self.data = data or {} self.origin = origin self.time_fired = time_fired or dt_util.utcnow() + self.context = context or Context() - def as_dict(self): + def as_dict(self) -> Dict: """Create a dict representation of this Event. Async friendly. @@ -327,9 +403,10 @@ def as_dict(self): 'data': dict(self.data), 'origin': str(self.origin), 'time_fired': self.time_fired, + 'context': self.context.as_dict() } - def __repr__(self): + def __repr__(self) -> str: """Return the representation.""" # pylint: disable=maybe-no-member if self.data: @@ -340,25 +417,26 @@ def __repr__(self): return "".format(self.event_type, str(self.origin)[0]) - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: """Return the comparison.""" - return (self.__class__ == other.__class__ and + return (self.__class__ == other.__class__ and # type: ignore self.event_type == other.event_type and self.data == other.data and self.origin == other.origin and - self.time_fired == other.time_fired) + self.time_fired == other.time_fired and + self.context == other.context) -class EventBus(object): +class EventBus: """Allow the firing of and listening for events.""" def __init__(self, hass: HomeAssistant) -> None: """Initialize a new event bus.""" - self._listeners = {} + self._listeners = {} # type: Dict[str, List[Callable]] self._hass = hass @callback - def async_listeners(self): + def async_listeners(self) -> Dict[str, int]: """Return dictionary with events and the number of listeners. This method must be run in the event loop. @@ -367,20 +445,23 @@ def async_listeners(self): for key in self._listeners} @property - def listeners(self): + def listeners(self) -> Dict[str, int]: """Return dictionary with events and the number of listeners.""" - return run_callback_threadsafe( + return run_callback_threadsafe( # type: ignore self._hass.loop, self.async_listeners ).result() - def fire(self, event_type: str, event_data=None, origin=EventOrigin.local): + def fire(self, event_type: str, event_data: Optional[Dict] = None, + origin: EventOrigin = EventOrigin.local, + context: Optional[Context] = None) -> None: """Fire an event.""" self._hass.loop.call_soon_threadsafe( - self.async_fire, event_type, event_data, origin) + self.async_fire, event_type, event_data, origin, context) @callback - def async_fire(self, event_type: str, event_data=None, - origin=EventOrigin.local): + def async_fire(self, event_type: str, event_data: Optional[Dict] = None, + origin: EventOrigin = EventOrigin.local, + context: Optional[Context] = None) -> None: """Fire an event. This method must be run in the event loop. @@ -393,7 +474,7 @@ def async_fire(self, event_type: str, event_data=None, event_type != EVENT_HOMEASSISTANT_CLOSE): listeners = match_all_listeners + listeners - event = Event(event_type, event_data, origin) + event = Event(event_type, event_data, origin, None, context) if event_type != EVENT_TIME_CHANGED: _LOGGER.info("Bus:Handling %s", event) @@ -404,7 +485,8 @@ def async_fire(self, event_type: str, event_data=None, for func in listeners: self._hass.async_add_job(func, event) - def listen(self, event_type, listener): + def listen( + self, event_type: str, listener: Callable) -> CALLBACK_TYPE: """Listen for all events or events of a specific type. To listen to all events specify the constant ``MATCH_ALL`` @@ -413,7 +495,7 @@ def listen(self, event_type, listener): async_remove_listener = run_callback_threadsafe( self._hass.loop, self.async_listen, event_type, listener).result() - def remove_listener(): + def remove_listener() -> None: """Remove the listener.""" run_callback_threadsafe( self._hass.loop, async_remove_listener).result() @@ -421,7 +503,8 @@ def remove_listener(): return remove_listener @callback - def async_listen(self, event_type, listener): + def async_listen( + self, event_type: str, listener: Callable) -> CALLBACK_TYPE: """Listen for all events or events of a specific type. To listen to all events specify the constant ``MATCH_ALL`` @@ -434,13 +517,14 @@ def async_listen(self, event_type, listener): else: self._listeners[event_type] = [listener] - def remove_listener(): + def remove_listener() -> None: """Remove the listener.""" self._async_remove_listener(event_type, listener) return remove_listener - def listen_once(self, event_type, listener): + def listen_once( + self, event_type: str, listener: Callable) -> CALLBACK_TYPE: """Listen once for event of a specific type. To listen to all events specify the constant ``MATCH_ALL`` @@ -452,7 +536,7 @@ def listen_once(self, event_type, listener): self._hass.loop, self.async_listen_once, event_type, listener, ).result() - def remove_listener(): + def remove_listener() -> None: """Remove the listener.""" run_callback_threadsafe( self._hass.loop, async_remove_listener).result() @@ -460,7 +544,8 @@ def remove_listener(): return remove_listener @callback - def async_listen_once(self, event_type, listener): + def async_listen_once( + self, event_type: str, listener: Callable) -> CALLBACK_TYPE: """Listen once for event of a specific type. To listen to all events specify the constant ``MATCH_ALL`` @@ -471,8 +556,8 @@ def async_listen_once(self, event_type, listener): This method must be run in the event loop. """ @callback - def onetime_listener(event): - """Remove listener from eventbus and then fire listener.""" + def onetime_listener(event: Event) -> None: + """Remove listener from event bus and then fire listener.""" if hasattr(onetime_listener, 'run'): return # Set variable so that we will never run twice. @@ -487,7 +572,8 @@ def onetime_listener(event): return self.async_listen(event_type, onetime_listener) @callback - def _async_remove_listener(self, event_type, listener): + def _async_remove_listener( + self, event_type: str, listener: Callable) -> None: """Remove a listener of a specific event_type. This method must be run in the event loop. @@ -504,7 +590,7 @@ def _async_remove_listener(self, event_type, listener): _LOGGER.warning("Unable to remove unknown listener %s", listener) -class State(object): +class State: """Object to represent a state within the state machine. entity_id: the entity that is represented. @@ -512,13 +598,17 @@ class State(object): attributes: extra information on entity and state last_changed: last time the state was changed, not the attributes. last_updated: last time this object was updated. + context: Context in which it was created """ __slots__ = ['entity_id', 'state', 'attributes', - 'last_changed', 'last_updated'] + 'last_changed', 'last_updated', 'context'] - def __init__(self, entity_id, state, attributes=None, last_changed=None, - last_updated=None): + def __init__(self, entity_id: str, state: Any, + attributes: Optional[Dict] = None, + last_changed: Optional[datetime.datetime] = None, + last_updated: Optional[datetime.datetime] = None, + context: Optional[Context] = None) -> None: """Initialize a new state.""" state = str(state) @@ -537,25 +627,26 @@ def __init__(self, entity_id, state, attributes=None, last_changed=None, self.attributes = MappingProxyType(attributes or {}) self.last_updated = last_updated or dt_util.utcnow() self.last_changed = last_changed or self.last_updated + self.context = context or Context() @property - def domain(self): + def domain(self) -> str: """Domain of this state.""" return split_entity_id(self.entity_id)[0] @property - def object_id(self): + def object_id(self) -> str: """Object id of this state.""" return split_entity_id(self.entity_id)[1] @property - def name(self): + def name(self) -> str: """Name of this state.""" return ( self.attributes.get(ATTR_FRIENDLY_NAME) or self.object_id.replace('_', ' ')) - def as_dict(self): + def as_dict(self) -> Dict: """Return a dict representation of the State. Async friendly. @@ -567,10 +658,11 @@ def as_dict(self): 'state': self.state, 'attributes': dict(self.attributes), 'last_changed': self.last_changed, - 'last_updated': self.last_updated} + 'last_updated': self.last_updated, + 'context': self.context.as_dict()} @classmethod - def from_dict(cls, json_dict): + def from_dict(cls, json_dict: Dict) -> Any: """Initialize a state from a dict. Async friendly. @@ -591,44 +683,52 @@ def from_dict(cls, json_dict): if isinstance(last_updated, str): last_updated = dt_util.parse_datetime(last_updated) + context = json_dict.get('context') + if context: + context = Context(**context) + return cls(json_dict['entity_id'], json_dict['state'], - json_dict.get('attributes'), last_changed, last_updated) + json_dict.get('attributes'), last_changed, last_updated, + context) - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: """Return the comparison of the state.""" - return (self.__class__ == other.__class__ and + return (self.__class__ == other.__class__ and # type: ignore self.entity_id == other.entity_id and self.state == other.state and - self.attributes == other.attributes) + self.attributes == other.attributes and + self.context == other.context) - def __repr__(self): + def __repr__(self) -> str: """Return the representation of the states.""" - attr = "; {}".format(util.repr_helper(self.attributes)) \ - if self.attributes else "" + attrs = "; {}".format(util.repr_helper(self.attributes)) \ + if self.attributes else "" return "".format( - self.entity_id, self.state, attr, + self.entity_id, self.state, attrs, dt_util.as_local(self.last_changed).isoformat()) -class StateMachine(object): +class StateMachine: """Helper class that tracks the state of different entities.""" - def __init__(self, bus, loop): + def __init__(self, bus: EventBus, + loop: asyncio.events.AbstractEventLoop) -> None: """Initialize state machine.""" - self._states = {} + self._states = {} # type: Dict[str, State] self._bus = bus self._loop = loop - def entity_ids(self, domain_filter=None): + def entity_ids(self, domain_filter: Optional[str] = None)-> List[str]: """List of entity ids that are being tracked.""" future = run_callback_threadsafe( self._loop, self.async_entity_ids, domain_filter ) - return future.result() + return future.result() # type: ignore @callback - def async_entity_ids(self, domain_filter=None): + def async_entity_ids( + self, domain_filter: Optional[str] = None) -> List[str]: """List of entity ids that are being tracked. This method must be run in the event loop. @@ -641,26 +741,27 @@ def async_entity_ids(self, domain_filter=None): return [state.entity_id for state in self._states.values() if state.domain == domain_filter] - def all(self): + def all(self)-> List[State]: """Create a list of all states.""" - return run_callback_threadsafe(self._loop, self.async_all).result() + return run_callback_threadsafe( # type: ignore + self._loop, self.async_all).result() @callback - def async_all(self): + def async_all(self)-> List[State]: """Create a list of all states. This method must be run in the event loop. """ return list(self._states.values()) - def get(self, entity_id): + def get(self, entity_id: str) -> Optional[State]: """Retrieve state of entity_id or None if not found. Async friendly. """ return self._states.get(entity_id.lower()) - def is_state(self, entity_id, state): + def is_state(self, entity_id: str, state: State) -> bool: """Test if entity exists and is specified state. Async friendly. @@ -668,16 +769,16 @@ def is_state(self, entity_id, state): state_obj = self.get(entity_id) return state_obj is not None and state_obj.state == state - def remove(self, entity_id): + def remove(self, entity_id: str) -> bool: """Remove the state of an entity. Returns boolean to indicate if an entity was removed. """ - return run_callback_threadsafe( + return run_callback_threadsafe( # type: ignore self._loop, self.async_remove, entity_id).result() @callback - def async_remove(self, entity_id): + def async_remove(self, entity_id: str) -> bool: """Remove the state of an entity. Returns boolean to indicate if an entity was removed. @@ -697,7 +798,10 @@ def async_remove(self, entity_id): }) return True - def set(self, entity_id, new_state, attributes=None, force_update=False): + def set(self, entity_id: str, new_state: Any, + attributes: Optional[Dict] = None, + force_update: bool = False, + context: Optional[Context] = None) -> None: """Set the state of an entity, add entity if it does not exist. Attributes is an optional dict to specify attributes of this state. @@ -708,11 +812,14 @@ def set(self, entity_id, new_state, attributes=None, force_update=False): run_callback_threadsafe( self._loop, self.async_set, entity_id, new_state, attributes, force_update, + context, ).result() @callback - def async_set(self, entity_id, new_state, attributes=None, - force_update=False): + def async_set(self, entity_id: str, new_state: Any, + attributes: Optional[Dict] = None, + force_update: bool = False, + context: Optional[Context] = None) -> None: """Set the state of an entity, add entity if it does not exist. Attributes is an optional dict to specify attributes of this state. @@ -726,30 +833,39 @@ def async_set(self, entity_id, new_state, attributes=None, new_state = str(new_state) attributes = attributes or {} old_state = self._states.get(entity_id) - is_existing = old_state is not None - same_state = (is_existing and old_state.state == new_state and - not force_update) - same_attr = is_existing and old_state.attributes == attributes + if old_state is None: + same_state = False + same_attr = False + last_changed = None + else: + same_state = (old_state.state == new_state and + not force_update) + same_attr = old_state.attributes == attributes + last_changed = old_state.last_changed if same_state else None if same_state and same_attr: return - last_changed = old_state.last_changed if same_state else None - state = State(entity_id, new_state, attributes, last_changed) + if context is None: + context = Context() + + state = State(entity_id, new_state, attributes, last_changed, None, + context) self._states[entity_id] = state self._bus.async_fire(EVENT_STATE_CHANGED, { 'entity_id': entity_id, 'old_state': old_state, 'new_state': state, - }) + }, EventOrigin.local, context) -class Service(object): +class Service: """Representation of a callable service.""" __slots__ = ['func', 'schema', 'is_callback', 'is_coroutinefunction'] - def __init__(self, func, schema): + def __init__(self, func: Callable, schema: Optional[vol.Schema], + context: Optional[Context] = None) -> None: """Initialize a service.""" self.func = func self.schema = schema @@ -757,54 +873,48 @@ def __init__(self, func, schema): self.is_coroutinefunction = asyncio.iscoroutinefunction(func) -class ServiceCall(object): +class ServiceCall: """Representation of a call to a service.""" - __slots__ = ['domain', 'service', 'data', 'call_id'] + __slots__ = ['domain', 'service', 'data', 'context'] - def __init__(self, domain, service, data=None, call_id=None): + def __init__(self, domain: str, service: str, data: Optional[Dict] = None, + context: Optional[Context] = None) -> None: """Initialize a service call.""" self.domain = domain.lower() self.service = service.lower() self.data = MappingProxyType(data or {}) - self.call_id = call_id + self.context = context or Context() - def __repr__(self): + def __repr__(self) -> str: """Return the representation of the service.""" if self.data: - return "".format( - self.domain, self.service, util.repr_helper(self.data)) + return "".format( + self.domain, self.service, self.context.id, + util.repr_helper(self.data)) - return "".format(self.domain, self.service) + return "".format( + self.domain, self.service, self.context.id) -class ServiceRegistry(object): +class ServiceRegistry: """Offer the services over the eventbus.""" - def __init__(self, hass): + def __init__(self, hass: HomeAssistant) -> None: """Initialize a service registry.""" - self._services = {} + self._services = {} # type: Dict[str, Dict[str, Service]] self._hass = hass - self._async_unsub_call_event = None - - def _gen_unique_id(): - cur_id = 1 - while True: - yield '{}-{}'.format(id(self), cur_id) - cur_id += 1 - - gen = _gen_unique_id() - self._generate_unique_id = lambda: next(gen) + self._async_unsub_call_event = None # type: Optional[CALLBACK_TYPE] @property - def services(self): + def services(self) -> Dict[str, Dict[str, Service]]: """Return dictionary with per domain a list of available services.""" - return run_callback_threadsafe( + return run_callback_threadsafe( # type: ignore self._hass.loop, self.async_services, ).result() @callback - def async_services(self): + def async_services(self) -> Dict[str, Dict[str, Service]]: """Return dictionary with per domain a list of available services. This method must be run in the event loop. @@ -812,14 +922,15 @@ def async_services(self): return {domain: self._services[domain].copy() for domain in self._services} - def has_service(self, domain, service): + def has_service(self, domain: str, service: str) -> bool: """Test if specified service exists. Async friendly. """ return service.lower() in self._services.get(domain.lower(), []) - def register(self, domain, service, service_func, schema=None): + def register(self, domain: str, service: str, service_func: Callable, + schema: Optional[vol.Schema] = None) -> None: """ Register a service. @@ -831,7 +942,8 @@ def register(self, domain, service, service_func, schema=None): ).result() @callback - def async_register(self, domain, service, service_func, schema=None): + def async_register(self, domain: str, service: str, service_func: Callable, + schema: Optional[vol.Schema] = None) -> None: """ Register a service. @@ -857,13 +969,13 @@ def async_register(self, domain, service, service_func, schema=None): {ATTR_DOMAIN: domain, ATTR_SERVICE: service} ) - def remove(self, domain, service): + def remove(self, domain: str, service: str) -> None: """Remove a registered service from service handler.""" run_callback_threadsafe( self._hass.loop, self.async_remove, domain, service).result() @callback - def async_remove(self, domain, service): + def async_remove(self, domain: str, service: str) -> None: """Remove a registered service from service handler. This method must be run in the event loop. @@ -883,7 +995,10 @@ def async_remove(self, domain, service): {ATTR_DOMAIN: domain, ATTR_SERVICE: service} ) - def call(self, domain, service, service_data=None, blocking=False): + def call(self, domain: str, service: str, + service_data: Optional[Dict] = None, + blocking: bool = False, + context: Optional[Context] = None) -> Optional[bool]: """ Call a service. @@ -900,13 +1015,15 @@ def call(self, domain, service, service_data=None, blocking=False): Because the service is sent as an event you are not allowed to use the keys ATTR_DOMAIN and ATTR_SERVICE in your service_data. """ - return run_coroutine_threadsafe( - self.async_call(domain, service, service_data, blocking), + return run_coroutine_threadsafe( # type: ignore + self.async_call(domain, service, service_data, blocking, context), self._hass.loop ).result() - async def async_call(self, domain, service, service_data=None, - blocking=False): + async def async_call(self, domain: str, service: str, + service_data: Optional[Dict] = None, + blocking: bool = False, + context: Optional[Context] = None) -> Optional[bool]: """ Call a service. @@ -925,42 +1042,42 @@ async def async_call(self, domain, service, service_data=None, This method is a coroutine. """ - call_id = self._generate_unique_id() - + context = context or Context() event_data = { ATTR_DOMAIN: domain.lower(), ATTR_SERVICE: service.lower(), ATTR_SERVICE_DATA: service_data, - ATTR_SERVICE_CALL_ID: call_id, } - if blocking: - fut = asyncio.Future(loop=self._hass.loop) + if not blocking: + self._hass.bus.async_fire( + EVENT_CALL_SERVICE, event_data, EventOrigin.local, context) + return None + + fut = asyncio.Future() # type: asyncio.Future - @callback - def service_executed(event): - """Handle an executed service.""" - if event.data[ATTR_SERVICE_CALL_ID] == call_id: - fut.set_result(True) + @callback + def service_executed(event: Event) -> None: + """Handle an executed service.""" + if event.context == context: + fut.set_result(True) - unsub = self._hass.bus.async_listen( - EVENT_SERVICE_EXECUTED, service_executed) + unsub = self._hass.bus.async_listen( + EVENT_SERVICE_EXECUTED, service_executed) - self._hass.bus.async_fire(EVENT_CALL_SERVICE, event_data) + self._hass.bus.async_fire(EVENT_CALL_SERVICE, event_data, + EventOrigin.local, context) - if blocking: - done, _ = await asyncio.wait( - [fut], loop=self._hass.loop, timeout=SERVICE_CALL_LIMIT) - success = bool(done) - unsub() - return success + done, _ = await asyncio.wait([fut], timeout=SERVICE_CALL_LIMIT) + success = bool(done) + unsub() + return success - async def _event_to_service_call(self, event): + async def _event_to_service_call(self, event: Event) -> None: """Handle the SERVICE_CALLED events from the EventBus.""" service_data = event.data.get(ATTR_SERVICE_DATA) or {} - domain = event.data.get(ATTR_DOMAIN).lower() - service = event.data.get(ATTR_SERVICE).lower() - call_id = event.data.get(ATTR_SERVICE_CALL_ID) + domain = event.data.get(ATTR_DOMAIN).lower() # type: ignore + service = event.data.get(ATTR_SERVICE).lower() # type: ignore if not self.has_service(domain, service): if event.origin == EventOrigin.local: @@ -970,18 +1087,15 @@ async def _event_to_service_call(self, event): service_handler = self._services[domain][service] - def fire_service_executed(): + def fire_service_executed() -> None: """Fire service executed event.""" - if not call_id: - return - - data = {ATTR_SERVICE_CALL_ID: call_id} - if (service_handler.is_coroutinefunction or service_handler.is_callback): - self._hass.bus.async_fire(EVENT_SERVICE_EXECUTED, data) + self._hass.bus.async_fire(EVENT_SERVICE_EXECUTED, {}, + EventOrigin.local, event.context) else: - self._hass.bus.fire(EVENT_SERVICE_EXECUTED, data) + self._hass.bus.fire(EVENT_SERVICE_EXECUTED, {}, + EventOrigin.local, event.context) try: if service_handler.schema: @@ -992,7 +1106,8 @@ def fire_service_executed(): fire_service_executed() return - service_call = ServiceCall(domain, service, service_data, call_id) + service_call = ServiceCall( + domain, service, service_data, event.context) try: if service_handler.is_callback: @@ -1002,44 +1117,44 @@ def fire_service_executed(): await service_handler.func(service_call) fire_service_executed() else: - def execute_service(): + def execute_service() -> None: """Execute a service and fires a SERVICE_EXECUTED event.""" service_handler.func(service_call) fire_service_executed() - await self._hass.async_add_job(execute_service) + await self._hass.async_add_executor_job(execute_service) except Exception: # pylint: disable=broad-except _LOGGER.exception('Error executing service %s', service_call) -class Config(object): +class Config: """Configuration settings for Home Assistant.""" - def __init__(self): + def __init__(self) -> None: """Initialize a new config object.""" self.latitude = None # type: Optional[float] self.longitude = None # type: Optional[float] self.elevation = None # type: Optional[int] self.location_name = None # type: Optional[str] - self.time_zone = None # type: Optional[str] + self.time_zone = None # type: Optional[datetime.tzinfo] self.units = METRIC_SYSTEM # type: UnitSystem # If True, pip install is skipped for requirements on startup self.skip_pip = False # type: bool # List of loaded components - self.components = set() + self.components = set() # type: set - # Remote.API object pointing at local API - self.api = None + # API (HTTP) server configuration + self.api = None # type: Optional[Any] # Directory that holds the configuration - self.config_dir = None + self.config_dir = None # type: Optional[str] # List of allowed external dirs to access - self.whitelist_external_dirs = set() + self.whitelist_external_dirs = set() # type: Set[str] - def distance(self: object, lat: float, lon: float) -> float: + def distance(self, lat: float, lon: float) -> Optional[float]: """Calculate distance from Home Assistant. Async friendly. @@ -1047,7 +1162,7 @@ def distance(self: object, lat: float, lon: float) -> float: return self.units.length( location.distance(self.latitude, self.longitude, lat, lon), 'm') - def path(self, *path): + def path(self, *path: str) -> str: """Generate path to the file within the configuration directory. Async friendly. @@ -1079,12 +1194,14 @@ def is_allowed_path(self, path: str) -> bool: return False - def as_dict(self): + def as_dict(self) -> Dict: """Create a dictionary representation of this dict. Async friendly. """ - time_zone = self.time_zone or dt_util.UTC + time_zone = dt_util.UTC.zone + if self.time_zone and getattr(self.time_zone, 'zone'): + time_zone = getattr(self.time_zone, 'zone') return { 'latitude': self.latitude, @@ -1092,7 +1209,7 @@ def as_dict(self): 'elevation': self.elevation, 'unit_system': self.units.as_dict(), 'location_name': self.location_name, - 'time_zone': time_zone.zone, + 'time_zone': time_zone, 'components': self.components, 'config_dir': self.config_dir, 'whitelist_external_dirs': self.whitelist_external_dirs, @@ -1100,12 +1217,12 @@ def as_dict(self): } -def _async_create_timer(hass): +def _async_create_timer(hass: HomeAssistant) -> None: """Create a timer that will start on HOMEASSISTANT_START.""" handle = None @callback - def fire_time_event(nxt): + def fire_time_event(nxt: float) -> None: """Fire next time event.""" nonlocal handle @@ -1122,7 +1239,7 @@ def fire_time_event(nxt): handle = hass.loop.call_later(slp_seconds, fire_time_event, nxt) @callback - def stop_timer(event): + def stop_timer(_: Event) -> None: """Stop the timer.""" if handle is not None: handle.cancel() diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index e9580aba273a6c..f820911e39680c 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -1,15 +1,13 @@ """Classes to help gather user submissions.""" import logging import uuid - -from .core import callback +import voluptuous as vol +from typing import Dict, Any, Callable, Hashable, List, Optional # noqa pylint: disable=unused-import +from .core import callback, HomeAssistant from .exceptions import HomeAssistantError _LOGGER = logging.getLogger(__name__) -SOURCE_USER = 'user' -SOURCE_DISCOVERY = 'discovery' - RESULT_TYPE_FORM = 'form' RESULT_TYPE_CREATE_ENTRY = 'create_entry' RESULT_TYPE_ABORT = 'abort' @@ -34,39 +32,38 @@ class UnknownStep(FlowError): class FlowManager: """Manage all the flows that are in progress.""" - def __init__(self, hass, async_create_flow, async_finish_flow): + def __init__(self, hass: HomeAssistant, async_create_flow: Callable, + async_finish_flow: Callable) -> None: """Initialize the flow manager.""" self.hass = hass - self._progress = {} + self._progress = {} # type: Dict[str, Any] self._async_create_flow = async_create_flow self._async_finish_flow = async_finish_flow @callback - def async_progress(self): + def async_progress(self) -> List[Dict]: """Return the flows in progress.""" return [{ 'flow_id': flow.flow_id, 'handler': flow.handler, - 'source': flow.source, + 'context': flow.context, } for flow in self._progress.values()] - async def async_init(self, handler, *, source=SOURCE_USER, data=None): + async def async_init(self, handler: Hashable, *, context: Dict = None, + data: Any = None) -> Any: """Start a configuration flow.""" - flow = await self._async_create_flow(handler, source=source, data=data) + flow = await self._async_create_flow( + handler, context=context, data=data) flow.hass = self.hass flow.handler = handler flow.flow_id = uuid.uuid4().hex - flow.source = source + flow.context = context self._progress[flow.flow_id] = flow - if source == SOURCE_USER: - step = 'init' - else: - step = source + return await self._async_handle_step(flow, flow.init_step, data) - return await self._async_handle_step(flow, step, data) - - async def async_configure(self, flow_id, user_input=None): + async def async_configure( + self, flow_id: str, user_input: str = None) -> Any: """Continue a configuration flow.""" flow = self._progress.get(flow_id) @@ -82,12 +79,13 @@ async def async_configure(self, flow_id, user_input=None): flow, step_id, user_input) @callback - def async_abort(self, flow_id): + def async_abort(self, flow_id: str) -> None: """Abort a flow.""" if self._progress.pop(flow_id, None) is None: raise UnknownFlow - async def _async_handle_step(self, flow, step_id, user_input): + async def _async_handle_step(self, flow: Any, step_id: str, + user_input: Optional[str]) -> Dict: """Handle a step of a flow.""" method = "async_step_{}".format(step_id) @@ -96,7 +94,7 @@ async def _async_handle_step(self, flow, step_id, user_input): raise UnknownStep("Handler {} doesn't support step {}".format( flow.__class__.__name__, step_id)) - result = await getattr(flow, method)(user_input) + result = await getattr(flow, method)(user_input) # type: Dict if result['type'] not in (RESULT_TYPE_FORM, RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_ABORT): @@ -110,11 +108,11 @@ async def _async_handle_step(self, flow, step_id, user_input): # Abort and Success results both finish the flow self._progress.pop(flow.flow_id) - if result['type'] == RESULT_TYPE_ABORT: - return result - # We pass a copy of the result because we're mutating our version - result['result'] = await self._async_finish_flow(dict(result)) + entry = await self._async_finish_flow(flow.context, dict(result)) + + if result['type'] == RESULT_TYPE_CREATE_ENTRY: + result['result'] = entry return result @@ -125,14 +123,19 @@ class FlowHandler: flow_id = None hass = None handler = None - source = SOURCE_USER cur_step = None + context = None + + # Set by _async_create_flow callback + init_step = 'init' # Set by developer VERSION = 1 @callback - def async_show_form(self, *, step_id, data_schema=None, errors=None): + def async_show_form(self, *, step_id: str, data_schema: vol.Schema = None, + errors: Dict = None, + description_placeholders: Dict = None) -> Dict: """Return the definition of a form to gather user input.""" return { 'type': RESULT_TYPE_FORM, @@ -141,10 +144,11 @@ def async_show_form(self, *, step_id, data_schema=None, errors=None): 'step_id': step_id, 'data_schema': data_schema, 'errors': errors, + 'description_placeholders': description_placeholders, } @callback - def async_create_entry(self, *, title, data): + def async_create_entry(self, *, title: str, data: Dict) -> Dict: """Finish config flow and create a config entry.""" return { 'version': self.VERSION, @@ -153,11 +157,10 @@ def async_create_entry(self, *, title, data): 'handler': self.handler, 'title': title, 'data': data, - 'source': self.source, } @callback - def async_abort(self, *, reason): + def async_abort(self, *, reason: str) -> Dict: """Abort the config flow.""" return { 'type': RESULT_TYPE_ABORT, diff --git a/homeassistant/exceptions.py b/homeassistant/exceptions.py index cb8a3c87820195..73bd237795045b 100644 --- a/homeassistant/exceptions.py +++ b/homeassistant/exceptions.py @@ -1,4 +1,5 @@ """The exceptions used by Home Assistant.""" +import jinja2 class HomeAssistantError(Exception): @@ -22,7 +23,7 @@ class NoEntitySpecifiedError(HomeAssistantError): class TemplateError(HomeAssistantError): """Error during template rendering.""" - def __init__(self, exception): + def __init__(self, exception: jinja2.TemplateError) -> None: """Init the error.""" super().__init__('{}: {}'.format(exception.__class__.__name__, exception)) diff --git a/homeassistant/helpers/__init__.py b/homeassistant/helpers/__init__.py index 91ec50515524bf..ed489ed858b00a 100644 --- a/homeassistant/helpers/__init__.py +++ b/homeassistant/helpers/__init__.py @@ -5,16 +5,10 @@ from homeassistant.const import CONF_PLATFORM -# Typing Imports and TypeAlias -# pylint: disable=using-constant-test,unused-import,wrong-import-order -if False: - from logging import Logger # NOQA - # pylint: disable=invalid-name ConfigType = Dict[str, Any] -# pylint: disable=invalid-sequence-index def config_per_platform(config: ConfigType, domain: str) -> Iterable[Tuple[Any, Any]]: """Break a component config into different platforms. diff --git a/homeassistant/helpers/aiohttp_client.py b/homeassistant/helpers/aiohttp_client.py index bb34942ad795dd..53b246c700d346 100644 --- a/homeassistant/helpers/aiohttp_client.py +++ b/homeassistant/helpers/aiohttp_client.py @@ -1,6 +1,5 @@ """Helper for aiohttp webclient stuff.""" import asyncio -import ssl import sys import aiohttp @@ -8,11 +7,11 @@ from aiohttp import web from aiohttp.web_exceptions import HTTPGatewayTimeout, HTTPBadGateway import async_timeout -import certifi from homeassistant.core import callback from homeassistant.const import EVENT_HOMEASSISTANT_CLOSE, __version__ from homeassistant.loader import bind_hass +from homeassistant.util import ssl as ssl_util DATA_CONNECTOR = 'aiohttp_connector' DATA_CONNECTOR_NOTVERIFY = 'aiohttp_connector_notverify' @@ -67,14 +66,13 @@ def async_create_clientsession(hass, verify_ssl=True, auto_cleanup=True, return clientsession -@asyncio.coroutine @bind_hass -def async_aiohttp_proxy_web(hass, request, web_coro, buffer_size=102400, - timeout=10): +async def async_aiohttp_proxy_web(hass, request, web_coro, + buffer_size=102400, timeout=10): """Stream websession request to aiohttp web response.""" try: with async_timeout.timeout(timeout, loop=hass.loop): - req = yield from web_coro + req = await web_coro except asyncio.CancelledError: # The user cancelled the request @@ -89,7 +87,7 @@ def async_aiohttp_proxy_web(hass, request, web_coro, buffer_size=102400, raise HTTPBadGateway() from err try: - yield from async_aiohttp_proxy_stream( + return await async_aiohttp_proxy_stream( hass, request, req.content, @@ -113,22 +111,17 @@ async def async_aiohttp_proxy_stream(hass, request, stream, content_type, data = await stream.read(buffer_size) if not data: - await response.write_eof() break - await response.write(data) except (asyncio.TimeoutError, aiohttp.ClientError): - # Something went wrong fetching data, close connection gracefully - await response.write_eof() - - except asyncio.CancelledError: - # The user closed the connection + # Something went wrong fetching data, closed connection pass + return response + @callback -# pylint: disable=invalid-name def _async_register_clientsession_shutdown(hass, clientsession): """Register ClientSession close on Home Assistant shutdown. @@ -155,9 +148,7 @@ def _async_get_connector(hass, verify_ssl=True): return hass.data[key] if verify_ssl: - ssl_context = ssl.SSLContext(ssl.PROTOCOL_SSLv23) - ssl_context.load_verify_locations(cafile=certifi.where(), - capath=None) + ssl_context = ssl_util.client_context() else: ssl_context = False diff --git a/homeassistant/helpers/condition.py b/homeassistant/helpers/condition.py index cb577e8a9c73e8..930f68c3da47d9 100644 --- a/homeassistant/helpers/condition.py +++ b/homeassistant/helpers/condition.py @@ -166,7 +166,8 @@ def async_numeric_state(hass: HomeAssistant, entity, below=None, above=None, try: value = float(value) except ValueError: - _LOGGER.warning("Value cannot be processed as a number: %s", value) + _LOGGER.warning("Value cannot be processed as a number: %s " + "(Offending entity: %s)", entity, value) return False if below is not None and value >= below: @@ -245,26 +246,24 @@ def sun(hass, before=None, after=None, before_offset=None, after_offset=None): sunrise = get_astral_event_date(hass, 'sunrise', today) sunset = get_astral_event_date(hass, 'sunset', today) - if sunrise is None and (before == SUN_EVENT_SUNRISE or - after == SUN_EVENT_SUNRISE): + if sunrise is None and SUN_EVENT_SUNRISE in (before, after): # There is no sunrise today return False - if sunset is None and (before == SUN_EVENT_SUNSET or - after == SUN_EVENT_SUNSET): + if sunset is None and SUN_EVENT_SUNSET in (before, after): # There is no sunset today return False if before == SUN_EVENT_SUNRISE and utcnow > sunrise + before_offset: return False - elif before == SUN_EVENT_SUNSET and utcnow > sunset + before_offset: + if before == SUN_EVENT_SUNSET and utcnow > sunset + before_offset: return False if after == SUN_EVENT_SUNRISE and utcnow < sunrise + after_offset: return False - elif after == SUN_EVENT_SUNSET and utcnow < sunset + after_offset: + if after == SUN_EVENT_SUNSET and utcnow < sunset + after_offset: return False return True diff --git a/homeassistant/helpers/config_entry_flow.py b/homeassistant/helpers/config_entry_flow.py new file mode 100644 index 00000000000000..e17d5071c6a502 --- /dev/null +++ b/homeassistant/helpers/config_entry_flow.py @@ -0,0 +1,97 @@ +"""Helpers for data entry flows for config entries.""" +from functools import partial + +from homeassistant.core import callback +from homeassistant import config_entries, data_entry_flow + + +def register_discovery_flow(domain, title, discovery_function): + """Register flow for discovered integrations that not require auth.""" + config_entries.HANDLERS.register(domain)( + partial(DiscoveryFlowHandler, domain, title, discovery_function)) + + +class DiscoveryFlowHandler(data_entry_flow.FlowHandler): + """Handle a discovery config flow.""" + + VERSION = 1 + + def __init__(self, domain, title, discovery_function): + """Initialize the discovery config flow.""" + self._domain = domain + self._title = title + self._discovery_function = discovery_function + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + if self._async_current_entries(): + return self.async_abort( + reason='single_instance_allowed' + ) + + # Get current discovered entries. + in_progress = self._async_in_progress() + + has_devices = in_progress + if not has_devices: + has_devices = await self.hass.async_add_job( + self._discovery_function, self.hass) + + if not has_devices: + return self.async_abort( + reason='no_devices_found' + ) + + # Cancel the discovered one. + for flow in in_progress: + self.hass.config_entries.flow.async_abort(flow['flow_id']) + + return self.async_create_entry( + title=self._title, + data={}, + ) + + async def async_step_confirm(self, user_input=None): + """Confirm setup.""" + if user_input is not None: + return self.async_create_entry( + title=self._title, + data={}, + ) + + return self.async_show_form( + step_id='confirm', + ) + + async def async_step_discovery(self, discovery_info): + """Handle a flow initialized by discovery.""" + if self._async_in_progress() or self._async_current_entries(): + return self.async_abort( + reason='single_instance_allowed' + ) + + return await self.async_step_confirm() + + async def async_step_import(self, _): + """Handle a flow initialized by import.""" + if self._async_in_progress() or self._async_current_entries(): + return self.async_abort( + reason='single_instance_allowed' + ) + + return self.async_create_entry( + title=self._title, + data={}, + ) + + @callback + def _async_current_entries(self): + """Return current entries.""" + return self.hass.config_entries.async_entries(self._domain) + + @callback + def _async_in_progress(self): + """Return other in progress flows for current domain.""" + return [flw for flw in self.hass.config_entries.flow.async_progress() + if flw['handler'] == self._domain and + flw['flow_id'] != self.flow_id] diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 0bd490940a925c..056d45ad656512 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -361,7 +361,7 @@ def temperature_unit(value) -> str: value = str(value).upper() if value == 'C': return TEMP_CELSIUS - elif value == 'F': + if value == 'F': return TEMP_FAHRENHEIT raise vol.Invalid('invalid temperature unit (expected C or F)') @@ -435,15 +435,14 @@ def socket_timeout(value): """ if value is None: return _GLOBAL_DEFAULT_TIMEOUT - else: - try: - float_value = float(value) - if float_value > 0.0: - return float_value - raise vol.Invalid('Invalid socket timeout value.' - ' float > 0.0 required.') - except Exception as _: - raise vol.Invalid('Invalid socket timeout: {err}'.format(err=_)) + try: + float_value = float(value) + if float_value > 0.0: + return float_value + raise vol.Invalid('Invalid socket timeout value.' + ' float > 0.0 required.') + except Exception as _: + raise vol.Invalid('Invalid socket timeout: {err}'.format(err=_)) # pylint: disable=no-value-for-parameter diff --git a/homeassistant/helpers/data_entry_flow.py b/homeassistant/helpers/data_entry_flow.py index 913e90a859daa2..378febf8f6d623 100644 --- a/homeassistant/helpers/data_entry_flow.py +++ b/homeassistant/helpers/data_entry_flow.py @@ -2,7 +2,7 @@ import voluptuous as vol -from homeassistant import data_entry_flow +from homeassistant import data_entry_flow, config_entries from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator @@ -23,7 +23,7 @@ def _prepare_result_json(self, result): data.pop('data') return data - elif result['type'] != data_entry_flow.RESULT_TYPE_FORM: + if result['type'] != data_entry_flow.RESULT_TYPE_FORM: return result import voluptuous_serialize @@ -44,7 +44,7 @@ class FlowManagerIndexView(_BaseFlowManagerView): @RequestDataValidator(vol.Schema({ vol.Required('handler'): vol.Any(str, list), - })) + }, extra=vol.ALLOW_EXTRA)) async def post(self, request, data): """Handle a POST request.""" if isinstance(data['handler'], list): @@ -53,7 +53,8 @@ async def post(self, request, data): handler = data['handler'] try: - result = await self._flow_mgr.async_init(handler) + result = await self._flow_mgr.async_init( + handler, context={'source': config_entries.SOURCE_USER}) except data_entry_flow.UnknownHandler: return self.json_message('Invalid handler specified', 404) except data_entry_flow.UnknownStep: diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 73a09464439885..8b621b2f01c692 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -33,8 +33,7 @@ def func_wrapper(self): # Return the old property return getattr(self, substitute_name) - else: - return func(self) + return func(self) return func_wrapper return decorator diff --git a/homeassistant/helpers/discovery.py b/homeassistant/helpers/discovery.py index cb587c432c1816..7d0730a969c68c 100644 --- a/homeassistant/helpers/discovery.py +++ b/homeassistant/helpers/discovery.py @@ -145,7 +145,7 @@ async def async_load_platform(hass, component, platform, discovered=None, Use `listen_platform` to register a callback for these events. Warning: Do not await this inside a setup method to avoid a dead lock. - Use `hass.async_add_job(async_load_platform(..))` instead. + Use `hass.async_create_task(async_load_platform(..))` instead. This method is a coroutine. """ diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index efaefc26184644..c356c266db6950 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -56,10 +56,9 @@ def async_generate_entity_id(entity_id_format: str, name: Optional[str], entity_id_format.format(slugify(name)), current_ids) -class Entity(object): +class Entity: """An abstract class for Home Assistant entities.""" - # pylint: disable=no-self-use # SAFE TO OVERWRITE # The properties and methods here are safe to overwrite when inheriting # this class. These may be used to customize the behavior of the entity. @@ -83,6 +82,9 @@ class Entity(object): # Name in the entity registry registry_name = None + # Hold list for functions to call on remove. + _on_remove = None + @property def should_poll(self) -> bool: """Return True if entity has to be polled for state. @@ -171,20 +173,13 @@ def supported_features(self) -> int: """Flag supported features.""" return None - def update(self): - """Retrieve latest state. - - For asyncio use coroutine async_update. - """ - pass - # DO NOT OVERWRITE # These properties and methods are either managed by Home Assistant or they # are used to perform a very specific function. Overwriting these may # produce undesirable effects in the entity's operation. @asyncio.coroutine - def async_update_ha_state(self, force_refresh=False): + def async_update_ha_state(self, force_refresh=False, context=None): """Update Home Assistant with current state of entity. If force_refresh == True will update entity before setting state. @@ -284,7 +279,7 @@ def async_update_ha_state(self, force_refresh=False): pass self.hass.states.async_set( - self.entity_id, state, attr, self.force_update) + self.entity_id, state, attr, self.force_update, context) def schedule_update_ha_state(self, force_refresh=False): """Schedule an update ha state change task. @@ -320,10 +315,10 @@ def async_device_update(self, warning=True): ) try: + # pylint: disable=no-member if hasattr(self, 'async_update'): - # pylint: disable=no-member yield from self.async_update() - else: + elif hasattr(self, 'update'): yield from self.hass.async_add_job(self.update) finally: self._update_staged = False @@ -332,8 +327,19 @@ def async_device_update(self, warning=True): if self.parallel_updates: self.parallel_updates.release() + @callback + def async_on_remove(self, func): + """Add a function to call when entity removed.""" + if self._on_remove is None: + self._on_remove = [] + self._on_remove.append(func) + async def async_remove(self): """Remove entity from Home Assistant.""" + if self._on_remove is not None: + while self._on_remove: + self._on_remove.pop()() + if self.platform is not None: await self.platform.async_remove_entity(self.entity_id) else: @@ -343,7 +349,17 @@ async def async_remove(self): def async_registry_updated(self, old, new): """Called when the entity registry has been updated.""" self.registry_name = new.name - self.async_schedule_update_ha_state() + + if new.entity_id == self.entity_id: + self.async_schedule_update_ha_state() + return + + async def readd(): + """Remove and add entity again.""" + await self.async_remove() + await self.platform.async_add_entities([self]) + + self.hass.async_create_task(readd()) def __eq__(self, other): """Return the comparison.""" @@ -372,7 +388,6 @@ def __repr__(self): class ToggleEntity(Entity): """An abstract class for entities that can be turned on and off.""" - # pylint: disable=no-self-use @property def state(self) -> str: """Return the state.""" diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index c82ae2a46f0831..72b6ceecbfd44f 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -17,7 +17,7 @@ DEFAULT_SCAN_INTERVAL = timedelta(seconds=15) -class EntityComponent(object): +class EntityComponent: """The EntityComponent manages platforms that manages entities. This class has the following responsibilities: @@ -108,7 +108,8 @@ async def async_setup_entry(self, config_entry): raise ValueError('Config entry has already been setup!') self._platforms[key] = self._async_init_entity_platform( - platform_type, platform + platform_type, platform, + scan_interval=getattr(platform, 'SCAN_INTERVAL', None), ) return await self._platforms[key].async_setup_entry(config_entry) diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 00a7e49840e159..dc1e376f471a86 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -15,7 +15,7 @@ PLATFORM_NOT_READY_RETRIES = 10 -class EntityPlatform(object): +class EntityPlatform: """Manage the entities for a single platform.""" def __init__(self, *, hass, logger, domain, platform_name, platform, @@ -216,6 +216,10 @@ async def async_add_entities(self, new_entities, update_before_add=False): component_entities, registry) for entity in new_entities] + # No entities for processing + if not tasks: + return + await asyncio.wait(tasks, loop=self.hass.loop) self.async_entities_added_callback() @@ -260,9 +264,15 @@ async def _async_add_entity(self, entity, update_before_add, suggested_object_id = '{} {}'.format( self.entity_namespace, suggested_object_id) + if self.config_entry is not None: + config_entry_id = self.config_entry.entry_id + else: + config_entry_id = None + entry = registry.async_get_or_create( self.domain, self.platform_name, entity.unique_id, - suggested_object_id=suggested_object_id) + suggested_object_id=suggested_object_id, + config_entry_id=config_entry_id) if entry.disabled: self.logger.info( @@ -273,7 +283,7 @@ async def _async_add_entity(self, entity, update_before_add, entity.entity_id = entry.entity_id entity.registry_name = entry.name - entry.add_update_listener(entity) + entity.async_on_remove(entry.add_update_listener(entity)) # We won't generate an entity ID if the platform has already set one # We will however make sure that platform cannot pick a registered ID diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index b5a9c309119ca2..2fa64ff8680f34 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -19,10 +19,10 @@ import attr -from ..core import callback, split_entity_id -from ..loader import bind_hass -from ..util import ensure_unique_string, slugify -from ..util.yaml import load_yaml, save_yaml +from homeassistant.core import callback, split_entity_id, valid_entity_id +from homeassistant.loader import bind_hass +from homeassistant.util import ensure_unique_string, slugify +from homeassistant.util.yaml import load_yaml, save_yaml PATH_REGISTRY = 'entity_registry.yaml' DATA_REGISTRY = 'entity_registry' @@ -37,12 +37,11 @@ class RegistryEntry: """Entity Registry Entry.""" - # pylint: disable=no-member - entity_id = attr.ib(type=str) unique_id = attr.ib(type=str) platform = attr.ib(type=str) name = attr.ib(type=str, default=None) + config_entry_id = attr.ib(type=str, default=None) disabled_by = attr.ib( type=str, default=None, validator=attr.validators.in_((DISABLED_HASS, DISABLED_USER, None))) @@ -64,8 +63,13 @@ def add_update_listener(self, listener): """Listen for when entry is updated. Listener: Callback function(old_entry, new_entry) + + Returns function to unlisten. """ - self.update_listeners.append(weakref.ref(listener)) + weak_listener = weakref.ref(listener) + self.update_listeners.append(weak_listener) + + return lambda: self.update_listeners.remove(weak_listener) class EntityRegistry: @@ -83,6 +87,15 @@ def async_is_registered(self, entity_id): """Check if an entity_id is currently registered.""" return entity_id in self.entities + @callback + def async_get_entity_id(self, domain: str, platform: str, unique_id: str): + """Check if an entity_id is currently registered.""" + for entity in self.entities.values(): + if entity.domain == domain and entity.platform == platform and \ + entity.unique_id == unique_id: + return entity.entity_id + return None + @callback def async_generate_entity_id(self, domain, suggested_object_id): """Generate an entity ID that does not conflict. @@ -97,17 +110,24 @@ def async_generate_entity_id(self, domain, suggested_object_id): @callback def async_get_or_create(self, domain, platform, unique_id, *, - suggested_object_id=None): + suggested_object_id=None, config_entry_id=None): """Get entity. Create if it doesn't exist.""" - for entity in self.entities.values(): - if entity.domain == domain and entity.platform == platform and \ - entity.unique_id == unique_id: - return entity + entity_id = self.async_get_entity_id(domain, platform, unique_id) + if entity_id: + entry = self.entities[entity_id] + if entry.config_entry_id == config_entry_id: + return entry + + self._async_update_entity( + entity_id, config_entry_id=config_entry_id) + return self.entities[entity_id] entity_id = self.async_generate_entity_id( domain, suggested_object_id or '{}_{}'.format(platform, unique_id)) + entity = RegistryEntry( entity_id=entity_id, + config_entry_id=config_entry_id, unique_id=unique_id, platform=platform, ) @@ -118,8 +138,19 @@ def async_get_or_create(self, domain, platform, unique_id, *, return entity @callback - def async_update_entity(self, entity_id, *, name=_UNDEF): + def async_update_entity(self, entity_id, *, name=_UNDEF, + new_entity_id=_UNDEF): """Update properties of an entity.""" + return self._async_update_entity( + entity_id, + name=name, + new_entity_id=new_entity_id + ) + + @callback + def _async_update_entity(self, entity_id, *, name=_UNDEF, + config_entry_id=_UNDEF, new_entity_id=_UNDEF): + """Private facing update properties method.""" old = self.entities[entity_id] changes = {} @@ -127,6 +158,24 @@ def async_update_entity(self, entity_id, *, name=_UNDEF): if name is not _UNDEF and name != old.name: changes['name'] = name + if (config_entry_id is not _UNDEF and + config_entry_id != old.config_entry_id): + changes['config_entry_id'] = config_entry_id + + if new_entity_id is not _UNDEF and new_entity_id != old.entity_id: + if self.async_is_registered(new_entity_id): + raise ValueError('Entity is already registered') + + if not valid_entity_id(new_entity_id): + raise ValueError('Invalid entity ID') + + if (split_entity_id(new_entity_id)[0] != + split_entity_id(entity_id)[0]): + raise ValueError('New entity ID should be same domain') + + self.entities.pop(entity_id) + entity_id = changes['entity_id'] = new_entity_id + if not changes: return old @@ -171,6 +220,7 @@ async def _async_load(self): for entity_id, info in data.items(): entities[entity_id] = RegistryEntry( entity_id=entity_id, + config_entry_id=info.get('config_entry_id'), unique_id=info['unique_id'], platform=info['platform'], name=info.get('name'), @@ -197,6 +247,7 @@ async def _async_save(self): for entry in self.entities.values(): data[entry.entity_id] = { + 'config_entry_id': entry.config_entry_id, 'unique_id': entry.unique_id, 'platform': entry.platform, 'name': entry.name, diff --git a/homeassistant/helpers/entity_values.py b/homeassistant/helpers/entity_values.py index 19980394d26d72..77739f8adabbf8 100644 --- a/homeassistant/helpers/entity_values.py +++ b/homeassistant/helpers/entity_values.py @@ -2,14 +2,16 @@ from collections import OrderedDict import fnmatch import re +from typing import Dict from homeassistant.core import split_entity_id -class EntityValues(object): +class EntityValues: """Class to store entity id based values.""" - def __init__(self, exact=None, domain=None, glob=None): + def __init__(self, exact: Dict = None, domain: Dict = None, + glob: Dict = None) -> None: """Initialize an EntityConfigDict.""" self._cache = {} self._exact = exact diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index d69a556b0cc37c..c8488fa3334d4d 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -133,7 +133,6 @@ def clear_listener(): """Clear all unsub listener.""" nonlocal async_remove_state_for_cancel, async_remove_state_for_listener - # pylint: disable=not-callable if async_remove_state_for_listener is not None: async_remove_state_for_listener() async_remove_state_for_listener = None @@ -375,7 +374,7 @@ def _process_state_match(parameter): if parameter is None or parameter == MATCH_ALL: return lambda _: True - elif isinstance(parameter, str) or not hasattr(parameter, '__iter__'): + if isinstance(parameter, str) or not hasattr(parameter, '__iter__'): return lambda state: state == parameter parameter = tuple(parameter) @@ -387,11 +386,11 @@ def _process_time_match(parameter): if parameter is None or parameter == MATCH_ALL: return lambda _: True - elif isinstance(parameter, str) and parameter.startswith('/'): + if isinstance(parameter, str) and parameter.startswith('/'): parameter = float(parameter[1:]) return lambda time: time % parameter == 0 - elif isinstance(parameter, str) or not hasattr(parameter, '__iter__'): + if isinstance(parameter, str) or not hasattr(parameter, '__iter__'): return lambda time: time == parameter parameter = tuple(parameter) diff --git a/homeassistant/helpers/intent.py b/homeassistant/helpers/intent.py index 5aa53f17e7bba0..8f26d4fe0eeeab 100644 --- a/homeassistant/helpers/intent.py +++ b/homeassistant/helpers/intent.py @@ -63,7 +63,8 @@ async def async_handle(hass, platform, intent_type, slots=None, intent_type, err) raise InvalidSlotInfo( 'Received invalid slot info for {}'.format(intent_type)) from err - except IntentHandleError: + # https://github.com/PyCQA/pylint/issues/2284 + except IntentHandleError: # pylint: disable=try-except-raise raise except Exception as err: raise IntentUnexpectedError( @@ -137,7 +138,8 @@ def async_validate_slots(self, slots): if self._slot_schema is None: self._slot_schema = vol.Schema({ key: SLOT_SCHEMA.extend({'value': validator}) - for key, validator in self.slot_schema.items()}) + for key, validator in self.slot_schema.items()}, + extra=vol.ALLOW_EXTRA) return self._slot_schema(slots) diff --git a/homeassistant/helpers/script.py b/homeassistant/helpers/script.py index f2ae36e7fd0839..a139be4b2607bf 100644 --- a/homeassistant/helpers/script.py +++ b/homeassistant/helpers/script.py @@ -119,7 +119,7 @@ def async_script_delay(now): self.hass.async_add_job(self._change_listener) return - elif CONF_WAIT_TEMPLATE in action: + if CONF_WAIT_TEMPLATE in action: # Call ourselves in the future to continue work wait_template = action[CONF_WAIT_TEMPLATE] wait_template.hass = self.hass @@ -147,7 +147,7 @@ def async_script_wait(entity_id, from_s, to_s): return - elif CONF_CONDITION in action: + if CONF_CONDITION in action: if not self._async_check_condition(action, variables): break diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 9114a4db941cfc..8aa3b553f3a4a8 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -1,7 +1,5 @@ """Service calling related helpers.""" import logging -# pylint: disable=unused-import -from typing import Optional # NOQA from os import path import voluptuous as vol @@ -105,12 +103,10 @@ def extract_entity_ids(hass, service_call, expand_group=True): return [ent_id for ent_id in group.expand_entity_ids(service_ent_id)] - else: - - if isinstance(service_ent_id, str): - return [service_ent_id] + if isinstance(service_ent_id, str): + return [service_ent_id] - return service_ent_id + return service_ent_id @bind_hass @@ -125,7 +121,7 @@ async def async_get_all_descriptions(hass): def domain_yaml_file(domain): """Return the services.yaml location for a domain.""" if domain == ha.DOMAIN: - import homeassistant.components as components + from homeassistant import components component_path = path.dirname(components.__file__) else: component_path = path.dirname(get_component(hass, domain).__file__) diff --git a/homeassistant/helpers/signal.py b/homeassistant/helpers/signal.py index 3ea52388d3388c..824b32177cdb54 100644 --- a/homeassistant/helpers/signal.py +++ b/homeassistant/helpers/signal.py @@ -3,7 +3,7 @@ import signal import sys -from homeassistant.core import callback +from homeassistant.core import callback, HomeAssistant from homeassistant.const import RESTART_EXIT_CODE from homeassistant.loader import bind_hass @@ -12,13 +12,13 @@ @callback @bind_hass -def async_register_signal_handling(hass): +def async_register_signal_handling(hass: HomeAssistant) -> None: """Register system signal handler for core.""" if sys.platform != 'win32': @callback def async_signal_handle(exit_code): """Wrap signal handling.""" - hass.async_add_job(hass.async_stop(exit_code)) + hass.async_create_task(hass.async_stop(exit_code)) try: hass.loop.add_signal_handler( diff --git a/homeassistant/helpers/state.py b/homeassistant/helpers/state.py index f97d70514591c7..4a3f915e810509 100644 --- a/homeassistant/helpers/state.py +++ b/homeassistant/helpers/state.py @@ -27,14 +27,15 @@ ATTR_FAN_MIN_ON_TIME, SERVICE_SET_FAN_MIN_ON_TIME, ATTR_RESUME_ALL, SERVICE_RESUME_PROGRAM) from homeassistant.components.cover import ( - ATTR_POSITION) + ATTR_POSITION, ATTR_TILT_POSITION) from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_OPTION, ATTR_TEMPERATURE, SERVICE_ALARM_ARM_AWAY, SERVICE_ALARM_ARM_HOME, SERVICE_ALARM_DISARM, SERVICE_ALARM_TRIGGER, SERVICE_LOCK, SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_STOP, SERVICE_MEDIA_SEEK, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_UNLOCK, SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, SERVICE_OPEN_COVER, - SERVICE_CLOSE_COVER, SERVICE_SET_COVER_POSITION, STATE_ALARM_ARMED_AWAY, + SERVICE_CLOSE_COVER, SERVICE_SET_COVER_POSITION, + SERVICE_SET_COVER_TILT_POSITION, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, STATE_CLOSED, STATE_HOME, STATE_LOCKED, STATE_NOT_HOME, STATE_OFF, STATE_ON, STATE_OPEN, STATE_PAUSED, STATE_PLAYING, STATE_UNKNOWN, @@ -68,7 +69,8 @@ SERVICE_SELECT_SOURCE: [ATTR_INPUT_SOURCE], SERVICE_SEND_IR_CODE: [ATTR_IR_CODE], SERVICE_SELECT_OPTION: [ATTR_OPTION], - SERVICE_SET_COVER_POSITION: [ATTR_POSITION] + SERVICE_SET_COVER_POSITION: [ATTR_POSITION], + SERVICE_SET_COVER_TILT_POSITION: [ATTR_TILT_POSITION] } # Update this dict when new services are added to HA. @@ -90,7 +92,7 @@ } -class AsyncTrackStates(object): +class AsyncTrackStates: """ Record the time when the with-block is entered. @@ -212,9 +214,9 @@ def state_as_number(state): if state.state in (STATE_ON, STATE_LOCKED, STATE_ABOVE_HORIZON, STATE_OPEN, STATE_HOME, STATE_HEAT, STATE_COOL): return 1 - elif state.state in (STATE_OFF, STATE_UNLOCKED, STATE_UNKNOWN, - STATE_BELOW_HORIZON, STATE_CLOSED, STATE_NOT_HOME, - STATE_IDLE): + if state.state in (STATE_OFF, STATE_UNLOCKED, STATE_UNKNOWN, + STATE_BELOW_HORIZON, STATE_CLOSED, STATE_NOT_HOME, + STATE_IDLE): return 0 return float(state.state) diff --git a/homeassistant/helpers/storage.py b/homeassistant/helpers/storage.py new file mode 100644 index 00000000000000..a68b489868d1ce --- /dev/null +++ b/homeassistant/helpers/storage.py @@ -0,0 +1,173 @@ +"""Helper to help store data.""" +import asyncio +import logging +import os +from typing import Dict, Optional + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import callback +from homeassistant.loader import bind_hass +from homeassistant.util import json +from homeassistant.helpers.event import async_call_later + +STORAGE_DIR = '.storage' +_LOGGER = logging.getLogger(__name__) + + +@bind_hass +async def async_migrator(hass, old_path, store, *, old_conf_migrate_func=None): + """Helper function to migrate old data to a store and then load data. + + async def old_conf_migrate_func(old_data) + """ + def load_old_config(): + """Helper to load old config.""" + if not os.path.isfile(old_path): + return None + + return json.load_json(old_path) + + config = await hass.async_add_executor_job(load_old_config) + + if config is None: + return await store.async_load() + + if old_conf_migrate_func is not None: + config = await old_conf_migrate_func(config) + + await store.async_save(config) + await hass.async_add_executor_job(os.remove, old_path) + return config + + +@bind_hass +class Store: + """Class to help storing data.""" + + def __init__(self, hass, version: int, key: str): + """Initialize storage class.""" + self.version = version + self.key = key + self.hass = hass + self._data = None + self._unsub_delay_listener = None + self._unsub_stop_listener = None + self._write_lock = asyncio.Lock() + self._load_task = None + + @property + def path(self): + """Return the config path.""" + return self.hass.config.path(STORAGE_DIR, self.key) + + async def async_load(self): + """Load data. + + If the expected version does not match the given version, the migrate + function will be invoked with await migrate_func(version, config). + + Will ensure that when a call comes in while another one is in progress, + the second call will wait and return the result of the first call. + """ + if self._load_task is None: + self._load_task = self.hass.async_add_job(self._async_load()) + + return await self._load_task + + async def _async_load(self): + """Helper to load the data.""" + if self._data is not None: + data = self._data + else: + data = await self.hass.async_add_executor_job( + json.load_json, self.path) + + if data == {}: + return None + if data['version'] == self.version: + stored = data['data'] + else: + _LOGGER.info('Migrating %s storage from %s to %s', + self.key, data['version'], self.version) + stored = await self._async_migrate_func( + data['version'], data['data']) + + self._load_task = None + return stored + + async def async_save(self, data: Dict, *, delay: Optional[int] = None): + """Save data with an optional delay.""" + self._data = { + 'version': self.version, + 'key': self.key, + 'data': data, + } + + self._async_cleanup_delay_listener() + + if delay is None: + self._async_cleanup_stop_listener() + await self._async_handle_write_data() + return + + self._unsub_delay_listener = async_call_later( + self.hass, delay, self._async_callback_delayed_write) + + self._async_ensure_stop_listener() + + @callback + def _async_ensure_stop_listener(self): + """Ensure that we write if we quit before delay has passed.""" + if self._unsub_stop_listener is None: + self._unsub_stop_listener = self.hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, self._async_callback_stop_write) + + @callback + def _async_cleanup_stop_listener(self): + """Clean up a stop listener.""" + if self._unsub_stop_listener is not None: + self._unsub_stop_listener() + self._unsub_stop_listener = None + + @callback + def _async_cleanup_delay_listener(self): + """Clean up a delay listener.""" + if self._unsub_delay_listener is not None: + self._unsub_delay_listener() + self._unsub_delay_listener = None + + async def _async_callback_delayed_write(self, _now): + """Handle a delayed write callback.""" + self._unsub_delay_listener = None + self._async_cleanup_stop_listener() + await self._async_handle_write_data() + + async def _async_callback_stop_write(self, _event): + """Handle a write because Home Assistant is stopping.""" + self._unsub_stop_listener = None + self._async_cleanup_delay_listener() + await self._async_handle_write_data() + + async def _async_handle_write_data(self, *_args): + """Handler to handle writing the config.""" + data = self._data + self._data = None + + async with self._write_lock: + try: + await self.hass.async_add_executor_job( + self._write_data, self.path, data) + except (json.SerializationError, json.WriteError) as err: + _LOGGER.error('Error writing config for %s: %s', self.key, err) + + def _write_data(self, path: str, data: Dict): + """Write the data.""" + if not os.path.isdir(os.path.dirname(path)): + os.makedirs(os.path.dirname(path)) + + _LOGGER.debug('Writing data for %s', self.key) + json.save_json(path, data) + + async def _async_migrate_func(self, old_version, old_data): + """Migrate to the new version.""" + raise NotImplementedError diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index f523726c388854..d0d3fb457b18e8 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -51,7 +51,7 @@ def render_complex(value, variables=None): if isinstance(value, list): return [render_complex(item, variables) for item in value] - elif isinstance(value, dict): + if isinstance(value, dict): return {key: render_complex(item, variables) for key, item in value.items()} return value.async_render(variables) @@ -82,7 +82,7 @@ def extract_entities(template, variables=None): return MATCH_ALL -class Template(object): +class Template: """Class to hold a template and manage caching and rendering.""" def __init__(self, template, hass=None): @@ -142,7 +142,6 @@ def render_with_possible_json_value(self, value, error_value=_SENTINEL): self.hass.loop, self.async_render_with_possible_json_value, value, error_value).result() - # pylint: disable=invalid-name def async_render_with_possible_json_value(self, value, error_value=_SENTINEL): """Render template with value exposed. @@ -198,7 +197,7 @@ def __eq__(self, other): self.hass == other.hass) -class AllStates(object): +class AllStates: """Class to expose all HA states as attributes.""" def __init__(self, hass): @@ -226,7 +225,7 @@ def __call__(self, entity_id): return STATE_UNKNOWN if state is None else state.state -class DomainStates(object): +class DomainStates: """Class to expose a specific HA domain as attributes.""" def __init__(self, hass, domain): @@ -286,7 +285,7 @@ def _wrap_state(state): return None if state is None else TemplateState(state) -class TemplateMethods(object): +class TemplateMethods: """Class to expose helpers to templates.""" def __init__(self, hass): @@ -318,7 +317,7 @@ def closest(self, *args): if point_state is None: _LOGGER.warning("Closest:Unable to find state %s", args[0]) return None - elif not loc_helper.has_location(point_state): + if not loc_helper.has_location(point_state): _LOGGER.warning( "Closest:State does not contain valid location: %s", point_state) @@ -420,7 +419,7 @@ def _resolve_state(self, entity_id_or_state): """Return state or entity_id if given.""" if isinstance(entity_id_or_state, State): return entity_id_or_state - elif isinstance(entity_id_or_state, str): + if isinstance(entity_id_or_state, str): return self._hass.states.get(entity_id_or_state) return None diff --git a/homeassistant/helpers/translation.py b/homeassistant/helpers/translation.py index f1335f733466e5..81ec046f2e9966 100644 --- a/homeassistant/helpers/translation.py +++ b/homeassistant/helpers/translation.py @@ -1,7 +1,5 @@ """Translation string lookup helpers.""" import logging -# pylint: disable=unused-import -from typing import Optional # NOQA from os import path from homeassistant import config_entries diff --git a/homeassistant/loader.py b/homeassistant/loader.py index 67647a323c9d64..3ac49e354b5d50 100644 --- a/homeassistant/loader.py +++ b/homeassistant/loader.py @@ -17,19 +17,21 @@ from types import ModuleType # pylint: disable=unused-import -from typing import Dict, List, Optional, Sequence, Set # NOQA +from typing import Optional, Set, TYPE_CHECKING, Callable, Any, TypeVar # NOQA from homeassistant.const import PLATFORM_FORMAT from homeassistant.util import OrderedSet -# Typing imports +# Typing imports that create a circular dependency # pylint: disable=using-constant-test,unused-import -if False: +if TYPE_CHECKING: from homeassistant.core import HomeAssistant # NOQA +CALLABLE_T = TypeVar('CALLABLE_T', bound=Callable) # noqa pylint: disable=invalid-name + PREPARED = False -DEPENDENCY_BLACKLIST = set(('config',)) +DEPENDENCY_BLACKLIST = {'config'} _LOGGER = logging.getLogger(__name__) @@ -39,7 +41,8 @@ PACKAGE_COMPONENTS = 'homeassistant.components' -def set_component(hass, comp_name: str, component: ModuleType) -> None: +def set_component(hass, # type: HomeAssistant + comp_name: str, component: Optional[ModuleType]) -> None: """Set a component in the cache. Async friendly. @@ -50,7 +53,8 @@ def set_component(hass, comp_name: str, component: ModuleType) -> None: cache[comp_name] = component -def get_platform(hass, domain: str, platform: str) -> Optional[ModuleType]: +def get_platform(hass, # type: HomeAssistant + domain: str, platform: str) -> Optional[ModuleType]: """Try to load specified platform. Async friendly. @@ -58,7 +62,8 @@ def get_platform(hass, domain: str, platform: str) -> Optional[ModuleType]: return get_component(hass, PLATFORM_FORMAT.format(domain, platform)) -def get_component(hass, comp_or_platform) -> Optional[ModuleType]: +def get_component(hass, # type: HomeAssistant + comp_or_platform: str) -> Optional[ModuleType]: """Try to load specified component. Looks in config dir first, then built-in components. @@ -66,12 +71,15 @@ def get_component(hass, comp_or_platform) -> Optional[ModuleType]: Async friendly. """ try: - return hass.data[DATA_KEY][comp_or_platform] + return hass.data[DATA_KEY][comp_or_platform] # type: ignore except KeyError: pass cache = hass.data.get(DATA_KEY) if cache is None: + if hass.config.config_dir is None: + _LOGGER.error("Can't load components - config dir is not set") + return None # Only insert if it's not there (happens during tests) if sys.path[0] != hass.config.config_dir: sys.path.insert(0, hass.config.config_dir) @@ -81,7 +89,7 @@ def get_component(hass, comp_or_platform) -> Optional[ModuleType]: potential_paths = ['custom_components.{}'.format(comp_or_platform), 'homeassistant.components.{}'.format(comp_or_platform)] - for path in potential_paths: + for index, path in enumerate(potential_paths): try: module = importlib.import_module(path) @@ -93,13 +101,22 @@ def get_component(hass, comp_or_platform) -> Optional[ModuleType]: # This prevents that when only # custom_components/switch/some_platform.py exists, # the import custom_components.switch would succeed. - if module.__spec__.origin == 'namespace': + # __file__ was unset for namespaces before Python 3.7 + if getattr(module, '__file__', None) is None: continue _LOGGER.info("Loaded %s from %s", comp_or_platform, path) cache[comp_or_platform] = module + if index == 0: + _LOGGER.warning( + 'You are using a custom component for %s which has not ' + 'been tested by Home Assistant. This component might ' + 'cause stability problems, be sure to disable it if you ' + 'do experience issues with Home Assistant.', + comp_or_platform) + return module except ImportError as err: @@ -124,14 +141,38 @@ def get_component(hass, comp_or_platform) -> Optional[ModuleType]: return None +class ModuleWrapper: + """Class to wrap a Python module and auto fill in hass argument.""" + + def __init__(self, + hass, # type: HomeAssistant + module: ModuleType) -> None: + """Initialize the module wrapper.""" + self._hass = hass + self._module = module + + def __getattr__(self, attr: str) -> Any: + """Fetch an attribute.""" + value = getattr(self._module, attr) + + if hasattr(value, '__bind_hass'): + value = ft.partial(value, self._hass) + + setattr(self, attr, value) + return value + + class Components: """Helper to load components.""" - def __init__(self, hass): + def __init__( + self, + hass # type: HomeAssistant + ) -> None: """Initialize the Components class.""" self._hass = hass - def __getattr__(self, comp_name): + def __getattr__(self, comp_name: str) -> ModuleWrapper: """Fetch a component.""" component = get_component(self._hass, comp_name) if component is None: @@ -144,11 +185,14 @@ def __getattr__(self, comp_name): class Helpers: """Helper to load helpers.""" - def __init__(self, hass): + def __init__( + self, + hass # type: HomeAssistant + ) -> None: """Initialize the Helpers class.""" self._hass = hass - def __getattr__(self, helper_name): + def __getattr__(self, helper_name: str) -> ModuleWrapper: """Fetch a helper.""" helper = importlib.import_module( 'homeassistant.helpers.{}'.format(helper_name)) @@ -157,33 +201,14 @@ def __getattr__(self, helper_name): return wrapped -class ModuleWrapper: - """Class to wrap a Python module and auto fill in hass argument.""" - - def __init__(self, hass, module): - """Initialize the module wrapper.""" - self._hass = hass - self._module = module - - def __getattr__(self, attr): - """Fetch an attribute.""" - value = getattr(self._module, attr) - - if hasattr(value, '__bind_hass'): - value = ft.partial(value, self._hass) - - setattr(self, attr, value) - return value - - -def bind_hass(func): +def bind_hass(func: CALLABLE_T) -> CALLABLE_T: """Decorate function to indicate that first argument is hass.""" - # pylint: disable=protected-access - func.__bind_hass = True + setattr(func, '__bind_hass', True) return func -def load_order_component(hass, comp_name: str) -> OrderedSet: +def load_order_component(hass, # type: HomeAssistant + comp_name: str) -> OrderedSet: """Return an OrderedSet of components in the correct order of loading. Raises HomeAssistantError if a circular dependency is detected. @@ -194,7 +219,8 @@ def load_order_component(hass, comp_name: str) -> OrderedSet: return _load_order_component(hass, comp_name, OrderedSet(), set()) -def _load_order_component(hass, comp_name: str, load_order: OrderedSet, +def _load_order_component(hass, # type: HomeAssistant + comp_name: str, load_order: OrderedSet, loading: Set) -> OrderedSet: """Recursive function to get load order of components. diff --git a/homeassistant/monkey_patch.py b/homeassistant/monkey_patch.py index d5c629c9d34472..edd25817f5af7f 100644 --- a/homeassistant/monkey_patch.py +++ b/homeassistant/monkey_patch.py @@ -20,9 +20,10 @@ - https://bugs.python.org/issue26617 """ import sys +from typing import Any -def patch_weakref_tasks(): +def patch_weakref_tasks() -> None: """Replace weakref.WeakSet to address Python 3 bug.""" # pylint: disable=no-self-use, protected-access, bare-except import asyncio.tasks @@ -30,18 +31,18 @@ def patch_weakref_tasks(): class IgnoreCalls: """Ignore add calls.""" - def add(self, other): + def add(self, other: Any) -> None: """No-op add.""" return - asyncio.tasks.Task._all_tasks = IgnoreCalls() + asyncio.tasks.Task._all_tasks = IgnoreCalls() # type: ignore try: del asyncio.tasks.Task.__del__ except: # noqa: E722 pass -def disable_c_asyncio(): +def disable_c_asyncio() -> None: """Disable using C implementation of asyncio. Required to be able to apply the weakref monkey patch. @@ -53,18 +54,16 @@ class AsyncioImportFinder: PATH_TRIGGER = '_asyncio' - def __init__(self, path_entry): + def __init__(self, path_entry: str) -> None: if path_entry != self.PATH_TRIGGER: raise ImportError() - return - def find_module(self, fullname, path=None): + def find_module(self, fullname: str, path: Any = None) -> None: """Find a module.""" if fullname == self.PATH_TRIGGER: # We lint in Py35, exception is introduced in Py36 # pylint: disable=undefined-variable - raise ModuleNotFoundError() # noqa - return None + raise ModuleNotFoundError() # type: ignore # noqa sys.path_hooks.append(AsyncioImportFinder) sys.path.insert(0, AsyncioImportFinder.PATH_TRIGGER) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index f6666c829e0d22..26628d7fe6255c 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -1,15 +1,16 @@ -requests==2.18.4 -pyyaml>=3.11,<4 -pytz>=2017.02 -pip>=8.0.3 -jinja2>=2.10 -voluptuous==0.11.1 -typing>=3,<4 -aiohttp==3.1.3 -async_timeout==2.0.1 +aiohttp==3.3.2 astral==1.6.1 -certifi>=2017.4.17 +async_timeout==3.0.0 attrs==18.1.0 +certifi>=2018.04.16 +jinja2>=2.10 +PyJWT==1.6.4 +cryptography==2.3.1 +pip>=8.0.3 +pytz>=2018.04 +pyyaml>=3.13,<4 +requests==2.19.1 +voluptuous==0.11.5 # Breaks Python 3.6 and is not needed for our supported Python versions enum34==1000000000.0.0 diff --git a/homeassistant/remote.py b/homeassistant/remote.py index 5a33bd58641a8b..c254dd500f77ea 100644 --- a/homeassistant/remote.py +++ b/homeassistant/remote.py @@ -13,7 +13,7 @@ import logging import urllib.parse -from typing import Optional +from typing import Optional, Dict, Any, List from aiohttp.hdrs import METH_GET, METH_POST, METH_DELETE, CONTENT_TYPE import requests @@ -31,7 +31,6 @@ class APIStatus(enum.Enum): """Representation of an API status.""" - # pylint: disable=no-init, invalid-name OK = "ok" INVALID_PASSWORD = "invalid_password" CANNOT_CONNECT = "cannot_connect" @@ -39,16 +38,17 @@ class APIStatus(enum.Enum): def __str__(self) -> str: """Return the state.""" - return self.value + return self.value # type: ignore -class API(object): +class API: """Object to pass around Home Assistant API location and credentials.""" def __init__(self, host: str, api_password: Optional[str] = None, port: Optional[int] = SERVER_PORT, use_ssl: bool = False) -> None: """Init the API.""" + _LOGGER.warning('This class is deprecated and will be removed in 0.77') self.host = host self.port = port self.api_password = api_password @@ -63,7 +63,7 @@ def __init__(self, host: str, api_password: Optional[str] = None, if port is not None: self.base_url += ':{}'.format(port) - self.status = None + self.status = None # type: Optional[APIStatus] self._headers = {CONTENT_TYPE: CONTENT_TYPE_JSON} if api_password is not None: @@ -76,20 +76,24 @@ def validate_api(self, force_validate: bool = False) -> bool: return self.status == APIStatus.OK - def __call__(self, method, path, data=None, timeout=5): + def __call__(self, method: str, path: str, data: Dict = None, + timeout: int = 5) -> requests.Response: """Make a call to the Home Assistant API.""" - if data is not None: - data = json.dumps(data, cls=JSONEncoder) + if data is None: + data_str = None + else: + data_str = json.dumps(data, cls=JSONEncoder) url = urllib.parse.urljoin(self.base_url, path) try: if method == METH_GET: return requests.get( - url, params=data, timeout=timeout, headers=self._headers) + url, params=data_str, timeout=timeout, + headers=self._headers) return requests.request( - method, url, data=data, timeout=timeout, + method, url, data=data_str, timeout=timeout, headers=self._headers) except requests.exceptions.ConnectionError: @@ -111,22 +115,22 @@ class JSONEncoder(json.JSONEncoder): """JSONEncoder that supports Home Assistant objects.""" # pylint: disable=method-hidden - def default(self, o): + def default(self, o: Any) -> Any: """Convert Home Assistant objects. Hand other objects to the original method. """ if isinstance(o, datetime): return o.isoformat() - elif isinstance(o, set): + if isinstance(o, set): return list(o) - elif hasattr(o, 'as_dict'): + if hasattr(o, 'as_dict'): return o.as_dict() return json.JSONEncoder.default(self, o) -def validate_api(api): +def validate_api(api: API) -> APIStatus: """Make a call to validate API.""" try: req = api(METH_GET, URL_API) @@ -134,7 +138,7 @@ def validate_api(api): if req.status_code == 200: return APIStatus.OK - elif req.status_code == 401: + if req.status_code == 401: return APIStatus.INVALID_PASSWORD return APIStatus.UNKNOWN @@ -143,12 +147,12 @@ def validate_api(api): return APIStatus.CANNOT_CONNECT -def get_event_listeners(api): +def get_event_listeners(api: API) -> Dict: """List of events that is being listened for.""" try: req = api(METH_GET, URL_API_EVENTS) - return req.json() if req.status_code == 200 else {} + return req.json() if req.status_code == 200 else {} # type: ignore except (HomeAssistantError, ValueError): # ValueError if req.json() can't parse the json @@ -157,7 +161,7 @@ def get_event_listeners(api): return {} -def fire_event(api, event_type, data=None): +def fire_event(api: API, event_type: str, data: Dict = None) -> None: """Fire an event at remote API.""" try: req = api(METH_POST, URL_API_EVENTS_EVENT.format(event_type), data) @@ -170,7 +174,7 @@ def fire_event(api, event_type, data=None): _LOGGER.exception("Error firing event") -def get_state(api, entity_id): +def get_state(api: API, entity_id: str) -> Optional[ha.State]: """Query given API for state of entity_id.""" try: req = api(METH_GET, URL_API_STATES_ENTITY.format(entity_id)) @@ -187,7 +191,7 @@ def get_state(api, entity_id): return None -def get_states(api): +def get_states(api: API) -> List[ha.State]: """Query given API for all states.""" try: req = api(METH_GET, @@ -203,7 +207,7 @@ def get_states(api): return [] -def remove_state(api, entity_id): +def remove_state(api: API, entity_id: str) -> bool: """Call API to remove state for entity_id. Return True if entity is gone (removed/never existed). @@ -223,7 +227,8 @@ def remove_state(api, entity_id): return False -def set_state(api, entity_id, new_state, attributes=None, force_update=False): +def set_state(api: API, entity_id: str, new_state: str, + attributes: Dict = None, force_update: bool = False) -> bool: """Tell API to update state for entity_id. Return True if success. @@ -250,14 +255,14 @@ def set_state(api, entity_id, new_state, attributes=None, force_update=False): return False -def is_state(api, entity_id, state): +def is_state(api: API, entity_id: str, state: str) -> bool: """Query API to see if entity_id is specified state.""" cur_state = get_state(api, entity_id) - return cur_state and cur_state.state == state + return bool(cur_state and cur_state.state == state) -def get_services(api): +def get_services(api: API) -> Dict: """Return a list of dicts. Each dict has a string "domain" and a list of strings "services". @@ -265,7 +270,7 @@ def get_services(api): try: req = api(METH_GET, URL_API_SERVICES) - return req.json() if req.status_code == 200 else {} + return req.json() if req.status_code == 200 else {} # type: ignore except (HomeAssistantError, ValueError): # ValueError if req.json() can't parse the json @@ -274,7 +279,9 @@ def get_services(api): return {} -def call_service(api, domain, service, service_data=None, timeout=5): +def call_service(api: API, domain: str, service: str, + service_data: Dict = None, + timeout: int = 5) -> None: """Call a service at the remote API.""" try: req = api(METH_POST, @@ -289,7 +296,7 @@ def call_service(api, domain, service, service_data=None, timeout=5): _LOGGER.exception("Error calling service") -def get_config(api): +def get_config(api: API) -> Dict: """Return configuration.""" try: req = api(METH_GET, URL_API_CONFIG) @@ -300,7 +307,7 @@ def get_config(api): result = req.json() if 'components' in result: result['components'] = set(result['components']) - return result + return result # type: ignore except (HomeAssistantError, ValueError): # ValueError if req.json() can't parse the JSON diff --git a/homeassistant/requirements.py b/homeassistant/requirements.py index 753947a2c12c69..b9b5e137d5c108 100644 --- a/homeassistant/requirements.py +++ b/homeassistant/requirements.py @@ -3,15 +3,18 @@ from functools import partial import logging import os +from typing import Any, Dict, List, Optional import homeassistant.util.package as pkg_util +from homeassistant.core import HomeAssistant DATA_PIP_LOCK = 'pip_lock' CONSTRAINT_FILE = 'package_constraints.txt' _LOGGER = logging.getLogger(__name__) -async def async_process_requirements(hass, name, requirements): +async def async_process_requirements(hass: HomeAssistant, name: str, + requirements: List[str]) -> bool: """Install the requirements for a component or platform. This method is a coroutine. @@ -25,7 +28,7 @@ async def async_process_requirements(hass, name, requirements): async with pip_lock: for req in requirements: - ret = await hass.async_add_job(pip_install, req) + ret = await hass.async_add_executor_job(pip_install, req) if not ret: _LOGGER.error("Not initializing %s because could not install " "requirement %s", name, req) @@ -34,11 +37,11 @@ async def async_process_requirements(hass, name, requirements): return True -def pip_kwargs(config_dir): +def pip_kwargs(config_dir: Optional[str]) -> Dict[str, Any]: """Return keyword arguments for PIP install.""" kwargs = { 'constraints': os.path.join(os.path.dirname(__file__), CONSTRAINT_FILE) } - if not pkg_util.is_virtual_env(): + if not (config_dir is None or pkg_util.is_virtual_env()): kwargs['target'] = os.path.join(config_dir, 'deps') return kwargs diff --git a/homeassistant/scripts/__init__.py b/homeassistant/scripts/__init__.py index 815a5c8e55f630..7aba3b2561cbaa 100644 --- a/homeassistant/scripts/__init__.py +++ b/homeassistant/scripts/__init__.py @@ -1,5 +1,6 @@ """Home Assistant command line scripts.""" import argparse +import asyncio import importlib import logging import os @@ -7,10 +8,10 @@ from typing import List -from homeassistant.bootstrap import mount_local_lib_path +from homeassistant.bootstrap import async_mount_local_lib_path from homeassistant.config import get_default_config_dir from homeassistant import requirements -from homeassistant.util.package import install_package +from homeassistant.util.package import install_package, is_virtual_env def run(args: List) -> int: @@ -38,7 +39,11 @@ def run(args: List) -> int: script = importlib.import_module('homeassistant.scripts.' + args[0]) config_dir = extract_config_dir() - mount_local_lib_path(config_dir) + + if not is_virtual_env(): + asyncio.get_event_loop().run_until_complete( + async_mount_local_lib_path(config_dir)) + pip_kwargs = requirements.pip_kwargs(config_dir) logging.basicConfig(stream=sys.stdout, level=logging.INFO) diff --git a/homeassistant/scripts/auth.py b/homeassistant/scripts/auth.py new file mode 100644 index 00000000000000..d141faa4c27637 --- /dev/null +++ b/homeassistant/scripts/auth.py @@ -0,0 +1,105 @@ +"""Script to manage users for the Home Assistant auth provider.""" +import argparse +import asyncio +import logging +import os + +from homeassistant.auth import auth_manager_from_config +from homeassistant.core import HomeAssistant +from homeassistant.config import get_default_config_dir +from homeassistant.auth.providers import homeassistant as hass_auth + + +def run(args): + """Handle Home Assistant auth provider script.""" + parser = argparse.ArgumentParser( + description=("Manage Home Assistant users")) + parser.add_argument( + '--script', choices=['auth']) + parser.add_argument( + '-c', '--config', + default=get_default_config_dir(), + help="Directory that contains the Home Assistant configuration") + + subparsers = parser.add_subparsers(dest='func') + subparsers.required = True + parser_list = subparsers.add_parser('list') + parser_list.set_defaults(func=list_users) + + parser_add = subparsers.add_parser('add') + parser_add.add_argument('username', type=str) + parser_add.add_argument('password', type=str) + parser_add.set_defaults(func=add_user) + + parser_validate_login = subparsers.add_parser('validate') + parser_validate_login.add_argument('username', type=str) + parser_validate_login.add_argument('password', type=str) + parser_validate_login.set_defaults(func=validate_login) + + parser_change_pw = subparsers.add_parser('change_password') + parser_change_pw.add_argument('username', type=str) + parser_change_pw.add_argument('new_password', type=str) + parser_change_pw.set_defaults(func=change_password) + + args = parser.parse_args(args) + loop = asyncio.get_event_loop() + hass = HomeAssistant(loop=loop) + loop.run_until_complete(run_command(hass, args)) + + # Triggers save on used storage helpers with delay (core auth) + logging.getLogger('homeassistant.core').setLevel(logging.WARNING) + loop.run_until_complete(hass.async_stop()) + + +async def run_command(hass, args): + """Run the command.""" + hass.config.config_dir = os.path.join(os.getcwd(), args.config) + hass.auth = await auth_manager_from_config(hass, [{ + 'type': 'homeassistant', + }]) + provider = hass.auth.auth_providers[0] + await provider.async_initialize() + await args.func(hass, provider, args) + + +async def list_users(hass, provider, args): + """List the users.""" + count = 0 + for user in provider.data.users: + count += 1 + print(user['username']) + + print() + print("Total users:", count) + + +async def add_user(hass, provider, args): + """Create a user.""" + try: + provider.data.add_auth(args.username, args.password) + except hass_auth.InvalidUser: + print("Username already exists!") + return + + # Save username/password + await provider.data.async_save() + print("Auth created") + + +async def validate_login(hass, provider, args): + """Validate a login.""" + try: + provider.data.validate_login(args.username, args.password) + print("Auth valid") + except hass_auth.InvalidAuth: + print("Auth invalid") + + +async def change_password(hass, provider, args): + """Change password.""" + try: + provider.data.change_password(args.username, args.new_password) + await provider.data.async_save() + print("Password changed") + except hass_auth.InvalidUser: + print("User not found") diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index 331b99926274bf..98de59f2da15cd 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -78,7 +78,6 @@ def listener(_): @benchmark -# pylint: disable=invalid-name async def async_million_time_changed_helper(hass): """Run a million events through time changed helper.""" count = 0 @@ -109,7 +108,6 @@ def listener(_): @benchmark -# pylint: disable=invalid-name async def async_million_state_changed_helper(hass): """Run a million events through state changed helper.""" count = 0 diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 3a1ffa82d47e88..d7be5b1a91c7d0 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -18,7 +18,7 @@ CONF_PACKAGES, merge_packages_config, _format_config_error, find_config_file, load_yaml_config_file, extract_domain_configs, config_per_platform) -import homeassistant.util.yaml as yaml +from homeassistant.util import yaml from homeassistant.exceptions import HomeAssistantError REQUIREMENTS = ('colorlog==3.1.4',) @@ -163,13 +163,13 @@ def check(config_dir, secrets=False): 'secret_cache': None, } - # pylint: disable=unused-variable + # pylint: disable=possibly-unused-variable def mock_load(filename): """Mock hass.util.load_yaml to save config file names.""" res['yaml_files'][filename] = True return MOCKS['load'][1](filename) - # pylint: disable=unused-variable + # pylint: disable=possibly-unused-variable def mock_secrets(ldr, node): """Mock _get_secrets.""" try: @@ -267,7 +267,7 @@ def sort_dict_key(val): print(' ', indent_str, i) -CheckConfigError = namedtuple( # pylint: disable=invalid-name +CheckConfigError = namedtuple( 'CheckConfigError', "message domain config") @@ -378,7 +378,6 @@ def _comp_error(ex, domain, config): # Validate platform specific schema if hasattr(platform, 'PLATFORM_SCHEMA'): - # pylint: disable=no-member try: p_validated = platform.PLATFORM_SCHEMA(p_validated) except vol.Invalid as ex: diff --git a/homeassistant/scripts/influxdb_import.py b/homeassistant/scripts/influxdb_import.py index 421e84d503a7ca..031df1d3a72a38 100644 --- a/homeassistant/scripts/influxdb_import.py +++ b/homeassistant/scripts/influxdb_import.py @@ -137,6 +137,7 @@ def run(script_args: List) -> int: override_measurement = args.override_measurement default_measurement = args.default_measurement + # pylint: disable=assignment-from-no-return query = session.query(func.count(models.Events.event_type)).filter( models.Events.event_type == 'state_changed') diff --git a/homeassistant/scripts/keyring.py b/homeassistant/scripts/keyring.py index 82a57c90263aaa..54e7eb01ae1a9b 100644 --- a/homeassistant/scripts/keyring.py +++ b/homeassistant/scripts/keyring.py @@ -5,7 +5,7 @@ from homeassistant.util.yaml import _SECRET_NAMESPACE -REQUIREMENTS = ['keyring==12.0.0', 'keyrings.alt==3.0'] +REQUIREMENTS = ['keyring==13.2.1', 'keyrings.alt==3.1'] def run(args): diff --git a/homeassistant/scripts/macos/__init__.py b/homeassistant/scripts/macos/__init__.py index 275a33627a9753..6c6557897ee4f7 100644 --- a/homeassistant/scripts/macos/__init__.py +++ b/homeassistant/scripts/macos/__init__.py @@ -52,10 +52,10 @@ def run(args): if args[0] == 'install': install_osx() return 0 - elif args[0] == 'uninstall': + if args[0] == 'uninstall': uninstall_osx() return 0 - elif args[0] == 'restart': + if args[0] == 'restart': uninstall_osx() # A small delay is needed on some systems to let the unload finish. time.sleep(0.5) diff --git a/homeassistant/setup.py b/homeassistant/setup.py index f26aa9b61f13a9..31404b978eb406 100644 --- a/homeassistant/setup.py +++ b/homeassistant/setup.py @@ -4,7 +4,7 @@ from timeit import default_timer as timer from types import ModuleType -from typing import Optional, Dict +from typing import Optional, Dict, List from homeassistant import requirements, core, loader, config as conf_util from homeassistant.config import async_notify_setup_error @@ -26,7 +26,7 @@ def setup_component(hass: core.HomeAssistant, domain: str, config: Optional[Dict] = None) -> bool: """Set up a component and all its dependencies.""" - return run_coroutine_threadsafe( + return run_coroutine_threadsafe( # type: ignore async_setup_component(hass, domain, config), loop=hass.loop).result() @@ -42,7 +42,7 @@ async def async_setup_component(hass: core.HomeAssistant, domain: str, setup_tasks = hass.data.get(DATA_SETUP) if setup_tasks is not None and domain in setup_tasks: - return await setup_tasks[domain] + return await setup_tasks[domain] # type: ignore if config is None: config = {} @@ -50,13 +50,15 @@ async def async_setup_component(hass: core.HomeAssistant, domain: str, if setup_tasks is None: setup_tasks = hass.data[DATA_SETUP] = {} - task = setup_tasks[domain] = hass.async_add_job( + task = setup_tasks[domain] = hass.async_create_task( _async_setup_component(hass, domain, config)) - return await task + return await task # type: ignore -async def _async_process_dependencies(hass, config, name, dependencies): +async def _async_process_dependencies( + hass: core.HomeAssistant, config: Dict, name: str, + dependencies: List[str]) -> bool: """Ensure all dependencies are set up.""" blacklisted = [dep for dep in dependencies if dep in loader.DEPENDENCY_BLACKLIST] @@ -88,12 +90,12 @@ async def _async_process_dependencies(hass, config, name, dependencies): async def _async_setup_component(hass: core.HomeAssistant, - domain: str, config) -> bool: + domain: str, config: Dict) -> bool: """Set up a component for Home Assistant. This method is a coroutine. """ - def log_error(msg, link=True): + def log_error(msg: str, link: bool = True) -> None: """Log helper.""" _LOGGER.error("Setup failed for %s: %s", domain, msg) async_notify_setup_error(hass, domain, link) @@ -139,10 +141,11 @@ def log_error(msg, link=True): try: if hasattr(component, 'async_setup'): - result = await component.async_setup(hass, processed_config) + result = await component.async_setup( # type: ignore + hass, processed_config) else: - result = await hass.async_add_job( - component.setup, hass, processed_config) + result = await hass.async_add_executor_job( + component.setup, hass, processed_config) # type: ignore except Exception: # pylint: disable=broad-except _LOGGER.exception("Error during setup of component %s", domain) async_notify_setup_error(hass, domain, True) @@ -156,29 +159,31 @@ def log_error(msg, link=True): if result is False: log_error("Component failed to initialize.") return False - elif result is not True: + if result is not True: log_error("Component did not return boolean if setup was successful. " "Disabling component.") loader.set_component(hass, domain, None) return False - for entry in hass.config_entries.async_entries(domain): - await entry.async_setup(hass, component=component) + if hass.config_entries: + for entry in hass.config_entries.async_entries(domain): + await entry.async_setup(hass, component=component) - hass.config.components.add(component.DOMAIN) + hass.config.components.add(component.DOMAIN) # type: ignore # Cleanup if domain in hass.data[DATA_SETUP]: hass.data[DATA_SETUP].pop(domain) hass.bus.async_fire( - EVENT_COMPONENT_LOADED, {ATTR_COMPONENT: component.DOMAIN} + EVENT_COMPONENT_LOADED, + {ATTR_COMPONENT: component.DOMAIN} # type: ignore ) return True -async def async_prepare_setup_platform(hass: core.HomeAssistant, config, +async def async_prepare_setup_platform(hass: core.HomeAssistant, config: Dict, domain: str, platform_name: str) \ -> Optional[ModuleType]: """Load a platform and makes sure dependencies are setup. @@ -187,7 +192,7 @@ async def async_prepare_setup_platform(hass: core.HomeAssistant, config, """ platform_path = PLATFORM_FORMAT.format(domain, platform_name) - def log_error(msg): + def log_error(msg: str) -> None: """Log helper.""" _LOGGER.error("Unable to prepare setup for platform %s: %s", platform_path, msg) @@ -201,7 +206,7 @@ def log_error(msg): return None # Already loaded - elif platform_path in hass.config.components: + if platform_path in hass.config.components: return platform try: @@ -214,7 +219,9 @@ def log_error(msg): return platform -async def async_process_deps_reqs(hass, config, name, module): +async def async_process_deps_reqs( + hass: core.HomeAssistant, config: Dict, name: str, + module: ModuleType) -> None: """Process all dependencies and requirements for a module. Module is a Python module of either a component or platform. @@ -228,14 +235,14 @@ async def async_process_deps_reqs(hass, config, name, module): if hasattr(module, 'DEPENDENCIES'): dep_success = await _async_process_dependencies( - hass, config, name, module.DEPENDENCIES) + hass, config, name, module.DEPENDENCIES) # type: ignore if not dep_success: raise HomeAssistantError("Could not setup all dependencies.") if not hass.config.skip_pip and hasattr(module, 'REQUIREMENTS'): req_success = await requirements.async_process_requirements( - hass, name, module.REQUIREMENTS) + hass, name, module.REQUIREMENTS) # type: ignore if not req_success: raise HomeAssistantError("Could not install all requirements.") diff --git a/homeassistant/util/__init__.py b/homeassistant/util/__init__.py index a8a84c6c880730..64c9f4f02c994c 100644 --- a/homeassistant/util/__init__.py +++ b/homeassistant/util/__init__.py @@ -1,9 +1,8 @@ """Helper methods for various modules.""" import asyncio -from collections.abc import MutableSet +from datetime import datetime, timedelta from itertools import chain import threading -from datetime import datetime import re import enum import socket @@ -13,12 +12,16 @@ from types import MappingProxyType from unicodedata import normalize -from typing import Any, Optional, TypeVar, Callable, KeysView, Union, Iterable +from typing import (Any, Optional, TypeVar, Callable, KeysView, Union, # noqa + Iterable, List, Dict, Iterator, Coroutine, MutableSet) from .dt import as_local, utcnow +# pylint: disable=invalid-name T = TypeVar('T') U = TypeVar('U') +ENUM_T = TypeVar('ENUM_T', bound=enum.Enum) +# pylint: enable=invalid-name RE_SANITIZE_FILENAME = re.compile(r'(~|\.\.|/|\\)') RE_SANITIZE_PATH = re.compile(r'(~|\.(\.)+)') @@ -55,7 +58,7 @@ def repr_helper(inp: Any) -> str: return ", ".join( repr_helper(key)+"="+repr_helper(item) for key, item in inp.items()) - elif isinstance(inp, datetime): + if isinstance(inp, datetime): return as_local(inp).isoformat() return str(inp) @@ -90,7 +93,7 @@ def ensure_unique_string(preferred_string: str, current_strings: # Taken from: http://stackoverflow.com/a/11735897 -def get_local_ip(): +def get_local_ip() -> str: """Try to determine the local IP address of the machine.""" try: sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) @@ -98,7 +101,7 @@ def get_local_ip(): # Use Google Public DNS server to determine own IP sock.connect(('8.8.8.8', 80)) - return sock.getsockname()[0] + return sock.getsockname()[0] # type: ignore except socket.error: try: return socket.gethostbyname(socket.gethostname()) @@ -109,7 +112,7 @@ def get_local_ip(): # Taken from http://stackoverflow.com/a/23728630 -def get_random_string(length=10): +def get_random_string(length: int = 10) -> str: """Return a random string with letters and digits.""" generator = random.SystemRandom() source_chars = string.ascii_letters + string.digits @@ -120,60 +123,62 @@ def get_random_string(length=10): class OrderedEnum(enum.Enum): """Taken from Python 3.4.0 docs.""" - # pylint: disable=no-init - def __ge__(self, other): + # https://github.com/PyCQA/pylint/issues/2306 + # pylint: disable=comparison-with-callable + + def __ge__(self, other: ENUM_T) -> bool: """Return the greater than element.""" if self.__class__ is other.__class__: - return self.value >= other.value + return bool(self.value >= other.value) return NotImplemented - def __gt__(self, other): + def __gt__(self, other: ENUM_T) -> bool: """Return the greater element.""" if self.__class__ is other.__class__: - return self.value > other.value + return bool(self.value > other.value) return NotImplemented - def __le__(self, other): + def __le__(self, other: ENUM_T) -> bool: """Return the lower than element.""" if self.__class__ is other.__class__: - return self.value <= other.value + return bool(self.value <= other.value) return NotImplemented - def __lt__(self, other): + def __lt__(self, other: ENUM_T) -> bool: """Return the lower element.""" if self.__class__ is other.__class__: - return self.value < other.value + return bool(self.value < other.value) return NotImplemented -class OrderedSet(MutableSet): +class OrderedSet(MutableSet[T]): """Ordered set taken from http://code.activestate.com/recipes/576694/.""" - def __init__(self, iterable=None): + def __init__(self, iterable: Iterable[T] = None) -> None: """Initialize the set.""" - self.end = end = [] - end += [None, end, end] # sentinel node for doubly linked list - self.map = {} # key --> [key, prev, next] + self.end = end = [] # type: List[Any] + end += [None, end, end] # sentinel node for doubly linked list + self.map = {} # type: Dict[T, List] # key --> [key, prev, next] if iterable is not None: - self |= iterable + self |= iterable # type: ignore - def __len__(self): + def __len__(self) -> int: """Return the length of the set.""" return len(self.map) - def __contains__(self, key): + def __contains__(self, key: T) -> bool: # type: ignore """Check if key is in set.""" return key in self.map # pylint: disable=arguments-differ - def add(self, key): + def add(self, key: T) -> None: """Add an element to the end of the set.""" if key not in self.map: end = self.end curr = end[1] curr[2] = end[1] = self.map[key] = [key, curr, end] - def promote(self, key): + def promote(self, key: T) -> None: """Promote element to beginning of the set, add if not there.""" if key in self.map: self.discard(key) @@ -183,14 +188,14 @@ def promote(self, key): curr[2] = begin[1] = self.map[key] = [key, curr, begin] # pylint: disable=arguments-differ - def discard(self, key): + def discard(self, key: T) -> None: """Discard an element from the set.""" if key in self.map: key, prev_item, next_item = self.map.pop(key) prev_item[2] = next_item next_item[1] = prev_item - def __iter__(self): + def __iter__(self) -> Iterator[T]: """Iterate of the set.""" end = self.end curr = end[2] @@ -198,7 +203,7 @@ def __iter__(self): yield curr[0] curr = curr[2] - def __reversed__(self): + def __reversed__(self) -> Iterator[T]: """Reverse the ordering.""" end = self.end curr = end[1] @@ -207,7 +212,7 @@ def __reversed__(self): curr = curr[1] # pylint: disable=arguments-differ - def pop(self, last=True): + def pop(self, last: bool = True) -> T: """Pop element of the end of the set. Set last=False to pop from the beginning. @@ -216,27 +221,27 @@ def pop(self, last=True): raise KeyError('set is empty') key = self.end[1][0] if last else self.end[2][0] self.discard(key) - return key + return key # type: ignore - def update(self, *args): + def update(self, *args: Any) -> None: """Add elements from args to the set.""" for item in chain(*args): self.add(item) - def __repr__(self): + def __repr__(self) -> str: """Return the representation.""" if not self: return '%s()' % (self.__class__.__name__,) return '%s(%r)' % (self.__class__.__name__, list(self)) - def __eq__(self, other): + def __eq__(self, other: Any) -> bool: """Return the comparison.""" if isinstance(other, OrderedSet): return len(self) == len(other) and list(self) == list(other) return set(self) == set(other) -class Throttle(object): +class Throttle: """A class for throttling the execution of tasks. This method decorator adds a cooldown to a method to prevent it from being @@ -254,20 +259,21 @@ class Throttle(object): Adds a datetime attribute `last_call` to the method. """ - def __init__(self, min_time, limit_no_throttle=None): + def __init__(self, min_time: timedelta, + limit_no_throttle: timedelta = None) -> None: """Initialize the throttle.""" self.min_time = min_time self.limit_no_throttle = limit_no_throttle - def __call__(self, method): + def __call__(self, method: Callable) -> Callable: """Caller for the throttle.""" # Make sure we return a coroutine if the method is async. if asyncio.iscoroutinefunction(method): - async def throttled_value(): + async def throttled_value() -> None: """Stand-in function for when real func is being throttled.""" return None else: - def throttled_value(): + def throttled_value() -> None: # type: ignore """Stand-in function for when real func is being throttled.""" return None @@ -288,14 +294,14 @@ def throttled_value(): '.' not in method.__qualname__.split('..')[-1]) @wraps(method) - def wrapper(*args, **kwargs): + def wrapper(*args: Any, **kwargs: Any) -> Union[Callable, Coroutine]: """Wrap that allows wrapped to be called only once per min_time. If we cannot acquire the lock, it is running so return None. """ # pylint: disable=protected-access if hasattr(method, '__self__'): - host = method.__self__ + host = getattr(method, '__self__') elif is_func: host = wrapper else: @@ -318,7 +324,7 @@ def wrapper(*args, **kwargs): if force or utcnow() - throttle[1] > self.min_time: result = method(*args, **kwargs) throttle[1] = utcnow() - return result + return result # type: ignore return throttled_value() finally: diff --git a/homeassistant/util/async_.py b/homeassistant/util/async_.py index 5676a1d08440a9..aa030bf13c7286 100644 --- a/homeassistant/util/async_.py +++ b/homeassistant/util/async_.py @@ -3,22 +3,25 @@ import threading import logging from asyncio import coroutines +from asyncio.events import AbstractEventLoop from asyncio.futures import Future from asyncio import ensure_future - +from typing import Any, Union, Coroutine, Callable, Generator _LOGGER = logging.getLogger(__name__) -def _set_result_unless_cancelled(fut, result): +def _set_result_unless_cancelled(fut: Future, result: Any) -> None: """Set the result only if the Future was not cancelled.""" if fut.cancelled(): return fut.set_result(result) -def _set_concurrent_future_state(concurr, source): +def _set_concurrent_future_state( + concurr: concurrent.futures.Future, + source: Union[concurrent.futures.Future, Future]) -> None: """Copy state from a future to a concurrent.futures.Future.""" assert source.done() if source.cancelled(): @@ -33,7 +36,8 @@ def _set_concurrent_future_state(concurr, source): concurr.set_result(result) -def _copy_future_state(source, dest): +def _copy_future_state(source: Union[concurrent.futures.Future, Future], + dest: Union[concurrent.futures.Future, Future]) -> None: """Copy state from another Future. The other Future may be a concurrent.futures.Future. @@ -53,7 +57,9 @@ def _copy_future_state(source, dest): dest.set_result(result) -def _chain_future(source, destination): +def _chain_future( + source: Union[concurrent.futures.Future, Future], + destination: Union[concurrent.futures.Future, Future]) -> None: """Chain two futures so that when one completes, so does the other. The result (or exception) of source will be copied to destination. @@ -65,23 +71,32 @@ def _chain_future(source, destination): if not isinstance(destination, (Future, concurrent.futures.Future)): raise TypeError('A future is required for destination argument') # pylint: disable=protected-access - source_loop = source._loop if isinstance(source, Future) else None - dest_loop = destination._loop if isinstance(destination, Future) else None + if isinstance(source, Future): + source_loop = source._loop # type: ignore + else: + source_loop = None + if isinstance(destination, Future): + dest_loop = destination._loop # type: ignore + else: + dest_loop = None - def _set_state(future, other): + def _set_state(future: Union[concurrent.futures.Future, Future], + other: Union[concurrent.futures.Future, Future]) -> None: if isinstance(future, Future): _copy_future_state(other, future) else: _set_concurrent_future_state(future, other) - def _call_check_cancel(destination): + def _call_check_cancel( + destination: Union[concurrent.futures.Future, Future]) -> None: if destination.cancelled(): if source_loop is None or source_loop is dest_loop: source.cancel() else: source_loop.call_soon_threadsafe(source.cancel) - def _call_set_state(source): + def _call_set_state( + source: Union[concurrent.futures.Future, Future]) -> None: if dest_loop is None or dest_loop is source_loop: _set_state(destination, source) else: @@ -91,7 +106,9 @@ def _call_set_state(source): source.add_done_callback(_call_set_state) -def run_coroutine_threadsafe(coro, loop): +def run_coroutine_threadsafe( + coro: Union[Coroutine, Generator], + loop: AbstractEventLoop) -> concurrent.futures.Future: """Submit a coroutine object to a given event loop. Return a concurrent.futures.Future to access the result. @@ -102,12 +119,11 @@ def run_coroutine_threadsafe(coro, loop): if not coroutines.iscoroutine(coro): raise TypeError('A coroutine object is required') - future = concurrent.futures.Future() + future = concurrent.futures.Future() # type: concurrent.futures.Future - def callback(): + def callback() -> None: """Handle the call to the coroutine.""" try: - # pylint: disable=deprecated-method _chain_future(ensure_future(coro, loop=loop), future) # pylint: disable=broad-except except Exception as exc: @@ -120,7 +136,8 @@ def callback(): return future -def fire_coroutine_threadsafe(coro, loop): +def fire_coroutine_threadsafe(coro: Coroutine, + loop: AbstractEventLoop) -> None: """Submit a coroutine object to a given event loop. This method does not provide a way to retrieve the result and @@ -134,16 +151,15 @@ def fire_coroutine_threadsafe(coro, loop): if not coroutines.iscoroutine(coro): raise TypeError('A coroutine object is required: %s' % coro) - def callback(): + def callback() -> None: """Handle the firing of a coroutine.""" - # pylint: disable=deprecated-method ensure_future(coro, loop=loop) loop.call_soon_threadsafe(callback) - return -def run_callback_threadsafe(loop, callback, *args): +def run_callback_threadsafe(loop: AbstractEventLoop, callback: Callable, + *args: Any) -> concurrent.futures.Future: """Submit a callback object to a given event loop. Return a concurrent.futures.Future to access the result. @@ -152,9 +168,9 @@ def run_callback_threadsafe(loop, callback, *args): if ident is not None and ident == threading.get_ident(): raise RuntimeError('Cannot be called from within the event loop') - future = concurrent.futures.Future() + future = concurrent.futures.Future() # type: concurrent.futures.Future - def run_callback(): + def run_callback() -> None: """Run callback and store result.""" try: future.set_result(callback(*args)) diff --git a/homeassistant/util/color.py b/homeassistant/util/color.py index 32e9df70a03e1c..0538bfbf369ffd 100644 --- a/homeassistant/util/color.py +++ b/homeassistant/util/color.py @@ -2,7 +2,7 @@ import math import colorsys -from typing import Tuple +from typing import Tuple, List # Official CSS3 colors from w3.org: # https://www.w3.org/TR/2010/PR-css3-color-20101028/#html4 @@ -162,7 +162,7 @@ } -def color_name_to_rgb(color_name): +def color_name_to_rgb(color_name: str) -> Tuple[int, int, int]: """Convert color name to RGB hex value.""" # COLORS map has no spaces in it, so make the color_name have no # spaces in it as well for matching purposes @@ -173,7 +173,7 @@ def color_name_to_rgb(color_name): return hex_value -# pylint: disable=invalid-name, invalid-sequence-index +# pylint: disable=invalid-name def color_RGB_to_xy(iR: int, iG: int, iB: int) -> Tuple[float, float]: """Convert from RGB color to XY color.""" return color_RGB_to_xy_brightness(iR, iG, iB)[:2] @@ -182,7 +182,7 @@ def color_RGB_to_xy(iR: int, iG: int, iB: int) -> Tuple[float, float]: # Taken from: # http://www.developers.meethue.com/documentation/color-conversions-rgb-xy # License: Code is given as is. Use at your own risk and discretion. -# pylint: disable=invalid-name, invalid-sequence-index +# pylint: disable=invalid-name def color_RGB_to_xy_brightness( iR: int, iG: int, iB: int) -> Tuple[float, float, int]: """Convert from RGB color to XY color.""" @@ -224,7 +224,6 @@ def color_xy_to_RGB(vX: float, vY: float) -> Tuple[int, int, int]: # Converted to Python from Obj-C, original source from: # http://www.developers.meethue.com/documentation/color-conversions-rgb-xy -# pylint: disable=invalid-sequence-index def color_xy_brightness_to_RGB(vX: float, vY: float, ibrightness: int) -> Tuple[int, int, int]: """Convert from XYZ to RGB.""" @@ -265,12 +264,11 @@ def color_xy_brightness_to_RGB(vX: float, vY: float, return (ir, ig, ib) -# pylint: disable=invalid-sequence-index def color_hsb_to_RGB(fH: float, fS: float, fB: float) -> Tuple[int, int, int]: """Convert a hsb into its rgb representation.""" if fS == 0: - fV = fB * 255 - return (fV, fV, fV) + fV = int(fB * 255) + return fV, fV, fV r = g = b = 0 h = fH / 60 @@ -307,8 +305,8 @@ def color_hsb_to_RGB(fH: float, fS: float, fB: float) -> Tuple[int, int, int]: return (r, g, b) -# pylint: disable=invalid-sequence-index -def color_RGB_to_hsv(iR: int, iG: int, iB: int) -> Tuple[float, float, float]: +def color_RGB_to_hsv( + iR: float, iG: float, iB: float) -> Tuple[float, float, float]: """Convert an rgb color to its hsv representation. Hue is scaled 0-360 @@ -319,13 +317,11 @@ def color_RGB_to_hsv(iR: int, iG: int, iB: int) -> Tuple[float, float, float]: return round(fHSV[0]*360, 3), round(fHSV[1]*100, 3), round(fHSV[2]*100, 3) -# pylint: disable=invalid-sequence-index -def color_RGB_to_hs(iR: int, iG: int, iB: int) -> Tuple[float, float]: +def color_RGB_to_hs(iR: float, iG: float, iB: float) -> Tuple[float, float]: """Convert an rgb color to its hs representation.""" return color_RGB_to_hsv(iR, iG, iB)[:2] -# pylint: disable=invalid-sequence-index def color_hsv_to_RGB(iH: float, iS: float, iV: float) -> Tuple[int, int, int]: """Convert an hsv color into its rgb representation. @@ -337,28 +333,23 @@ def color_hsv_to_RGB(iH: float, iS: float, iV: float) -> Tuple[int, int, int]: return (int(fRGB[0]*255), int(fRGB[1]*255), int(fRGB[2]*255)) -# pylint: disable=invalid-sequence-index def color_hs_to_RGB(iH: float, iS: float) -> Tuple[int, int, int]: """Convert an hsv color into its rgb representation.""" return color_hsv_to_RGB(iH, iS, 100) -# pylint: disable=invalid-sequence-index def color_xy_to_hs(vX: float, vY: float) -> Tuple[float, float]: """Convert an xy color to its hs representation.""" h, s, _ = color_RGB_to_hsv(*color_xy_to_RGB(vX, vY)) - return (h, s) + return h, s -# pylint: disable=invalid-sequence-index def color_hs_to_xy(iH: float, iS: float) -> Tuple[float, float]: """Convert an hs color to its xy representation.""" return color_RGB_to_xy(*color_hs_to_RGB(iH, iS)) -# pylint: disable=invalid-sequence-index -def _match_max_scale(input_colors: Tuple[int, ...], - output_colors: Tuple[int, ...]) -> Tuple[int, ...]: +def _match_max_scale(input_colors: Tuple, output_colors: Tuple) -> Tuple: """Match the maximum value of the output to the input.""" max_in = max(input_colors) max_out = max(output_colors) @@ -369,7 +360,7 @@ def _match_max_scale(input_colors: Tuple[int, ...], return tuple(int(round(i * factor)) for i in output_colors) -def color_rgb_to_rgbw(r, g, b): +def color_rgb_to_rgbw(r: int, g: int, b: int) -> Tuple[int, int, int, int]: """Convert an rgb color to an rgbw representation.""" # Calculate the white channel as the minimum of input rgb channels. # Subtract the white portion from the remaining rgb channels. @@ -378,25 +369,25 @@ def color_rgb_to_rgbw(r, g, b): # Match the output maximum value to the input. This ensures the full # channel range is used. - return _match_max_scale((r, g, b), rgbw) + return _match_max_scale((r, g, b), rgbw) # type: ignore -def color_rgbw_to_rgb(r, g, b, w): +def color_rgbw_to_rgb(r: int, g: int, b: int, w: int) -> Tuple[int, int, int]: """Convert an rgbw color to an rgb representation.""" # Add the white channel back into the rgb channels. rgb = (r + w, g + w, b + w) # Match the output maximum value to the input. This ensures the # output doesn't overflow. - return _match_max_scale((r, g, b, w), rgb) + return _match_max_scale((r, g, b, w), rgb) # type: ignore -def color_rgb_to_hex(r, g, b): +def color_rgb_to_hex(r: int, g: int, b: int) -> str: """Return a RGB color from a hex color string.""" return '{0:02x}{1:02x}{2:02x}'.format(round(r), round(g), round(b)) -def rgb_hex_to_rgb_list(hex_string): +def rgb_hex_to_rgb_list(hex_string: str) -> List[int]: """Return an RGB color value list from a hex color string.""" return [int(hex_string[i:i + len(hex_string) // 3], 16) for i in range(0, @@ -404,12 +395,14 @@ def rgb_hex_to_rgb_list(hex_string): len(hex_string) // 3)] -def color_temperature_to_hs(color_temperature_kelvin): +def color_temperature_to_hs( + color_temperature_kelvin: float) -> Tuple[float, float]: """Return an hs color from a color temperature in Kelvin.""" return color_RGB_to_hs(*color_temperature_to_rgb(color_temperature_kelvin)) -def color_temperature_to_rgb(color_temperature_kelvin): +def color_temperature_to_rgb( + color_temperature_kelvin: float) -> Tuple[float, float, float]: """ Return an RGB color from a color temperature in Kelvin. @@ -430,7 +423,7 @@ def color_temperature_to_rgb(color_temperature_kelvin): blue = _get_blue(tmp_internal) - return (red, green, blue) + return red, green, blue def _bound(color_component: float, minimum: float = 0, @@ -473,11 +466,11 @@ def _get_blue(temperature: float) -> float: return _bound(blue) -def color_temperature_mired_to_kelvin(mired_temperature): +def color_temperature_mired_to_kelvin(mired_temperature: float) -> float: """Convert absolute mired shift to degrees kelvin.""" return math.floor(1000000 / mired_temperature) -def color_temperature_kelvin_to_mired(kelvin_temperature): +def color_temperature_kelvin_to_mired(kelvin_temperature: float) -> float: """Convert degrees kelvin to mired shift.""" return math.floor(1000000 / kelvin_temperature) diff --git a/homeassistant/util/decorator.py b/homeassistant/util/decorator.py index c26606d52cffa7..22ed1a4dae66de 100644 --- a/homeassistant/util/decorator.py +++ b/homeassistant/util/decorator.py @@ -1,12 +1,15 @@ """Decorator utility functions.""" +from typing import Callable, TypeVar + +CALLABLE_T = TypeVar('CALLABLE_T', bound=Callable) # noqa pylint: disable=invalid-name class Registry(dict): """Registry of items.""" - def register(self, name): + def register(self, name: str) -> Callable[[CALLABLE_T], CALLABLE_T]: """Return decorator to register item with a specific name.""" - def decorator(func): + def decorator(func: CALLABLE_T) -> CALLABLE_T: """Register decorated function.""" self[name] = func return func diff --git a/homeassistant/util/dt.py b/homeassistant/util/dt.py index 7b5b996a3a3555..ce6775b9ea7e3d 100644 --- a/homeassistant/util/dt.py +++ b/homeassistant/util/dt.py @@ -6,9 +6,11 @@ from typing import Any, Dict, Union, Optional, Tuple # NOQA import pytz +import pytz.exceptions as pytzexceptions DATE_STR_FORMAT = "%Y-%m-%d" -UTC = DEFAULT_TIME_ZONE = pytz.utc # type: dt.tzinfo +UTC = pytz.utc +DEFAULT_TIME_ZONE = pytz.utc # type: dt.tzinfo # Copyright (c) Django Software Foundation and individual contributors. @@ -27,7 +29,7 @@ def set_default_time_zone(time_zone: dt.tzinfo) -> None: Async friendly. """ - global DEFAULT_TIME_ZONE # pylint: disable=global-statement + global DEFAULT_TIME_ZONE # NOTE: Remove in the future in favour of typing assert isinstance(time_zone, dt.tzinfo) @@ -42,7 +44,7 @@ def get_time_zone(time_zone_str: str) -> Optional[dt.tzinfo]: """ try: return pytz.timezone(time_zone_str) - except pytz.exceptions.UnknownTimeZoneError: + except pytzexceptions.UnknownTimeZoneError: return None @@ -63,20 +65,20 @@ def as_utc(dattim: dt.datetime) -> dt.datetime: """ if dattim.tzinfo == UTC: return dattim - elif dattim.tzinfo is None: - dattim = DEFAULT_TIME_ZONE.localize(dattim) + if dattim.tzinfo is None: + dattim = DEFAULT_TIME_ZONE.localize(dattim) # type: ignore return dattim.astimezone(UTC) -def as_timestamp(dt_value): +def as_timestamp(dt_value: dt.datetime) -> float: """Convert a date/time into a unix time (seconds since 1970).""" if hasattr(dt_value, "timestamp"): - parsed_dt = dt_value + parsed_dt = dt_value # type: Optional[dt.datetime] else: parsed_dt = parse_datetime(str(dt_value)) - if not parsed_dt: - raise ValueError("not a valid date/time.") + if parsed_dt is None: + raise ValueError("not a valid date/time.") return parsed_dt.timestamp() @@ -84,7 +86,7 @@ def as_local(dattim: dt.datetime) -> dt.datetime: """Convert a UTC datetime object to local time zone.""" if dattim.tzinfo == DEFAULT_TIME_ZONE: return dattim - elif dattim.tzinfo is None: + if dattim.tzinfo is None: dattim = UTC.localize(dattim) return dattim.astimezone(DEFAULT_TIME_ZONE) @@ -92,23 +94,24 @@ def as_local(dattim: dt.datetime) -> dt.datetime: def utc_from_timestamp(timestamp: float) -> dt.datetime: """Return a UTC time from a timestamp.""" - return dt.datetime.utcfromtimestamp(timestamp).replace(tzinfo=UTC) + return UTC.localize(dt.datetime.utcfromtimestamp(timestamp)) def start_of_local_day(dt_or_d: - Union[dt.date, dt.datetime]=None) -> dt.datetime: + Union[dt.date, dt.datetime] = None) -> dt.datetime: """Return local datetime object of start of day from date or datetime.""" if dt_or_d is None: date = now().date() # type: dt.date elif isinstance(dt_or_d, dt.datetime): date = dt_or_d.date() - return DEFAULT_TIME_ZONE.localize(dt.datetime.combine(date, dt.time())) + return DEFAULT_TIME_ZONE.localize(dt.datetime.combine( # type: ignore + date, dt.time())) # Copyright (c) Django Software Foundation and individual contributors. # All rights reserved. # https://github.com/django/django/blob/master/LICENSE -def parse_datetime(dt_str: str) -> dt.datetime: +def parse_datetime(dt_str: str) -> Optional[dt.datetime]: """Parse a string and return a datetime.datetime. This function supports time zone offsets. When the input contains one, @@ -134,14 +137,12 @@ def parse_datetime(dt_str: str) -> dt.datetime: if tzinfo_str[0] == '-': offset = -offset tzinfo = dt.timezone(offset) - else: - tzinfo = None kws = {k: int(v) for k, v in kws.items() if v is not None} kws['tzinfo'] = tzinfo return dt.datetime(**kws) -def parse_date(dt_str: str) -> dt.date: +def parse_date(dt_str: str) -> Optional[dt.date]: """Convert a date string to a date object.""" try: return dt.datetime.strptime(dt_str, DATE_STR_FORMAT).date() @@ -149,7 +150,7 @@ def parse_date(dt_str: str) -> dt.date: return None -def parse_time(time_str): +def parse_time(time_str: str) -> Optional[dt.time]: """Parse a time string (00:20:00) into Time object. Return None if invalid. @@ -180,11 +181,9 @@ def get_age(date: dt.datetime) -> str: def formatn(number: int, unit: str) -> str: """Add "unit" if it's plural.""" if number == 1: - return "1 %s" % unit - elif number > 1: - return "%d %ss" % (number, unit) + return '1 {}'.format(unit) + return '{:d} {}s'.format(number, unit) - # pylint: disable=invalid-sequence-index def q_n_r(first: int, second: int) -> Tuple[int, int]: """Return quotient and remaining.""" return first // second, first % second @@ -211,4 +210,4 @@ def q_n_r(first: int, second: int) -> Tuple[int, int]: if minute > 0: return formatn(minute, 'minute') - return formatn(second, 'second') if second > 0 else "0 seconds" + return formatn(second, 'second') diff --git a/homeassistant/util/json.py b/homeassistant/util/json.py index b2577ff6be6da6..8ecfebd5b33162 100644 --- a/homeassistant/util/json.py +++ b/homeassistant/util/json.py @@ -8,10 +8,16 @@ _LOGGER = logging.getLogger(__name__) -_UNDEFINED = object() +class SerializationError(HomeAssistantError): + """Error serializing the data to JSON.""" -def load_json(filename: str, default: Union[List, Dict] = _UNDEFINED) \ + +class WriteError(HomeAssistantError): + """Error writing the data.""" + + +def load_json(filename: str, default: Union[List, Dict, None] = None) \ -> Union[List, Dict]: """Load JSON data from a file and return as dict or list. @@ -19,7 +25,7 @@ def load_json(filename: str, default: Union[List, Dict] = _UNDEFINED) \ """ try: with open(filename, encoding='utf-8') as fdesc: - return json.loads(fdesc.read()) + return json.loads(fdesc.read()) # type: ignore except FileNotFoundError: # This is not a fatal error _LOGGER.debug('JSON file not found: %s', filename) @@ -29,25 +35,23 @@ def load_json(filename: str, default: Union[List, Dict] = _UNDEFINED) \ except OSError as error: _LOGGER.exception('JSON file reading failed: %s', filename) raise HomeAssistantError(error) - return {} if default is _UNDEFINED else default + return {} if default is None else default -def save_json(filename: str, data: Union[List, Dict]): +def save_json(filename: str, data: Union[List, Dict]) -> None: """Save JSON data to a file. Returns True on success. """ try: - data = json.dumps(data, sort_keys=True, indent=4) + json_data = json.dumps(data, sort_keys=True, indent=4) with open(filename, 'w', encoding='utf-8') as fdesc: - fdesc.write(data) - return True + fdesc.write(json_data) except TypeError as error: _LOGGER.exception('Failed to serialize to JSON: %s', filename) - raise HomeAssistantError(error) + raise SerializationError(error) except OSError as error: _LOGGER.exception('Saving JSON file failed: %s', filename) - raise HomeAssistantError(error) - return False + raise WriteError(error) diff --git a/homeassistant/util/location.py b/homeassistant/util/location.py index dae8ed17dc95ad..16aec2ec6172eb 100644 --- a/homeassistant/util/location.py +++ b/homeassistant/util/location.py @@ -33,7 +33,7 @@ 'use_metric']) -def detect_location_info(): +def detect_location_info() -> Optional[LocationInfo]: """Detect location information.""" data = _get_freegeoip() @@ -49,15 +49,21 @@ def detect_location_info(): return LocationInfo(**data) -def distance(lat1, lon1, lat2, lon2): +def distance(lat1: Optional[float], lon1: Optional[float], + lat2: float, lon2: float) -> Optional[float]: """Calculate the distance in meters between two points. Async friendly. """ - return vincenty((lat1, lon1), (lat2, lon2)) * 1000 + if lat1 is None or lon1 is None: + return None + result = vincenty((lat1, lon1), (lat2, lon2)) + if result is None: + return None + return result * 1000 -def elevation(latitude, longitude): +def elevation(latitude: float, longitude: float) -> int: """Return elevation for given latitude and longitude.""" try: req = requests.get( @@ -82,7 +88,7 @@ def elevation(latitude, longitude): # Author: https://github.com/maurycyp # Source: https://github.com/maurycyp/vincenty # License: https://github.com/maurycyp/vincenty/blob/master/LICENSE -# pylint: disable=invalid-name, unused-variable, invalid-sequence-index +# pylint: disable=invalid-name def vincenty(point1: Tuple[float, float], point2: Tuple[float, float], miles: bool = False) -> Optional[float]: """ diff --git a/homeassistant/util/logging.py b/homeassistant/util/logging.py index 10b43445184886..f2bf15d8a03b4d 100644 --- a/homeassistant/util/logging.py +++ b/homeassistant/util/logging.py @@ -1,7 +1,9 @@ """Logging utilities.""" import asyncio +from asyncio.events import AbstractEventLoop import logging import threading +from typing import Optional from .async_ import run_coroutine_threadsafe @@ -9,12 +11,12 @@ class HideSensitiveDataFilter(logging.Filter): """Filter API password calls.""" - def __init__(self, text): + def __init__(self, text: str) -> None: """Initialize sensitive data filter.""" super().__init__() self.text = text - def filter(self, record): + def filter(self, record: logging.LogRecord) -> bool: """Hide sensitive data in messages.""" record.msg = record.msg.replace(self.text, '*******') @@ -22,14 +24,15 @@ def filter(self, record): # pylint: disable=invalid-name -class AsyncHandler(object): +class AsyncHandler: """Logging handler wrapper to add an async layer.""" - def __init__(self, loop, handler): + def __init__( + self, loop: AbstractEventLoop, handler: logging.Handler) -> None: """Initialize async logging handler wrapper.""" self.handler = handler self.loop = loop - self._queue = asyncio.Queue(loop=loop) + self._queue = asyncio.Queue(loop=loop) # type: asyncio.Queue self._thread = threading.Thread(target=self._process) # Delegate from handler @@ -45,11 +48,11 @@ def __init__(self, loop, handler): self._thread.start() - def close(self): + def close(self) -> None: """Wrap close to handler.""" self.emit(None) - async def async_close(self, blocking=False): + async def async_close(self, blocking: bool = False) -> None: """Close the handler. When blocking=True, will wait till closed. @@ -60,7 +63,7 @@ async def async_close(self, blocking=False): while self._thread.is_alive(): await asyncio.sleep(0, loop=self.loop) - def emit(self, record): + def emit(self, record: Optional[logging.LogRecord]) -> None: """Process a record.""" ident = self.loop.__dict__.get("_thread_ident") @@ -71,11 +74,11 @@ def emit(self, record): else: self.loop.call_soon_threadsafe(self._queue.put_nowait, record) - def __repr__(self): + def __repr__(self) -> str: """Return the string names.""" return str(self.handler) - def _process(self): + def _process(self) -> None: """Process log in a thread.""" while True: record = run_coroutine_threadsafe( @@ -87,34 +90,34 @@ def _process(self): self.handler.emit(record) - def createLock(self): + def createLock(self) -> None: """Ignore lock stuff.""" pass - def acquire(self): + def acquire(self) -> None: """Ignore lock stuff.""" pass - def release(self): + def release(self) -> None: """Ignore lock stuff.""" pass @property - def level(self): + def level(self) -> int: """Wrap property level to handler.""" return self.handler.level @property - def formatter(self): + def formatter(self) -> Optional[logging.Formatter]: """Wrap property formatter to handler.""" return self.handler.formatter @property - def name(self): + def name(self) -> str: """Wrap property set_name to handler.""" - return self.handler.get_name() + return self.handler.get_name() # type: ignore @name.setter - def name(self, name): + def name(self, name: str) -> None: """Wrap property get_name to handler.""" - self.handler.name = name + self.handler.set_name(name) # type: ignore diff --git a/homeassistant/util/package.py b/homeassistant/util/package.py index a2f707c54f5032..9433046e6881be 100644 --- a/homeassistant/util/package.py +++ b/homeassistant/util/package.py @@ -16,7 +16,7 @@ INSTALL_LOCK = threading.Lock() -def is_virtual_env(): +def is_virtual_env() -> bool: """Return if we run in a virtual environtment.""" # Check supports venv && virtualenv return (getattr(sys, 'base_prefix', sys.prefix) != sys.prefix or @@ -77,32 +77,16 @@ def check_package_exists(package: str) -> bool: return any(dist in req for dist in env[req.project_name]) -def _get_user_site(deps_dir: str) -> tuple: - """Get arguments and environment for subprocess used in get_user_site.""" - env = os.environ.copy() - env['PYTHONUSERBASE'] = os.path.abspath(deps_dir) - args = [sys.executable, '-m', 'site', '--user-site'] - return args, env - - -def get_user_site(deps_dir: str) -> str: - """Return user local library path.""" - args, env = _get_user_site(deps_dir) - process = Popen(args, stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env) - stdout, _ = process.communicate() - lib_dir = stdout.decode().strip() - return lib_dir - - -async def async_get_user_site(deps_dir: str, - loop: asyncio.AbstractEventLoop) -> str: +async def async_get_user_site(deps_dir: str) -> str: """Return user local library path. This function is a coroutine. """ - args, env = _get_user_site(deps_dir) + env = os.environ.copy() + env['PYTHONUSERBASE'] = os.path.abspath(deps_dir) + args = [sys.executable, '-m', 'site', '--user-site'] process = await asyncio.create_subprocess_exec( - *args, loop=loop, stdin=asyncio.subprocess.PIPE, + *args, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, env=env) stdout, _ = await process.communicate() diff --git a/homeassistant/util/ssl.py b/homeassistant/util/ssl.py new file mode 100644 index 00000000000000..b78395cdb0d341 --- /dev/null +++ b/homeassistant/util/ssl.py @@ -0,0 +1,94 @@ +"""Helper to create SSL contexts.""" +import ssl + +import certifi + + +def client_context() -> ssl.SSLContext: + """Return an SSL context for making requests.""" + context = ssl.create_default_context( + purpose=ssl.Purpose.SERVER_AUTH, + cafile=certifi.where() + ) + return context + + +def server_context_modern() -> ssl.SSLContext: + """Return an SSL context following the Mozilla recommendations. + + TLS configuration follows the best-practice guidelines specified here: + https://wiki.mozilla.org/Security/Server_Side_TLS + Modern guidelines are followed. + """ + context = ssl.SSLContext(ssl.PROTOCOL_TLS) # pylint: disable=no-member + + context.options |= ( + ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | + ssl.OP_NO_TLSv1 | ssl.OP_NO_TLSv1_1 | + ssl.OP_CIPHER_SERVER_PREFERENCE + ) + if hasattr(ssl, 'OP_NO_COMPRESSION'): + context.options |= ssl.OP_NO_COMPRESSION + + context.set_ciphers( + "ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:" + "ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:" + "ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:" + "ECDHE-ECDSA-AES256-SHA384:ECDHE-RSA-AES256-SHA384:" + "ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256" + ) + + return context + + +def server_context_intermediate() -> ssl.SSLContext: + """Return an SSL context following the Mozilla recommendations. + + TLS configuration follows the best-practice guidelines specified here: + https://wiki.mozilla.org/Security/Server_Side_TLS + Intermediate guidelines are followed. + """ + context = ssl.SSLContext(ssl.PROTOCOL_TLS) # pylint: disable=no-member + + context.options |= ( + ssl.OP_NO_SSLv2 | ssl.OP_NO_SSLv3 | + ssl.OP_CIPHER_SERVER_PREFERENCE + ) + if hasattr(ssl, 'OP_NO_COMPRESSION'): + context.options |= ssl.OP_NO_COMPRESSION + + context.set_ciphers( + "ECDHE-ECDSA-CHACHA20-POLY1305:" + "ECDHE-RSA-CHACHA20-POLY1305:" + "ECDHE-ECDSA-AES128-GCM-SHA256:" + "ECDHE-RSA-AES128-GCM-SHA256:" + "ECDHE-ECDSA-AES256-GCM-SHA384:" + "ECDHE-RSA-AES256-GCM-SHA384:" + "DHE-RSA-AES128-GCM-SHA256:" + "DHE-RSA-AES256-GCM-SHA384:" + "ECDHE-ECDSA-AES128-SHA256:" + "ECDHE-RSA-AES128-SHA256:" + "ECDHE-ECDSA-AES128-SHA:" + "ECDHE-RSA-AES256-SHA384:" + "ECDHE-RSA-AES128-SHA:" + "ECDHE-ECDSA-AES256-SHA384:" + "ECDHE-ECDSA-AES256-SHA:" + "ECDHE-RSA-AES256-SHA:" + "DHE-RSA-AES128-SHA256:" + "DHE-RSA-AES128-SHA:" + "DHE-RSA-AES256-SHA256:" + "DHE-RSA-AES256-SHA:" + "ECDHE-ECDSA-DES-CBC3-SHA:" + "ECDHE-RSA-DES-CBC3-SHA:" + "EDH-RSA-DES-CBC3-SHA:" + "AES128-GCM-SHA256:" + "AES256-GCM-SHA384:" + "AES128-SHA256:" + "AES256-SHA256:" + "AES128-SHA:" + "AES256-SHA:" + "DES-CBC3-SHA:" + "!DSS" + ) + + return context diff --git a/homeassistant/util/temperature.py b/homeassistant/util/temperature.py index 913d645690621f..6e2b378b2180ad 100644 --- a/homeassistant/util/temperature.py +++ b/homeassistant/util/temperature.py @@ -29,6 +29,6 @@ def convert(temperature: float, from_unit: str, to_unit: str, if from_unit == to_unit: return temperature - elif from_unit == TEMP_CELSIUS: + if from_unit == TEMP_CELSIUS: return celsius_to_fahrenheit(temperature, interval) return fahrenheit_to_celsius(temperature, interval) diff --git a/homeassistant/util/unit_system.py b/homeassistant/util/unit_system.py index ecef1087747079..5a8f515c3adfbc 100644 --- a/homeassistant/util/unit_system.py +++ b/homeassistant/util/unit_system.py @@ -1,6 +1,7 @@ """Unit system helper class and methods.""" import logging +from typing import Optional from numbers import Number from homeassistant.const import ( @@ -61,10 +62,10 @@ def is_valid_unit(unit: str, unit_type: str) -> bool: return unit in units -class UnitSystem(object): +class UnitSystem: """A container for units of measure.""" - def __init__(self: object, name: str, temperature: str, length: str, + def __init__(self, name: str, temperature: str, length: str, volume: str, mass: str) -> None: """Initialize the unit system object.""" errors = \ @@ -86,11 +87,11 @@ def __init__(self: object, name: str, temperature: str, length: str, self.volume_unit = volume @property - def is_metric(self: object) -> bool: + def is_metric(self) -> bool: """Determine if this is the metric unit system.""" return self.name == CONF_UNIT_SYSTEM_METRIC - def temperature(self: object, temperature: float, from_unit: str) -> float: + def temperature(self, temperature: float, from_unit: str) -> float: """Convert the given temperature to this unit system.""" if not isinstance(temperature, Number): raise TypeError( @@ -99,7 +100,7 @@ def temperature(self: object, temperature: float, from_unit: str) -> float: return temperature_util.convert(temperature, from_unit, self.temperature_unit) - def length(self: object, length: float, from_unit: str) -> float: + def length(self, length: Optional[float], from_unit: str) -> float: """Convert the given length to this unit system.""" if not isinstance(length, Number): raise TypeError('{} is not a numeric value.'.format(str(length))) diff --git a/homeassistant/util/yaml.py b/homeassistant/util/yaml.py index 66d673987a3789..69f83aefad7989 100644 --- a/homeassistant/util/yaml.py +++ b/homeassistant/util/yaml.py @@ -4,7 +4,7 @@ import sys import fnmatch from collections import OrderedDict -from typing import Union, List, Dict +from typing import Union, List, Dict, Iterator, overload, TypeVar import yaml try: @@ -13,7 +13,7 @@ keyring = None try: - import credstash # pylint: disable=import-error, no-member + import credstash except ImportError: credstash = None @@ -22,7 +22,10 @@ _LOGGER = logging.getLogger(__name__) _SECRET_NAMESPACE = 'homeassistant' SECRET_YAML = 'secrets.yaml' -__SECRET_CACHE = {} # type: Dict +__SECRET_CACHE = {} # type: Dict[str, JSON_TYPE] + +JSON_TYPE = Union[List, Dict, str] # pylint: disable=invalid-name +DICT_T = TypeVar('DICT_T', bound=Dict) # pylint: disable=invalid-name class NodeListClass(list): @@ -37,31 +40,53 @@ class NodeStrClass(str): pass -def _add_reference(obj, loader, node): - """Add file reference information to an object.""" - if isinstance(obj, list): - obj = NodeListClass(obj) - if isinstance(obj, str): - obj = NodeStrClass(obj) - setattr(obj, '__config_file__', loader.name) - setattr(obj, '__line__', node.start_mark.line) - return obj - - # pylint: disable=too-many-ancestors class SafeLineLoader(yaml.SafeLoader): """Loader class that keeps track of line numbers.""" - def compose_node(self, parent: yaml.nodes.Node, index) -> yaml.nodes.Node: + def compose_node(self, parent: yaml.nodes.Node, + index: int) -> yaml.nodes.Node: """Annotate a node with the first line it was seen.""" last_line = self.line # type: int node = super(SafeLineLoader, self).compose_node(parent, index) # type: yaml.nodes.Node - node.__line__ = last_line + 1 + node.__line__ = last_line + 1 # type: ignore return node -def load_yaml(fname: str) -> Union[List, Dict]: +# pylint: disable=pointless-statement +@overload +def _add_reference(obj: Union[list, NodeListClass], + loader: yaml.SafeLoader, + node: yaml.nodes.Node) -> NodeListClass: ... + + +@overload # noqa: F811 +def _add_reference(obj: Union[str, NodeStrClass], + loader: yaml.SafeLoader, + node: yaml.nodes.Node) -> NodeStrClass: ... + + +@overload # noqa: F811 +def _add_reference(obj: DICT_T, + loader: yaml.SafeLoader, + node: yaml.nodes.Node) -> DICT_T: ... +# pylint: enable=pointless-statement + + +def _add_reference(obj, loader: SafeLineLoader, # type: ignore # noqa: F811 + node: yaml.nodes.Node): + """Add file reference information to an object.""" + if isinstance(obj, list): + obj = NodeListClass(obj) + if isinstance(obj, str): + obj = NodeStrClass(obj) + setattr(obj, '__config_file__', loader.name) + setattr(obj, '__line__', node.start_mark.line) + return obj + + +def load_yaml(fname: str) -> JSON_TYPE: """Load a YAML file.""" try: with open(fname, encoding='utf-8') as conf_file: @@ -69,7 +94,7 @@ def load_yaml(fname: str) -> Union[List, Dict]: # We convert that to an empty dict return yaml.load(conf_file, Loader=SafeLineLoader) or OrderedDict() except yaml.YAMLError as exc: - _LOGGER.error(exc) + _LOGGER.error(str(exc)) raise HomeAssistantError(exc) except UnicodeDecodeError as exc: _LOGGER.error("Unable to read file %s: %s", fname, exc) @@ -83,12 +108,12 @@ def dump(_dict: dict) -> str: .replace(': null\n', ':\n') -def save_yaml(path, data): +def save_yaml(path: str, data: dict) -> None: """Save YAML to a file.""" # Dump before writing to not truncate the file if dumping fails - data = dump(data) + str_data = dump(data) with open(path, 'w', encoding='utf-8') as outfile: - outfile.write(data) + outfile.write(str_data) def clear_secret_cache() -> None: @@ -100,7 +125,7 @@ def clear_secret_cache() -> None: def _include_yaml(loader: SafeLineLoader, - node: yaml.nodes.Node) -> Union[List, Dict]: + node: yaml.nodes.Node) -> JSON_TYPE: """Load another YAML file and embeds it using the !include tag. Example: @@ -115,7 +140,7 @@ def _is_file_valid(name: str) -> bool: return not name.startswith('.') -def _find_files(directory: str, pattern: str): +def _find_files(directory: str, pattern: str) -> Iterator[str]: """Recursively load files in a directory.""" for root, dirs, files in os.walk(directory, topdown=True): dirs[:] = [d for d in dirs if _is_file_valid(d)] @@ -151,7 +176,7 @@ def _include_dir_merge_named_yaml(loader: SafeLineLoader, def _include_dir_list_yaml(loader: SafeLineLoader, - node: yaml.nodes.Node): + node: yaml.nodes.Node) -> List[JSON_TYPE]: """Load multiple files from directory as a list.""" loc = os.path.join(os.path.dirname(loader.name), node.value) return [load_yaml(f) for f in _find_files(loc, '*.yaml') @@ -159,11 +184,11 @@ def _include_dir_list_yaml(loader: SafeLineLoader, def _include_dir_merge_list_yaml(loader: SafeLineLoader, - node: yaml.nodes.Node): + node: yaml.nodes.Node) -> JSON_TYPE: """Load multiple files from directory as a merged list.""" loc = os.path.join(os.path.dirname(loader.name), node.value) # type: str - merged_list = [] # type: List + merged_list = [] # type: List[JSON_TYPE] for fname in _find_files(loc, '*.yaml'): if os.path.basename(fname) == SECRET_YAML: continue @@ -202,28 +227,27 @@ def _ordered_dict(loader: SafeLineLoader, return _add_reference(OrderedDict(nodes), loader, node) -def _construct_seq(loader: SafeLineLoader, node: yaml.nodes.Node): +def _construct_seq(loader: SafeLineLoader, node: yaml.nodes.Node) -> JSON_TYPE: """Add line number and file name to Load YAML sequence.""" obj, = loader.construct_yaml_seq(node) return _add_reference(obj, loader, node) def _env_var_yaml(loader: SafeLineLoader, - node: yaml.nodes.Node): + node: yaml.nodes.Node) -> str: """Load environment variables and embed it into the configuration YAML.""" args = node.value.split() # Check for a default value if len(args) > 1: return os.getenv(args[0], ' '.join(args[1:])) - elif args[0] in os.environ: + if args[0] in os.environ: return os.environ[args[0]] - else: - _LOGGER.error("Environment variable %s not defined.", node.value) - raise HomeAssistantError(node.value) + _LOGGER.error("Environment variable %s not defined.", node.value) + raise HomeAssistantError(node.value) -def _load_secret_yaml(secret_path: str) -> Dict: +def _load_secret_yaml(secret_path: str) -> JSON_TYPE: """Load the secrets yaml from path.""" secret_path = os.path.join(secret_path, SECRET_YAML) if secret_path in __SECRET_CACHE: @@ -232,6 +256,8 @@ def _load_secret_yaml(secret_path: str) -> Dict: _LOGGER.debug('Loading %s', secret_path) try: secrets = load_yaml(secret_path) + if not isinstance(secrets, dict): + raise HomeAssistantError('Secrets is not a dictionary') if 'logger' in secrets: logger = str(secrets['logger']).lower() if logger == 'debug': @@ -246,9 +272,8 @@ def _load_secret_yaml(secret_path: str) -> Dict: return secrets -# pylint: disable=protected-access def _secret_yaml(loader: SafeLineLoader, - node: yaml.nodes.Node): + node: yaml.nodes.Node) -> JSON_TYPE: """Load secrets and embed it into the configuration YAML.""" secret_path = os.path.dirname(loader.name) while True: @@ -308,9 +333,10 @@ def _secret_yaml(loader: SafeLineLoader, # From: https://gist.github.com/miracle2k/3184458 # pylint: disable=redefined-outer-name -def represent_odict(dump, tag, mapping, flow_style=None): +def represent_odict(dump, tag, mapping, # type: ignore + flow_style=None) -> yaml.MappingNode: """Like BaseRepresenter.represent_mapping but does not issue the sort().""" - value = [] + value = [] # type: list node = yaml.MappingNode(tag, value, flow_style=flow_style) if dump.alias_key is not None: dump.represented_objects[dump.alias_key] = node diff --git a/mypy.ini b/mypy.ini new file mode 100644 index 00000000000000..875aec5eda7993 --- /dev/null +++ b/mypy.ini @@ -0,0 +1,21 @@ +[mypy] +check_untyped_defs = true +disallow_untyped_calls = true +follow_imports = silent +ignore_missing_imports = true +warn_incomplete_stub = true +warn_redundant_casts = true +warn_return_any = true +warn_unused_configs = true +warn_unused_ignores = true + +[mypy-homeassistant.*] +disallow_untyped_defs = true + +[mypy-homeassistant.config_entries] +disallow_untyped_defs = false + +[mypy-homeassistant.util.yaml] +warn_return_any = false +disallow_untyped_calls = false + diff --git a/pylintrc b/pylintrc index df839b379b549b..b72502248d7b29 100644 --- a/pylintrc +++ b/pylintrc @@ -1,12 +1,9 @@ -[MASTER] -reports=no - +[MESSAGES CONTROL] # Reasons disabled: # locally-disabled - it spams too much # duplicate-code - unavoidable # cyclic-import - doesn't test if both import on load # abstract-class-little-used - prevents from setting right foundation -# abstract-class-not-used - is flaky, should not show up but does # unused-argument - generic callbacks and setup methods create a lot of warnings # global-statement - used for the on-demand requirement installation # redefined-variable-type - this is Python, we're duck typing! @@ -14,18 +11,16 @@ reports=no # too-few-* - same as too-many-* # abstract-method - with intro of async there are always methods missing # inconsistent-return-statements - doesn't handle raise - -generated-members=botocore.errorfactory - +# not-an-iterable - https://github.com/PyCQA/pylint/issues/2311 disable= abstract-class-little-used, - abstract-class-not-used, abstract-method, cyclic-import, duplicate-code, global-statement, inconsistent-return-statements, locally-disabled, + not-an-iterable, not-context-manager, redefined-variable-type, too-few-public-methods, @@ -39,9 +34,13 @@ disable= too-many-statements, unused-argument -[EXCEPTIONS] -overgeneral-exceptions=Exception,HomeAssistantError +[REPORTS] +reports=no +[TYPECHECK] # For attrs -[typecheck] ignored-classes=_CountingAttr +generated-members=botocore.errorfactory + +[EXCEPTIONS] +overgeneral-exceptions=Exception,HomeAssistantError diff --git a/requirements_all.txt b/requirements_all.txt index 0887ff98996ef4..fe728bf2e0244b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1,19 +1,23 @@ # Home Assistant core -requests==2.18.4 -pyyaml>=3.11,<4 -pytz>=2017.02 -pip>=8.0.3 -jinja2>=2.10 -voluptuous==0.11.1 -typing>=3,<4 -aiohttp==3.1.3 -async_timeout==2.0.1 +aiohttp==3.3.2 astral==1.6.1 -certifi>=2017.4.17 +async_timeout==3.0.0 attrs==18.1.0 +certifi>=2018.04.16 +jinja2>=2.10 +PyJWT==1.6.4 +cryptography==2.3.1 +pip>=8.0.3 +pytz>=2018.04 +pyyaml>=3.13,<4 +requests==2.19.1 +voluptuous==0.11.5 # homeassistant.components.nuimo_controller ---only-binary=all https://github.com/getSenic/nuimo-linux-python/archive/29fc42987f74d8090d0e2382e8f248ff5990b8c9.zip#nuimo==1.0.0 +--only-binary=all nuimo==0.1.0 + +# homeassistant.components.sensor.dht +# Adafruit-DHT==1.3.3 # homeassistant.components.sensor.sht31 Adafruit-GPIO==1.0.3 @@ -28,25 +32,25 @@ Adafruit-SHT31==1.0.2 DoorBirdPy==0.1.3 # homeassistant.components.homekit -HAP-python==2.0.0 +HAP-python==2.2.2 # homeassistant.components.notify.mastodon -Mastodon.py==1.2.2 +Mastodon.py==1.3.1 # homeassistant.components.isy994 PyISY==1.1.0 -# homeassistant.components.notify.html5 -PyJWT==1.6.0 - # homeassistant.components.sensor.mvglive PyMVGLive==1.1.4 # homeassistant.components.arduino PyMata==2.14 +# homeassistant.components.sensor.rmvtransport +PyRMVtransport==0.0.7 + # homeassistant.components.xiaomi_aqara -PyXiaomiGateway==0.9.0 +PyXiaomiGateway==0.9.5 # homeassistant.components.rpi_gpio # RPi.GPIO==0.6.1 @@ -54,17 +58,17 @@ PyXiaomiGateway==0.9.0 # homeassistant.components.remember_the_milk RtmAPI==0.7.0 -# homeassistant.components.media_player.sonos +# homeassistant.components.sonos SoCo==0.14 # homeassistant.components.sensor.travisci TravisPy==0.3.5 # homeassistant.components.notify.twitter -TwitterAPI==2.5.0 +TwitterAPI==2.5.4 # homeassistant.components.sensor.waze_travel_time -WazeRouteCalculator==0.5 +WazeRouteCalculator==0.6 # homeassistant.components.notify.yessssms YesssSMS==0.1.1b3 @@ -81,18 +85,24 @@ aioautomatic==0.6.5 # homeassistant.components.sensor.dnsip aiodns==1.1.1 +# homeassistant.components.device_tracker.freebox +aiofreepybox==0.0.4 + +# homeassistant.components.camera.yi +aioftp==0.10.1 + # homeassistant.components.emulated_hue # homeassistant.components.http aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==1.3.0 +aiohue==1.5.0 # homeassistant.components.sensor.imap aioimaplib==0.7.13 # homeassistant.components.light.lifx -aiolifx==0.6.1 +aiolifx==0.6.3 # homeassistant.components.light.lifx aiolifx_effects==0.1.2 @@ -100,6 +110,9 @@ aiolifx_effects==0.1.2 # homeassistant.components.scene.hunterdouglas_powerview aiopvapi==1.5.4 +# homeassistant.components.cover.aladdin_connect +aladdin_connect==0.1 + # homeassistant.components.alarmdecoder alarmdecoder==1.13.2 @@ -107,7 +120,10 @@ alarmdecoder==1.13.2 alpha_vantage==2.0.0 # homeassistant.components.amcrest -amcrest==1.2.2 +amcrest==1.2.3 + +# homeassistant.components.switch.anel_pwrctrl +anel_pwrctrl-homeassistant==0.0.1.dev2 # homeassistant.components.media_player.anthemav anthemav==1.1.8 @@ -121,6 +137,9 @@ apns2==0.3.0 # homeassistant.components.asterisk_mbox asterisk_mbox==0.4.0 +# homeassistant.components.media_player.dlna_dmr +async-upnp-client==0.12.3 + # homeassistant.components.light.avion # avion==0.7 @@ -143,13 +162,13 @@ batinfo==0.4.2 # homeassistant.components.sensor.geizhals # homeassistant.components.sensor.scrape # homeassistant.components.sensor.sytadin -beautifulsoup4==4.6.0 +beautifulsoup4==4.6.1 # homeassistant.components.zha -bellows==0.5.2 +bellows==0.6.0 # homeassistant.components.bmw_connected_drive -bimmer_connected==0.5.0 +bimmer_connected==0.5.1 # homeassistant.components.blink blinkpy==0.6.0 @@ -161,7 +180,7 @@ blinkstick==1.1.8 # blinkt==0.1.0 # homeassistant.components.sensor.bitcoin -blockchain==1.4.0 +blockchain==1.4.4 # homeassistant.components.light.decora # bluepy==1.1.4 @@ -178,13 +197,22 @@ boto3==1.4.7 # homeassistant.scripts.credstash botocore==1.7.34 +# homeassistant.components.media_player.braviatv +braviarc-homeassistant==0.3.7.dev0 + # homeassistant.components.sensor.broadlink # homeassistant.components.switch.broadlink broadlink==0.9.0 +# homeassistant.components.cover.brunt +brunt==0.1.2 + # homeassistant.components.device_tracker.bluetooth_tracker bt_proximity==0.1.2 +# homeassistant.components.device_tracker.bt_home_hub_5 +bthomehub5-devicelist==0.1.1 + # homeassistant.components.sensor.buienradar # homeassistant.components.weather.buienradar buienradar==0.91 @@ -199,7 +227,7 @@ ciscosparkapi==0.4.2 coinbase==2.1.0 # homeassistant.components.sensor.coinmarketcap -coinmarketcap==4.2.1 +coinmarketcap==5.0.3 # homeassistant.scripts.check_config colorlog==3.1.4 @@ -246,10 +274,10 @@ defusedxml==0.5.0 deluge-client==1.4.0 # homeassistant.components.media_player.denonavr -denonavr==0.6.1 +denonavr==0.7.5 # homeassistant.components.media_player.directv -directpy==0.2 +directpy==0.5 # homeassistant.components.sensor.discogs discogs_client==2.2.1 @@ -258,7 +286,7 @@ discogs_client==2.2.1 discord.py==0.16.12 # homeassistant.components.updater -distro==1.2.0 +distro==1.3.0 # homeassistant.components.switch.digitalloggers dlipower==0.7.165 @@ -276,6 +304,9 @@ dsmr_parser==0.11 # homeassistant.components.sensor.dweet dweepy==0.3.0 +# homeassistant.components.media_player.horizon +einder==0.3.1 + # homeassistant.components.sensor.eliqonline eliqonline==1.0.14 @@ -285,9 +316,18 @@ enocean==0.40 # homeassistant.components.sensor.envirophat # envirophat==0.0.6 +# homeassistant.components.sensor.enphase_envoy +envoy_reader==0.1 + # homeassistant.components.sensor.season ephem==3.7.6.0 +# homeassistant.components.media_player.epson +epson-projector==0.1.3 + +# homeassistant.components.netgear_lte +eternalegypt==0.0.3 + # homeassistant.components.keyboard_remote # evdev==0.6.1 @@ -308,11 +348,14 @@ fedexdeliverymanager==1.0.6 # homeassistant.components.sensor.geo_rss_events feedparser==5.2.1 +# homeassistant.components.sensor.fints +fints==0.2.1 + # homeassistant.components.sensor.fitbit fitbit==0.3.0 # homeassistant.components.sensor.fixer -fixerio==0.1.1 +fixerio==1.0.0a0 # homeassistant.components.light.flux_led flux_led==0.21 @@ -338,10 +381,10 @@ gTTS-token==1.1.1 # gattlib==0.20150805 # homeassistant.components.sensor.gearbest -gearbest_parser==1.0.5 +gearbest_parser==1.0.7 # homeassistant.components.sensor.gitter -gitterpy==0.1.6 +gitterpy==0.1.7 # homeassistant.components.notify.gntp gntp==1.0.3 @@ -365,7 +408,7 @@ gstreamer-player==1.1.0 ha-ffmpeg==1.9 # homeassistant.components.media_player.philips_js -ha-philipsjs==0.0.3 +ha-philipsjs==0.0.5 # homeassistant.components.sensor.geo_rss_events haversine==0.4.5 @@ -382,56 +425,36 @@ hikvision==0.4 # homeassistant.components.notify.hipchat hipnotify==1.0.8 +# homeassistant.components.sensor.pi_hole +hole==0.3.0 + # homeassistant.components.binary_sensor.workday -holidays==0.9.5 +holidays==0.9.6 # homeassistant.components.frontend -home-assistant-frontend==20180509.0 +home-assistant-frontend==20180820.0 # homeassistant.components.homekit_controller -# homekit==0.6 +# homekit==0.10 # homeassistant.components.homematicip_cloud -homematicip==0.9.2.4 - -# homeassistant.components.camera.onvif -http://github.com/tgaugry/suds-passworddigest-py3/archive/86fc50e39b4d2b8997481967d6a7fe1c57118999.zip#suds-passworddigest-py3==0.1.2a +homematicip==0.9.8 +# homeassistant.components.google # homeassistant.components.remember_the_milk httplib2==0.10.3 -# homeassistant.components.sensor.dht -# https://github.com/adafruit/Adafruit_Python_DHT/archive/da8cddf7fb629c1ef4f046ca44f42523c9cf2d11.zip#Adafruit_DHT==1.3.2 - -# homeassistant.components.media_player.braviatv -https://github.com/aparraga/braviarc/archive/0.3.7.zip#braviarc==0.3.7 - -# homeassistant.components.media_player.spotify -https://github.com/happyleavesaoc/spotipy/archive/544614f4b1d508201d363e84e871f86c90aa26b2.zip#spotipy==2.4.4 - -# homeassistant.components.netatmo -https://github.com/jabesq/netatmo-api-python/archive/v0.9.2.1.zip#lnetatmo==0.9.2.1 - -# homeassistant.components.neato -https://github.com/jabesq/pybotvac/archive/v0.0.5.zip#pybotvac==0.0.5 - -# homeassistant.components.switch.anel_pwrctrl -https://github.com/mweinelt/anel-pwrctrl/archive/ed26e8830e28a2bfa4260a9002db23ce3e7e63d7.zip#anel_pwrctrl==0.0.1 - -# homeassistant.components.sensor.gtfs -https://github.com/robbiet480/pygtfs/archive/00546724e4bbcb3053110d844ca44e2246267dd8.zip#pygtfs==0.1.3 - -# homeassistant.components.binary_sensor.flic -https://github.com/soldag/pyflic/archive/0.4.zip#pyflic==0.4 - -# homeassistant.components.media_player.lg_netcast -https://github.com/wokar/pylgnetcast/archive/v0.2.0.zip#pylgnetcast==0.2.0 +# homeassistant.components.hydrawise +hydrawiser==0.1.1 # homeassistant.components.sensor.bh1750 # homeassistant.components.sensor.bme280 # homeassistant.components.sensor.htu21d # i2csense==0.0.4 +# homeassistant.components.watson_iot +ibmiotf==0.3.4 + # homeassistant.components.light.iglo iglo==1.2.7 @@ -446,7 +469,10 @@ influxdb==5.0.0 insteonlocal==0.53 # homeassistant.components.insteon_plm -insteonplm==0.9.1 +insteonplm==0.11.7 + +# homeassistant.components.sensor.iperf3 +iperf3==0.1.10 # homeassistant.components.verisure jsonpath==0.75 @@ -459,13 +485,19 @@ jsonrpc-async==0.6 jsonrpc-websocket==0.6 # homeassistant.scripts.keyring -keyring==12.0.0 +keyring==13.2.1 # homeassistant.scripts.keyring -keyrings.alt==3.0 +keyrings.alt==3.1 + +# homeassistant.components.lock.kiwi +kiwiki-client==0.1.1 + +# homeassistant.components.konnected +konnected==0.1.2 # homeassistant.components.eufy -lakeside==0.5 +lakeside==0.7 # homeassistant.components.device_tracker.owntracks # homeassistant.components.device_tracker.owntracks_http @@ -478,7 +510,7 @@ libpurecoollink==0.4.2 libpyfoscam==1.0 # homeassistant.components.device_tracker.mikrotik -librouteros==1.0.5 +librouteros==2.1.0 # homeassistant.components.media_player.soundtouch libsoundtouch==0.7.2 @@ -490,10 +522,10 @@ liffylights==0.9.4 lightify==1.0.6.1 # homeassistant.components.light.limitlessled -limitlessled==1.1.0 +limitlessled==1.1.2 # homeassistant.components.linode -linode-api==4.1.4b2 +linode-api==4.1.9b1 # homeassistant.components.media_player.liveboxplaytv liveboxplaytv==2.0.2 @@ -503,14 +535,20 @@ liveboxplaytv==2.0.2 lmnotify==0.0.4 # homeassistant.components.device_tracker.google_maps -locationsharinglib==1.2.2 +locationsharinglib==2.0.11 # homeassistant.components.sensor.luftdaten -luftdaten==0.1.3 +luftdaten==0.2.0 + +# homeassistant.components.light.lw12wifi +lw12==0.9.2 # homeassistant.components.sensor.lyft lyft_rides==0.2 +# homeassistant.components.sensor.magicseaweed +magicseaweed==1.0.0 + # homeassistant.components.matrix matrix-client==0.2.0 @@ -534,10 +572,10 @@ mitemp_bt==0.0.1 motorparts==1.0.2 # homeassistant.components.tts -mutagen==1.40.0 +mutagen==1.41.0 # homeassistant.components.mychevy -mychevy==0.1.1 +mychevy==0.4.0 # homeassistant.components.mycroft mycroftapi==2.0 @@ -552,8 +590,14 @@ nad_receiver==0.0.9 # homeassistant.components.light.nanoleaf_aurora nanoleaf==0.4.1 +# homeassistant.components.device_tracker.keenetic_ndms2 +ndms2_client==0.0.3 + +# homeassistant.components.sensor.netdata +netdata==0.1.2 + # homeassistant.components.discovery -netdisco==1.4.1 +netdisco==2.0.0 # homeassistant.components.sensor.neurio_energy neurio==0.3.1 @@ -561,12 +605,15 @@ neurio==0.3.1 # homeassistant.components.sensor.nederlandse_spoorwegen nsapi==2.7.4 +# homeassistant.components.sensor.nsw_fuel_station +nsw-fuel-api-client==1.0.10 + # homeassistant.components.nuheat nuheat==0.3.0 # homeassistant.components.binary_sensor.trend # homeassistant.components.image_processing.opencv -numpy==1.14.3 +numpy==1.15.0 # homeassistant.components.google oauth2client==4.0.0 @@ -604,7 +651,7 @@ pdunehd==1.3 # homeassistant.components.device_tracker.cisco_ios # homeassistant.components.device_tracker.unifi_direct # homeassistant.components.media_player.pandora -pexpect==4.0.1 +pexpect==4.6.0 # homeassistant.components.rpi_pfio pifacecommon==4.1.2 @@ -619,7 +666,7 @@ piglow==1.2.4 pilight==0.1.1 # homeassistant.components.camera.proxy -pillow==5.0.0 +pillow==5.2.0 # homeassistant.components.dominos pizzapi==0.0.3 @@ -636,16 +683,16 @@ pmsensor==0.4 pocketcasts==0.1 # homeassistant.components.sensor.postnl -postnl_api==1.0.1 +postnl_api==1.0.2 # homeassistant.components.climate.proliphix proliphix==0.4.1 # homeassistant.components.prometheus -prometheus_client==0.1.0 +prometheus_client==0.2.0 # homeassistant.components.sensor.systemmonitor -psutil==5.4.5 +psutil==5.4.6 # homeassistant.components.wink pubnubsub-handler==1.0.2 @@ -661,7 +708,7 @@ pushetta==1.0.15 pwmled==1.2.1 # homeassistant.components.august -py-august==0.4.0 +py-august==0.6.0 # homeassistant.components.canary py-canary==0.5.0 @@ -680,7 +727,7 @@ pyCEC==0.4.13 # homeassistant.components.light.tplink # homeassistant.components.switch.tplink -pyHS100==0.3.0 +pyHS100==0.3.2 # homeassistant.components.rfxtrx pyRFXtrx==0.22.1 @@ -691,17 +738,20 @@ pyTibber==0.4.1 # homeassistant.components.switch.dlink pyW215==0.6.0 +# homeassistant.components.cover.ryobi_gdo +py_ryobi_gdo==0.0.10 + # homeassistant.components.ads pyads==2.2.6 # homeassistant.components.sensor.airvisual -pyairvisual==1.0.0 +pyairvisual==2.0.1 # homeassistant.components.alarm_control_panel.alarmdotcom pyalarmdotcom==0.3.2 # homeassistant.components.arlo -pyarlo==0.1.2 +pyarlo==0.2.0 # homeassistant.components.notify.xmpp pyasn1-modules==0.1.5 @@ -709,8 +759,11 @@ pyasn1-modules==0.1.5 # homeassistant.components.notify.xmpp pyasn1==0.3.7 +# homeassistant.components.netatmo +pyatmo==1.1.1 + # homeassistant.components.apple_tv -pyatv==0.3.9 +pyatv==0.3.10 # homeassistant.components.device_tracker.bbox # homeassistant.components.sensor.bbox @@ -722,14 +775,20 @@ pyblackbird==0.5 # homeassistant.components.device_tracker.bluetooth_tracker # pybluez==0.22 +# homeassistant.components.neato +pybotvac==0.0.9 + +# homeassistant.components.cloudflare +pycfdns==0.0.1 + # homeassistant.components.media_player.channels pychannels==1.0.0 -# homeassistant.components.media_player.cast +# homeassistant.components.cast pychromecast==2.1.0 # homeassistant.components.media_player.cmus -pycmus==0.1.0 +pycmus==0.1.1 # homeassistant.components.comfoconnect pycomfoconnect==0.3 @@ -745,7 +804,7 @@ pycsspeechtts==1.0.2 pydaikin==0.4 # homeassistant.components.deconz -pydeconz==37 +pydeconz==43 # homeassistant.components.zwave pydispatcher==2.0.5 @@ -753,6 +812,12 @@ pydispatcher==2.0.5 # homeassistant.components.android_ip_webcam pydroid-ipcam==0.8 +# homeassistant.components.sensor.duke_energy +pydukeenergy==0.0.6 + +# homeassistant.components.sensor.ebox +pyebox==1.1.4 + # homeassistant.components.climate.econet pyeconet==0.0.5 @@ -760,13 +825,13 @@ pyeconet==0.0.5 pyedimax==0.1 # homeassistant.components.eight_sleep -pyeight==0.0.8 +pyeight==0.0.9 # homeassistant.components.media_player.emby pyemby==1.5 # homeassistant.components.envisalink -pyenvisalink==2.2 +pyenvisalink==2.3 # homeassistant.components.climate.ephember pyephember==0.1.1 @@ -777,14 +842,26 @@ pyfido==2.1.1 # homeassistant.components.climate.flexit pyflexit==0.3 +# homeassistant.components.binary_sensor.flic +pyflic-homeassistant==0.4.dev0 + +# homeassistant.components.light.futurenow +pyfnip==0.2 + # homeassistant.components.fritzbox pyfritzhome==0.3.7 # homeassistant.components.ifttt pyfttt==0.3 +# homeassistant.components.sensor.skybeacon +pygatt==3.2.0 + # homeassistant.components.cover.gogogate2 -pygogogate2==0.0.7 +pygogogate2==0.1.1 + +# homeassistant.components.sensor.gtfs +pygtfs-homeassistant==0.1.3.dev0 # homeassistant.components.remote.harmony pyharmony==1.0.20 @@ -796,7 +873,7 @@ pyhik==0.1.8 pyhiveapi==0.2.14 # homeassistant.components.homematic -pyhomematic==0.1.42 +pyhomematic==0.1.46 # homeassistant.components.sensor.hydroquebec pyhydroquebec==2.2.2 @@ -807,6 +884,9 @@ pyialarm==0.2 # homeassistant.components.device_tracker.icloud pyicloud==0.9.1 +# homeassistant.components.weather.ipma +pyipma==1.1.3 + # homeassistant.components.sensor.irish_rail_transport pyirishrail==0.0.2 @@ -826,7 +906,10 @@ pykwb==0.0.8 pylacrosse==0.3.1 # homeassistant.components.sensor.lastfm -pylast==2.2.0 +pylast==2.4.0 + +# homeassistant.components.media_player.lg_netcast +pylgnetcast-homeassistant==0.2.0.dev0 # homeassistant.components.media_player.webostv # homeassistant.components.notify.webostv @@ -848,10 +931,10 @@ pylutron==0.1.0 pymailgunner==1.4 # homeassistant.components.media_player.mediaroom -pymediaroom==0.6.3 +pymediaroom==0.6.4 # homeassistant.components.media_player.xiaomi_tv -pymitv==1.0.0 +pymitv==1.4.0 # homeassistant.components.mochad pymochad==0.2.0 @@ -866,16 +949,16 @@ pymonoprice==0.3 pymusiccast==0.1.6 # homeassistant.components.cover.myq -pymyq==0.0.8 +pymyq==0.0.11 # homeassistant.components.mysensors -pymysensors==0.11.1 +pymysensors==0.17.0 # homeassistant.components.lock.nello pynello==1.5.1 # homeassistant.components.device_tracker.netgear -pynetgear==0.4.0 +pynetgear==0.4.1 # homeassistant.components.switch.netio pynetio==0.1.6 @@ -890,30 +973,36 @@ pynut2==2.1.2 # homeassistant.components.binary_sensor.nx584 pynx584==0.4 +# homeassistant.components.openuv +pyopenuv==1.0.1 + # homeassistant.components.iota -pyota==2.0.4 +pyota==2.0.5 # homeassistant.components.sensor.otp pyotp==2.2.6 # homeassistant.components.sensor.openweathermap # homeassistant.components.weather.openweathermap -pyowm==2.8.0 +pyowm==2.9.0 + +# homeassistant.components.media_player.pjlink +pypjlink2==1.2.0 # homeassistant.components.sensor.pollen -pypollencom==1.1.2 +pypollencom==2.1.0 # homeassistant.components.qwikswitch pyqwikswitch==0.8 # homeassistant.components.rainbird -pyrainbird==0.1.3 +pyrainbird==0.1.6 -# homeassistant.components.sensor.sabnzbd +# homeassistant.components.sabnzbd pysabnzbd==1.0.1 # homeassistant.components.climate.sensibo -pysensibo==1.0.2 +pysensibo==1.0.3 # homeassistant.components.sensor.serial pyserial-asyncio==0.4 @@ -933,7 +1022,7 @@ pysma==0.2 # homeassistant.components.device_tracker.snmp # homeassistant.components.sensor.snmp # homeassistant.components.switch.snmp -pysnmp==4.4.4 +pysnmp==4.4.5 # homeassistant.components.notify.stride pystride==0.1.7 @@ -966,6 +1055,9 @@ python-ecobee-api==0.0.18 # homeassistant.components.sensor.etherscan python-etherscan-api==0.0.3 +# homeassistant.components.camera.familyhub +python-family-hub-local==0.0.2 + # homeassistant.components.sensor.darksky # homeassistant.components.weather.darksky python-forecastio==1.4.0 @@ -993,17 +1085,17 @@ python-juicenet==0.0.5 # homeassistant.components.sensor.xiaomi_miio # homeassistant.components.switch.xiaomi_miio # homeassistant.components.vacuum.xiaomi_miio -python-miio==0.3.9 +python-miio==0.4.0 # homeassistant.components.media_player.mpd python-mpd2==1.0.0 # homeassistant.components.light.mystrom # homeassistant.components.switch.mystrom -python-mystrom==0.4.2 +python-mystrom==0.4.4 # homeassistant.components.nest -python-nest==3.7.0 +python-nest==4.0.3 # homeassistant.components.device_tracker.nmap_tracker python-nmap==0.6.1 @@ -1024,31 +1116,31 @@ python-sochain-api==0.0.2 python-songpal==0.0.7 # homeassistant.components.sensor.synologydsm -python-synology==0.1.0 +python-synology==0.2.0 # homeassistant.components.tado python-tado==0.2.3 # homeassistant.components.telegram_bot -python-telegram-bot==10.0.2 +python-telegram-bot==10.1.0 # homeassistant.components.sensor.twitch python-twitch==1.3.0 # homeassistant.components.velbus -python-velbus==2.0.11 +python-velbus==2.0.17 # homeassistant.components.media_player.vlc python-vlc==1.1.2 # homeassistant.components.wink -python-wink==1.7.3 +python-wink==1.9.1 # homeassistant.components.sensor.swiss_public_transport -python_opendata_transport==0.0.3 +python_opendata_transport==0.1.3 # homeassistant.components.zwave -python_openzwave==0.4.3 +python_openzwave==0.4.9 # homeassistant.components.egardia pythonegardia==1.0.39 @@ -1057,7 +1149,7 @@ pythonegardia==1.0.39 pythonwhois==2.4.3 # homeassistant.components.device_tracker.tile -pytile==1.1.0 +pytile==2.0.2 # homeassistant.components.climate.touchline pytouchline==0.7 @@ -1066,7 +1158,7 @@ pytouchline==0.7 pytrackr==0.0.5 # homeassistant.components.tradfri -pytradfri[async]==5.4.2 +pytradfri[async]==5.5.1 # homeassistant.components.device_tracker.unifi pyunifi==2.13 @@ -1074,11 +1166,14 @@ pyunifi==2.13 # homeassistant.components.upnp pyupnp-async==0.1.0.2 +# homeassistant.components.binary_sensor.uptimerobot +pyuptimerobot==0.0.5 + # homeassistant.components.keyboard # pyuserinput==0.1.11 # homeassistant.components.vera -pyvera==0.2.42 +pyvera==0.2.44 # homeassistant.components.switch.vesync pyvesync==0.1.1 @@ -1093,7 +1188,7 @@ pyvlx==0.1.3 pywebpush==1.6.0 # homeassistant.components.wemo -pywemo==0.4.25 +pywemo==0.4.28 # homeassistant.components.camera.xeoma pyxeoma==1.4.0 @@ -1104,29 +1199,32 @@ pyzabbix==0.7.4 # homeassistant.components.sensor.qnap qnapstats==0.2.6 -# homeassistant.components.switch.rachio -rachiopy==0.1.2 +# homeassistant.components.rachio +rachiopy==0.1.3 # homeassistant.components.climate.radiotherm -radiotherm==1.3 +radiotherm==1.4.1 # homeassistant.components.raincloud -raincloudy==0.0.4 +raincloudy==0.0.5 # homeassistant.components.raspihats # raspihats==2.2.3 # homeassistant.components.rainmachine -regenmaschine==0.4.1 +regenmaschine==1.0.2 # homeassistant.components.python_script -restrictedpython==4.0b3 +restrictedpython==4.0b4 # homeassistant.components.rflink rflink==0.0.37 # homeassistant.components.ring -ring_doorbell==0.1.8 +ring_doorbell==0.2.1 + +# homeassistant.components.device_tracker.ritassist +ritassist==0.5 # homeassistant.components.notify.rocketchat rocketchat-API==0.6.1 @@ -1159,26 +1257,29 @@ schiene==0.22 scsgate==0.1.0 # homeassistant.components.notify.sendgrid -sendgrid==5.3.0 +sendgrid==5.4.1 # homeassistant.components.light.sensehat # homeassistant.components.sensor.sensehat sense-hat==2.2.0 # homeassistant.components.sensor.sense -sense_energy==0.3.1 +sense_energy==0.4.1 # homeassistant.components.media_player.aquostv sharp_aquos_rc==0.3.2 # homeassistant.components.sensor.shodan -shodan==1.7.7 +shodan==1.9.0 # homeassistant.components.notify.simplepush simplepush==1.1.4 # homeassistant.components.alarm_control_panel.simplisafe -simplisafe-python==1.0.5 +simplisafe-python==2.0.2 + +# homeassistant.components.sisyphus +sisyphus-control==2.1 # homeassistant.components.skybell skybellpy==0.1.2 @@ -1193,7 +1294,7 @@ sleekxmpp==1.3.2 sleepyq==0.6 # homeassistant.components.smappee -smappy==0.2.15 +smappy==0.2.16 # homeassistant.components.raspihats # homeassistant.components.sensor.bh1750 @@ -1213,15 +1314,21 @@ socialbladeclient==0.2 somecomfort==0.5.2 # homeassistant.components.sensor.speedtest -speedtest-cli==2.0.0 +speedtest-cli==2.0.2 + +# homeassistant.components.spider +spiderpy==1.2.0 # homeassistant.components.sensor.spotcrime spotcrime==1.0.3 +# homeassistant.components.media_player.spotify +spotipy-homeassistant==2.4.4.dev1 + # homeassistant.components.recorder # homeassistant.scripts.db_migrator # homeassistant.components.sensor.sql -sqlalchemy==1.2.7 +sqlalchemy==1.2.10 # homeassistant.components.statsd statsd==3.2.1 @@ -1229,6 +1336,9 @@ statsd==3.2.1 # homeassistant.components.sensor.steam_online steamodd==4.21 +# homeassistant.components.camera.onvif +suds-passworddigest-homeassistant==0.1.2a0.dev0 + # homeassistant.components.camera.onvif suds-py3==1.3.3.0 @@ -1269,12 +1379,18 @@ todoist-python==7.0.17 toonlib==1.0.2 # homeassistant.components.alarm_control_panel.totalconnect -total_connect_client==0.17 +total_connect_client==0.18 + +# homeassistant.components.device_tracker.tplink +tplink==0.2.1 # homeassistant.components.sensor.transmission # homeassistant.components.switch.transmission transmissionrpc==0.11 +# homeassistant.components.tuya +tuyapy==0.1.3 + # homeassistant.components.twilio twilio==5.7.0 @@ -1297,7 +1413,7 @@ uvcclient==0.10.1 venstarcolortouch==0.6 # homeassistant.components.config.config_entries -voluptuous-serialize==1 +voluptuous-serialize==2.0.0 # homeassistant.components.volvooncall volvooncall==0.4.0 @@ -1336,6 +1452,9 @@ websocket-client==0.37.0 # homeassistant.components.media_player.webostv websockets==3.2 +# homeassistant.components.wirelesstag +wirelesstagpy==0.3.0 + # homeassistant.components.zigbee xbee-helper==0.0.7 @@ -1367,7 +1486,7 @@ yeelight==0.4.0 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2018.04.25 +youtube_dl==2018.08.04 # homeassistant.components.light.zengge zengge==0.2 @@ -1375,11 +1494,14 @@ zengge==0.2 # homeassistant.components.zeroconf zeroconf==0.20.0 +# homeassistant.components.climate.zhong_hong +zhong_hong_hvac==1.0.9 + # homeassistant.components.media_player.ziggo_mediabox_xl ziggo-mediabox-xl==1.0.0 # homeassistant.components.zha -zigpy-xbee==0.0.2 +zigpy-xbee==0.1.1 # homeassistant.components.zha -zigpy==0.0.3 +zigpy==0.1.0 diff --git a/requirements_docs.txt b/requirements_docs.txt index bb0d30462ce2da..a7436cad2fcf16 100644 --- a/requirements_docs.txt +++ b/requirements_docs.txt @@ -1,3 +1,3 @@ -Sphinx==1.7.1 -sphinx-autodoc-typehints==1.2.5 +Sphinx==1.7.6 +sphinx-autodoc-typehints==1.3.0 sphinx-autodoc-annotation==1.0.post1 diff --git a/requirements_test.txt b/requirements_test.txt index 6d5f68615befa6..5c2bd3404ed3b4 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -1,17 +1,17 @@ # linters such as flake8 and pylint should be pinned, as new releases # make new things fail. Manually update these pins when pulling in a # new version -asynctest>=0.11.1 +asynctest==0.12.2 coveralls==1.2.0 flake8-docstrings==1.0.3 flake8==3.5 mock-open==1.3.1 -mypy==0.590 +mypy==0.620 pydocstyle==1.1.1 -pylint==1.8.3 +pylint==2.1.1 pytest-aiohttp==0.3.0 pytest-cov==2.5.1 pytest-sugar==0.9.1 -pytest-timeout>=1.2.1 -pytest==3.4.2 -requests_mock==1.4 +pytest-timeout==1.3.1 +pytest==3.7.1 +requests_mock==1.5.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a25f36a8195171..7119259304e625 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -2,29 +2,29 @@ # linters such as flake8 and pylint should be pinned, as new releases # make new things fail. Manually update these pins when pulling in a # new version -asynctest>=0.11.1 +asynctest==0.12.2 coveralls==1.2.0 flake8-docstrings==1.0.3 flake8==3.5 mock-open==1.3.1 -mypy==0.590 +mypy==0.620 pydocstyle==1.1.1 -pylint==1.8.3 +pylint==2.1.1 pytest-aiohttp==0.3.0 pytest-cov==2.5.1 pytest-sugar==0.9.1 -pytest-timeout>=1.2.1 -pytest==3.4.2 -requests_mock==1.4 +pytest-timeout==1.3.1 +pytest==3.7.1 +requests_mock==1.5.2 # homeassistant.components.homekit -HAP-python==2.0.0 +HAP-python==2.2.2 -# homeassistant.components.notify.html5 -PyJWT==1.6.0 +# homeassistant.components.sensor.rmvtransport +PyRMVtransport==0.0.7 -# homeassistant.components.media_player.sonos +# homeassistant.components.sonos SoCo==0.14 # homeassistant.components.device_tracker.automatic @@ -35,7 +35,7 @@ aioautomatic==0.6.5 aiohttp_cors==0.7.0 # homeassistant.components.hue -aiohue==1.3.0 +aiohue==1.5.0 # homeassistant.components.notify.apns apns2==0.3.0 @@ -44,7 +44,7 @@ apns2==0.3.0 caldav==0.5.0 # homeassistant.components.sensor.coinmarketcap -coinmarketcap==4.2.1 +coinmarketcap==5.0.3 # homeassistant.components.device_tracker.upc_connect defusedxml==0.5.0 @@ -78,10 +78,13 @@ haversine==0.4.5 hbmqtt==0.9.2 # homeassistant.components.binary_sensor.workday -holidays==0.9.5 +holidays==0.9.6 # homeassistant.components.frontend -home-assistant-frontend==20180509.0 +home-assistant-frontend==20180820.0 + +# homeassistant.components.homematicip_cloud +homematicip==0.9.8 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb @@ -99,7 +102,7 @@ mficlient==0.3.0 # homeassistant.components.binary_sensor.trend # homeassistant.components.image_processing.opencv -numpy==1.14.3 +numpy==1.15.0 # homeassistant.components.mqtt # homeassistant.components.shiftr @@ -110,7 +113,7 @@ paho-mqtt==1.3.1 # homeassistant.components.device_tracker.cisco_ios # homeassistant.components.device_tracker.unifi_direct # homeassistant.components.media_player.pandora -pexpect==4.0.1 +pexpect==4.6.0 # homeassistant.components.pilight pilight==0.1.1 @@ -120,7 +123,7 @@ pilight==0.1.1 pmsensor==0.4 # homeassistant.components.prometheus -prometheus_client==0.1.0 +prometheus_client==0.2.0 # homeassistant.components.notify.pushbullet # homeassistant.components.sensor.pushbullet @@ -133,7 +136,7 @@ py-canary==0.5.0 pyblackbird==0.5 # homeassistant.components.deconz -pydeconz==37 +pydeconz==43 # homeassistant.components.zwave pydispatcher==2.0.5 @@ -155,9 +158,15 @@ pyqwikswitch==0.8 # homeassistant.components.weather.darksky python-forecastio==1.4.0 +# homeassistant.components.nest +python-nest==4.0.3 + # homeassistant.components.sensor.whois pythonwhois==2.4.3 +# homeassistant.components.tradfri +pytradfri[async]==5.5.1 + # homeassistant.components.device_tracker.unifi pyunifi==2.13 @@ -168,13 +177,13 @@ pyupnp-async==0.1.0.2 pywebpush==1.6.0 # homeassistant.components.python_script -restrictedpython==4.0b3 +restrictedpython==4.0b4 # homeassistant.components.rflink rflink==0.0.37 # homeassistant.components.ring -ring_doorbell==0.1.8 +ring_doorbell==0.2.1 # homeassistant.components.media_player.yamaha rxv==0.5.1 @@ -188,7 +197,7 @@ somecomfort==0.5.2 # homeassistant.components.recorder # homeassistant.scripts.db_migrator # homeassistant.components.sensor.sql -sqlalchemy==1.2.7 +sqlalchemy==1.2.10 # homeassistant.components.statsd statsd==3.2.1 @@ -197,7 +206,7 @@ statsd==3.2.1 uvcclient==0.10.1 # homeassistant.components.config.config_entries -voluptuous-serialize==1 +voluptuous-serialize==2.0.0 # homeassistant.components.vultr vultr==0.1.2 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index b5b636dc8745d1..7652d29086b227 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -11,7 +11,7 @@ 'RPi.GPIO', 'raspihats', 'rpi-rf', - 'Adafruit_Python_DHT', + 'Adafruit-DHT', 'Adafruit_BBIO', 'fritzconnection', 'pybluez', @@ -56,6 +56,7 @@ 'hbmqtt', 'holidays', 'home-assistant-frontend', + 'homematicip', 'influxdb', 'libpurecoollink', 'libsoundtouch', @@ -76,7 +77,10 @@ 'pymonoprice', 'pynx584', 'pyqwikswitch', + 'PyRMVtransport', 'python-forecastio', + 'python-nest', + 'pytradfri\[async\]', 'pyunifi', 'pyupnp-async', 'pywebpush', @@ -179,6 +183,10 @@ def gather_modules(): for req in module.REQUIREMENTS: if req in IGNORE_REQ: continue + if '://' in req: + errors.append( + "{}[Only pypi dependencies are allowed: {}]".format( + package, req)) if req.partition('==')[1] == '' and req not in IGNORE_PIN: errors.append( "{}[Please pin requirement {}, see {}]".format( @@ -254,7 +262,7 @@ def write_requirements_file(data): def write_test_requirements_file(data): - """Write the modules to the requirements_all.txt.""" + """Write the modules to the requirements_test_all.txt.""" with open('requirements_test_all.txt', 'w+', newline="\n") as req_file: req_file.write(data) @@ -272,7 +280,7 @@ def validate_requirements_file(data): def validate_requirements_test_file(data): - """Validate if requirements_all.txt is up to date.""" + """Validate if requirements_test_all.txt is up to date.""" with open('requirements_test_all.txt', 'r') as req_file: return data == req_file.read() diff --git a/script/lazytox.py b/script/lazytox.py index 19af5560dfb132..f0388a0fdcbb47 100755 --- a/script/lazytox.py +++ b/script/lazytox.py @@ -39,7 +39,6 @@ def printc(the_color, *args): def validate_requirements_ok(): """Validate requirements, returns True of ok.""" - # pylint: disable=E0402 from gen_requirements_all import main as req_main return req_main(True) == 0 @@ -70,7 +69,6 @@ async def async_exec(*args, display=False): 'stderr': asyncio.subprocess.STDOUT} if display: kwargs['stderr'] = asyncio.subprocess.PIPE - # pylint: disable=E1120 proc = await asyncio.create_subprocess_exec(*args, **kwargs) except FileNotFoundError as err: printc(FAIL, "Could not execute {}. Did you install test requirements?" diff --git a/script/lint b/script/lint index dc6884f4882308..8ba14d8939ef87 100755 --- a/script/lint +++ b/script/lint @@ -8,7 +8,7 @@ echo '=================================================' echo '= FILES CHANGED =' echo '=================================================' if [ -z "$files" ] ; then - echo "No python file changed. Rather use: tox -e lint" + echo "No python file changed. Rather use: tox -e lint\n" exit fi printf "%s\n" $files @@ -19,5 +19,10 @@ flake8 --doctests $files echo "================" echo "LINT with pylint" echo "================" -pylint $(echo "$files" | grep -v '^tests.*') +pylint_files=$(echo "$files" | grep -v '^tests.*') +if [ -z "$pylint_files" ] ; then + echo "Only test files changed. Skipping\n" + exit +fi +pylint $pylint_files echo diff --git a/script/monkeytype b/script/monkeytype new file mode 100755 index 00000000000000..dc1894c91edea2 --- /dev/null +++ b/script/monkeytype @@ -0,0 +1,25 @@ +#!/bin/sh +# Run monkeytype on test suite or optionally on a test module or directory. + +# Stop on errors +set -e + +cd "$(dirname "$0")/.." + +command -v pytest >/dev/null 2>&1 || { + echo >&2 "This script requires pytest but it's not installed." \ + "Aborting. Try: pip install pytest"; exit 1; } + +command -v monkeytype >/dev/null 2>&1 || { + echo >&2 "This script requires monkeytype but it's not installed." \ + "Aborting. Try: pip install monkeytype"; exit 1; } + +if [ $# -eq 0 ] + then + echo "Run monkeytype on test suite" + monkeytype run "`command -v pytest`" + exit +fi + +echo "Run monkeytype on tests in $1" +monkeytype run "`command -v pytest`" "$1" diff --git a/script/release b/script/release index dc3e208bc1a495..cf4f808377ed35 100755 --- a/script/release +++ b/script/release @@ -27,5 +27,6 @@ then exit 1 fi +rm -rf dist python3 setup.py sdist bdist_wheel python3 -m twine upload dist/* --skip-existing diff --git a/script/translations_download b/script/translations_download index 099e32c9d1b164..15b6a6810563d9 100755 --- a/script/translations_download +++ b/script/translations_download @@ -28,7 +28,7 @@ mkdir -p ${LOCAL_DIR} docker run \ -v ${LOCAL_DIR}:/opt/dest/locale \ - lokalise/lokalise-cli@sha256:79b3108211ed1fcc9f7b09a011bfc53c240fc2f3b7fa7f0c8390f593271b4cd7 lokalise \ + lokalise/lokalise-cli@sha256:ddf5677f58551261008342df5849731c88bcdc152ab645b133b21819aede8218 lokalise \ --token ${LOKALISE_TOKEN} \ export ${PROJECT_ID} \ --export_empty skip \ diff --git a/script/translations_upload_merge.py b/script/translations_upload_merge.py index 450a4c9ba0fb64..ce0a14c85e6fa4 100755 --- a/script/translations_upload_merge.py +++ b/script/translations_upload_merge.py @@ -57,7 +57,7 @@ def get_translation_dict(translations, component, platform): if not component: return translations['component'] - if component not in translations: + if component not in translations['component']: translations['component'][component] = {} if not platform: diff --git a/script/version_bump.py b/script/version_bump.py index 59060a7075b024..e324b231d0667d 100755 --- a/script/version_bump.py +++ b/script/version_bump.py @@ -2,6 +2,7 @@ """Helper script to bump the current version.""" import argparse import re +import subprocess from packaging.version import Version @@ -117,12 +118,21 @@ def main(): help="The type of the bump the version to.", choices=['beta', 'dev', 'patch', 'minor'], ) + parser.add_argument( + '--commit', action='store_true', + help='Create a version bump commit.') arguments = parser.parse_args() current = Version(const.__version__) bumped = bump_version(current, arguments.type) assert bumped > current, 'BUG! New version is not newer than old version' write_version(bumped) + if not arguments.commit: + return + + subprocess.run([ + 'git', 'commit', '-am', 'Bumped version to {}'.format(bumped)]) + def test_bump_version(): """Make sure it all works.""" diff --git a/setup.cfg b/setup.cfg index d6dfdfe0ea5645..7813cc5c0472ea 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,5 +1,19 @@ -[wheel] -universal = 1 +[metadata] +license = Apache License 2.0 +license_file = LICENSE.md +platforms = any +description = Open-source home automation platform running on Python 3. +long_description = file: README.rst +keywords = home, automation +classifier = + Development Status :: 4 - Beta + Intended Audience :: End Users/Desktop + Intended Audience :: Developers + License :: OSI Approved :: Apache Software License + Operating System :: OS Independent + Programming Language :: Python :: 3.5 + Programming Language :: Python :: 3.6 + Topic :: Home Automation [tool:pytest] testpaths = tests diff --git a/setup.py b/setup.py index 8a68617afd9846..7484dc286e62ee 100755 --- a/setup.py +++ b/setup.py @@ -1,5 +1,6 @@ #!/usr/bin/env python3 """Home Assistant setup script.""" +from datetime import datetime as dt from setuptools import setup, find_packages import homeassistant.const as hass_const @@ -8,26 +9,9 @@ PROJECT_PACKAGE_NAME = 'homeassistant' PROJECT_LICENSE = 'Apache License 2.0' PROJECT_AUTHOR = 'The Home Assistant Authors' -PROJECT_COPYRIGHT = ' 2013-2018, {}'.format(PROJECT_AUTHOR) +PROJECT_COPYRIGHT = ' 2013-{}, {}'.format(dt.now().year, PROJECT_AUTHOR) PROJECT_URL = 'https://home-assistant.io/' PROJECT_EMAIL = 'hello@home-assistant.io' -PROJECT_DESCRIPTION = ('Open-source home automation platform ' - 'running on Python 3.') -PROJECT_LONG_DESCRIPTION = ('Home Assistant is an open-source ' - 'home automation platform running on Python 3. ' - 'Track and control all devices at home and ' - 'automate control. ' - 'Installation in less than a minute.') -PROJECT_CLASSIFIERS = [ - 'Development Status :: 4 - Beta', - 'Intended Audience :: End Users/Desktop', - 'Intended Audience :: Developers', - 'License :: OSI Approved :: Apache Software License', - 'Operating System :: OS Independent', - 'Programming Language :: Python :: 3.5', - 'Programming Language :: Python :: 3.6', - 'Topic :: Home Automation' -] PROJECT_GITHUB_USERNAME = 'home-assistant' PROJECT_GITHUB_REPOSITORY = 'home-assistant' @@ -38,22 +22,30 @@ GITHUB_URL = 'https://github.com/{}'.format(GITHUB_PATH) DOWNLOAD_URL = '{}/archive/{}.zip'.format(GITHUB_URL, hass_const.__version__) +PROJECT_URLS = { + 'Bug Reports': '{}/issues'.format(GITHUB_URL), + 'Dev Docs': 'https://developers.home-assistant.io/', + 'Discord': 'https://discordapp.com/invite/c5DvZ4e', + 'Forum': 'https://community.home-assistant.io/', +} PACKAGES = find_packages(exclude=['tests', 'tests.*']) REQUIRES = [ - 'requests==2.18.4', - 'pyyaml>=3.11,<4', - 'pytz>=2017.02', - 'pip>=8.0.3', - 'jinja2>=2.10', - 'voluptuous==0.11.1', - 'typing>=3,<4', - 'aiohttp==3.1.3', - 'async_timeout==2.0.1', + 'aiohttp==3.3.2', 'astral==1.6.1', - 'certifi>=2017.4.17', + 'async_timeout==3.0.0', 'attrs==18.1.0', + 'certifi>=2018.04.16', + 'jinja2>=2.10', + 'PyJWT==1.6.4', + # PyJWT has loose dependency. We want the latest one. + 'cryptography==2.3.1', + 'pip>=8.0.3', + 'pytz>=2018.04', + 'pyyaml>=3.13,<4', + 'requests==2.19.1', + 'voluptuous==0.11.5', ] MIN_PY_VERSION = '.'.join(map(str, hass_const.REQUIRED_PYTHON_VER)) @@ -61,24 +53,20 @@ setup( name=PROJECT_PACKAGE_NAME, version=hass_const.__version__, - license=PROJECT_LICENSE, url=PROJECT_URL, download_url=DOWNLOAD_URL, + project_urls=PROJECT_URLS, author=PROJECT_AUTHOR, author_email=PROJECT_EMAIL, - description=PROJECT_DESCRIPTION, packages=PACKAGES, include_package_data=True, zip_safe=False, - platforms='any', install_requires=REQUIRES, python_requires='>={}'.format(MIN_PY_VERSION), test_suite='tests', - keywords=['home', 'automation'], entry_points={ 'console_scripts': [ 'hass = homeassistant.__main__:main' ] }, - classifiers=PROJECT_CLASSIFIERS, ) diff --git a/tests/auth/__init__.py b/tests/auth/__init__.py new file mode 100644 index 00000000000000..48a99324b304e2 --- /dev/null +++ b/tests/auth/__init__.py @@ -0,0 +1 @@ +"""Tests for the Home Assistant auth module.""" diff --git a/tests/auth_providers/__init__.py b/tests/auth/providers/__init__.py similarity index 100% rename from tests/auth_providers/__init__.py rename to tests/auth/providers/__init__.py diff --git a/tests/auth/providers/test_homeassistant.py b/tests/auth/providers/test_homeassistant.py new file mode 100644 index 00000000000000..9db6293d98a14f --- /dev/null +++ b/tests/auth/providers/test_homeassistant.py @@ -0,0 +1,132 @@ +"""Test the Home Assistant local auth provider.""" +from unittest.mock import Mock + +import pytest + +from homeassistant import data_entry_flow +from homeassistant.auth import auth_manager_from_config +from homeassistant.auth.providers import ( + auth_provider_from_config, homeassistant as hass_auth) + + +@pytest.fixture +def data(hass): + """Create a loaded data class.""" + data = hass_auth.Data(hass) + hass.loop.run_until_complete(data.async_load()) + return data + + +async def test_adding_user(data, hass): + """Test adding a user.""" + data.add_auth('test-user', 'test-pass') + data.validate_login('test-user', 'test-pass') + + +async def test_adding_user_duplicate_username(data, hass): + """Test adding a user.""" + data.add_auth('test-user', 'test-pass') + with pytest.raises(hass_auth.InvalidUser): + data.add_auth('test-user', 'other-pass') + + +async def test_validating_password_invalid_user(data, hass): + """Test validating an invalid user.""" + with pytest.raises(hass_auth.InvalidAuth): + data.validate_login('non-existing', 'pw') + + +async def test_validating_password_invalid_password(data, hass): + """Test validating an invalid user.""" + data.add_auth('test-user', 'test-pass') + + with pytest.raises(hass_auth.InvalidAuth): + data.validate_login('test-user', 'invalid-pass') + + +async def test_changing_password(data, hass): + """Test adding a user.""" + user = 'test-user' + data.add_auth(user, 'test-pass') + data.change_password(user, 'new-pass') + + with pytest.raises(hass_auth.InvalidAuth): + data.validate_login(user, 'test-pass') + + data.validate_login(user, 'new-pass') + + +async def test_changing_password_raises_invalid_user(data, hass): + """Test that we initialize an empty config.""" + with pytest.raises(hass_auth.InvalidUser): + data.change_password('non-existing', 'pw') + + +async def test_login_flow_validates(data, hass): + """Test login flow.""" + data.add_auth('test-user', 'test-pass') + await data.async_save() + + provider = hass_auth.HassAuthProvider(hass, None, {}) + flow = hass_auth.LoginFlow(provider) + result = await flow.async_step_init() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + + result = await flow.async_step_init({ + 'username': 'incorrect-user', + 'password': 'test-pass', + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['errors']['base'] == 'invalid_auth' + + result = await flow.async_step_init({ + 'username': 'test-user', + 'password': 'incorrect-pass', + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['errors']['base'] == 'invalid_auth' + + result = await flow.async_step_init({ + 'username': 'test-user', + 'password': 'test-pass', + }) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + +async def test_saving_loading(data, hass): + """Test saving and loading JSON.""" + data.add_auth('test-user', 'test-pass') + data.add_auth('second-user', 'second-pass') + await data.async_save() + + data = hass_auth.Data(hass) + await data.async_load() + data.validate_login('test-user', 'test-pass') + data.validate_login('second-user', 'second-pass') + + +async def test_not_allow_set_id(): + """Test we are not allowed to set an ID in config.""" + hass = Mock() + provider = await auth_provider_from_config(hass, None, { + 'type': 'homeassistant', + 'id': 'invalid', + }) + assert provider is None + + +async def test_new_users_populate_values(hass, data): + """Test that we populate data for new users.""" + data.add_auth('hello', 'test-pass') + await data.async_save() + + manager = await auth_manager_from_config(hass, [{ + 'type': 'homeassistant' + }]) + provider = manager.auth_providers[0] + credentials = await provider.async_get_or_create_credentials({ + 'username': 'hello' + }) + user = await manager.async_get_or_create_user(credentials) + assert user.name == 'hello' + assert user.is_active diff --git a/tests/auth_providers/test_insecure_example.py b/tests/auth/providers/test_insecure_example.py similarity index 60% rename from tests/auth_providers/test_insecure_example.py rename to tests/auth/providers/test_insecure_example.py index 92fc2974e272c0..b472e4c95df3b4 100644 --- a/tests/auth_providers/test_insecure_example.py +++ b/tests/auth/providers/test_insecure_example.py @@ -4,25 +4,26 @@ import pytest -from homeassistant import auth -from homeassistant.auth_providers import insecure_example +from homeassistant.auth import auth_store, models as auth_models, AuthManager +from homeassistant.auth.providers import insecure_example from tests.common import mock_coro @pytest.fixture -def store(): +def store(hass): """Mock store.""" - return auth.AuthStore(Mock()) + return auth_store.AuthStore(hass) @pytest.fixture -def provider(store): +def provider(hass, store): """Mock provider.""" - return insecure_example.ExampleAuthProvider(store, { + return insecure_example.ExampleAuthProvider(hass, store, { 'type': 'insecure_example', 'users': [ { + 'name': 'Test Name', 'username': 'user-test', 'password': 'password-test', }, @@ -34,7 +35,15 @@ def provider(store): }) -async def test_create_new_credential(provider): +@pytest.fixture +def manager(hass, store, provider): + """Mock manager.""" + return AuthManager(hass, store, { + (provider.type, provider.id): provider + }) + + +async def test_create_new_credential(manager, provider): """Test that we create a new credential.""" credentials = await provider.async_get_or_create_credentials({ 'username': 'user-test', @@ -42,10 +51,14 @@ async def test_create_new_credential(provider): }) assert credentials.is_new is True + user = await manager.async_get_or_create_user(credentials) + assert user.name == 'Test Name' + assert user.is_active + async def test_match_existing_credentials(store, provider): """See if we match existing users.""" - existing = auth.Credentials( + existing = auth_models.Credentials( id=uuid.uuid4(), auth_provider_type='insecure_example', auth_provider_id=None, @@ -54,7 +67,7 @@ async def test_match_existing_credentials(store, provider): }, is_new=False, ) - store.credentials_for_provider = Mock(return_value=mock_coro([existing])) + provider.async_credentials = Mock(return_value=mock_coro([existing])) credentials = await provider.async_get_or_create_credentials({ 'username': 'user-test', 'password': 'password-test', @@ -64,20 +77,16 @@ async def test_match_existing_credentials(store, provider): async def test_verify_username(provider): """Test we raise if incorrect user specified.""" - with pytest.raises(auth.InvalidUser): - await provider.async_get_or_create_credentials({ - 'username': 'non-existing-user', - 'password': 'password-test', - }) + with pytest.raises(insecure_example.InvalidAuthError): + await provider.async_validate_login( + 'non-existing-user', 'password-test') async def test_verify_password(provider): """Test we raise if incorrect user specified.""" - with pytest.raises(auth.InvalidPassword): - await provider.async_get_or_create_credentials({ - 'username': 'user-test', - 'password': 'incorrect-password', - }) + with pytest.raises(insecure_example.InvalidAuthError): + await provider.async_validate_login( + 'user-test', 'incorrect-password') async def test_utf_8_username_password(provider): diff --git a/tests/auth/providers/test_legacy_api_password.py b/tests/auth/providers/test_legacy_api_password.py new file mode 100644 index 00000000000000..71642bd7a32c26 --- /dev/null +++ b/tests/auth/providers/test_legacy_api_password.py @@ -0,0 +1,80 @@ +"""Tests for the legacy_api_password auth provider.""" +from unittest.mock import Mock + +import pytest + +from homeassistant import auth +from homeassistant.auth import auth_store +from homeassistant.auth.providers import legacy_api_password + + +@pytest.fixture +def store(hass): + """Mock store.""" + return auth_store.AuthStore(hass) + + +@pytest.fixture +def provider(hass, store): + """Mock provider.""" + return legacy_api_password.LegacyApiPasswordAuthProvider(hass, store, { + 'type': 'legacy_api_password', + }) + + +@pytest.fixture +def manager(hass, store, provider): + """Mock manager.""" + return auth.AuthManager(hass, store, { + (provider.type, provider.id): provider + }) + + +async def test_create_new_credential(manager, provider): + """Test that we create a new credential.""" + credentials = await provider.async_get_or_create_credentials({}) + assert credentials.data["username"] is legacy_api_password.LEGACY_USER + assert credentials.is_new is True + + user = await manager.async_get_or_create_user(credentials) + assert user.name == legacy_api_password.LEGACY_USER + assert user.is_active + + +async def test_only_one_credentials(manager, provider): + """Call create twice will return same credential.""" + credentials = await provider.async_get_or_create_credentials({}) + await manager.async_get_or_create_user(credentials) + credentials2 = await provider.async_get_or_create_credentials({}) + assert credentials2.data["username"] == legacy_api_password.LEGACY_USER + assert credentials2.id == credentials.id + assert credentials2.is_new is False + + +async def test_verify_not_load(hass, provider): + """Test we raise if http module not load.""" + with pytest.raises(ValueError): + provider.async_validate_login('test-password') + hass.http = Mock(api_password=None) + with pytest.raises(ValueError): + provider.async_validate_login('test-password') + hass.http = Mock(api_password='test-password') + provider.async_validate_login('test-password') + + +async def test_verify_login(hass, provider): + """Test we raise if http module not load.""" + hass.http = Mock(api_password='test-password') + provider.async_validate_login('test-password') + hass.http = Mock(api_password='test-password') + with pytest.raises(legacy_api_password.InvalidAuthError): + provider.async_validate_login('invalid-password') + + +async def test_utf_8_username_password(provider): + """Test that we create a new credential.""" + credentials = await provider.async_get_or_create_credentials({ + 'username': '🎉', + 'password': '😎', + }) + assert credentials.is_new is True diff --git a/tests/auth/test_init.py b/tests/auth/test_init.py new file mode 100644 index 00000000000000..da5daca7cf63c5 --- /dev/null +++ b/tests/auth/test_init.py @@ -0,0 +1,283 @@ +"""Tests for the Home Assistant auth module.""" +from datetime import timedelta +from unittest.mock import Mock, patch + +import pytest + +from homeassistant import auth, data_entry_flow +from homeassistant.auth import ( + models as auth_models, auth_store, const as auth_const) +from homeassistant.util import dt as dt_util +from tests.common import ( + MockUser, ensure_auth_manager_loaded, flush_store, CLIENT_ID) + + +@pytest.fixture +def mock_hass(loop): + """Hass mock with minimum amount of data set to make it work with auth.""" + hass = Mock() + hass.config.skip_pip = True + return hass + + +async def test_auth_manager_from_config_validates_config_and_id(mock_hass): + """Test get auth providers.""" + manager = await auth.auth_manager_from_config(mock_hass, [{ + 'name': 'Test Name', + 'type': 'insecure_example', + 'users': [], + }, { + 'name': 'Invalid config because no users', + 'type': 'insecure_example', + 'id': 'invalid_config', + }, { + 'name': 'Test Name 2', + 'type': 'insecure_example', + 'id': 'another', + 'users': [], + }, { + 'name': 'Wrong because duplicate ID', + 'type': 'insecure_example', + 'id': 'another', + 'users': [], + }]) + + providers = [{ + 'name': provider.name, + 'id': provider.id, + 'type': provider.type, + } for provider in manager.auth_providers] + assert providers == [{ + 'name': 'Test Name', + 'type': 'insecure_example', + 'id': None, + }, { + 'name': 'Test Name 2', + 'type': 'insecure_example', + 'id': 'another', + }] + + +async def test_create_new_user(hass, hass_storage): + """Test creating new user.""" + manager = await auth.auth_manager_from_config(hass, [{ + 'type': 'insecure_example', + 'users': [{ + 'username': 'test-user', + 'password': 'test-pass', + 'name': 'Test Name' + }] + }]) + + step = await manager.login_flow.async_init(('insecure_example', None)) + assert step['type'] == data_entry_flow.RESULT_TYPE_FORM + + step = await manager.login_flow.async_configure(step['flow_id'], { + 'username': 'test-user', + 'password': 'test-pass', + }) + assert step['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + credentials = step['result'] + user = await manager.async_get_or_create_user(credentials) + assert user is not None + assert user.is_owner is False + assert user.name == 'Test Name' + + +async def test_login_as_existing_user(mock_hass): + """Test login as existing user.""" + manager = await auth.auth_manager_from_config(mock_hass, [{ + 'type': 'insecure_example', + 'users': [{ + 'username': 'test-user', + 'password': 'test-pass', + 'name': 'Test Name' + }] + }]) + ensure_auth_manager_loaded(manager) + + # Add a fake user that we're not going to log in with + user = MockUser( + id='mock-user2', + is_owner=False, + is_active=False, + name='Not user', + ).add_to_auth_manager(manager) + user.credentials.append(auth_models.Credentials( + id='mock-id2', + auth_provider_type='insecure_example', + auth_provider_id=None, + data={'username': 'other-user'}, + is_new=False, + )) + + # Add fake user with credentials for example auth provider. + user = MockUser( + id='mock-user', + is_owner=False, + is_active=False, + name='Paulus', + ).add_to_auth_manager(manager) + user.credentials.append(auth_models.Credentials( + id='mock-id', + auth_provider_type='insecure_example', + auth_provider_id=None, + data={'username': 'test-user'}, + is_new=False, + )) + + step = await manager.login_flow.async_init(('insecure_example', None)) + assert step['type'] == data_entry_flow.RESULT_TYPE_FORM + + step = await manager.login_flow.async_configure(step['flow_id'], { + 'username': 'test-user', + 'password': 'test-pass', + }) + assert step['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + credentials = step['result'] + + user = await manager.async_get_or_create_user(credentials) + assert user is not None + assert user.id == 'mock-user' + assert user.is_owner is False + assert user.is_active is False + assert user.name == 'Paulus' + + +async def test_linking_user_to_two_auth_providers(hass, hass_storage): + """Test linking user to two auth providers.""" + manager = await auth.auth_manager_from_config(hass, [{ + 'type': 'insecure_example', + 'users': [{ + 'username': 'test-user', + 'password': 'test-pass', + }] + }, { + 'type': 'insecure_example', + 'id': 'another-provider', + 'users': [{ + 'username': 'another-user', + 'password': 'another-password', + }] + }]) + + step = await manager.login_flow.async_init(('insecure_example', None)) + step = await manager.login_flow.async_configure(step['flow_id'], { + 'username': 'test-user', + 'password': 'test-pass', + }) + user = await manager.async_get_or_create_user(step['result']) + assert user is not None + + step = await manager.login_flow.async_init(('insecure_example', + 'another-provider')) + step = await manager.login_flow.async_configure(step['flow_id'], { + 'username': 'another-user', + 'password': 'another-password', + }) + await manager.async_link_user(user, step['result']) + assert len(user.credentials) == 2 + + +async def test_saving_loading(hass, hass_storage): + """Test storing and saving data. + + Creates one of each type that we store to test we restore correctly. + """ + manager = await auth.auth_manager_from_config(hass, [{ + 'type': 'insecure_example', + 'users': [{ + 'username': 'test-user', + 'password': 'test-pass', + }] + }]) + + step = await manager.login_flow.async_init(('insecure_example', None)) + step = await manager.login_flow.async_configure(step['flow_id'], { + 'username': 'test-user', + 'password': 'test-pass', + }) + user = await manager.async_get_or_create_user(step['result']) + await manager.async_activate_user(user) + await manager.async_create_refresh_token(user, CLIENT_ID) + + await flush_store(manager._store._store) + + store2 = auth_store.AuthStore(hass) + users = await store2.async_get_users() + assert len(users) == 1 + assert users[0] == user + + +async def test_cannot_retrieve_expired_access_token(hass): + """Test that we cannot retrieve expired access tokens.""" + manager = await auth.auth_manager_from_config(hass, []) + user = MockUser().add_to_auth_manager(manager) + refresh_token = await manager.async_create_refresh_token(user, CLIENT_ID) + assert refresh_token.user.id is user.id + assert refresh_token.client_id == CLIENT_ID + + access_token = manager.async_create_access_token(refresh_token) + assert ( + await manager.async_validate_access_token(access_token) + is refresh_token + ) + + with patch('homeassistant.util.dt.utcnow', + return_value=dt_util.utcnow() - + auth_const.ACCESS_TOKEN_EXPIRATION - timedelta(seconds=11)): + access_token = manager.async_create_access_token(refresh_token) + + assert ( + await manager.async_validate_access_token(access_token) + is None + ) + + +async def test_generating_system_user(hass): + """Test that we can add a system user.""" + manager = await auth.auth_manager_from_config(hass, []) + user = await manager.async_create_system_user('Hass.io') + token = await manager.async_create_refresh_token(user) + assert user.system_generated + assert token is not None + assert token.client_id is None + + +async def test_refresh_token_requires_client_for_user(hass): + """Test that we can add a system user.""" + manager = await auth.auth_manager_from_config(hass, []) + user = MockUser().add_to_auth_manager(manager) + assert user.system_generated is False + + with pytest.raises(ValueError): + await manager.async_create_refresh_token(user) + + token = await manager.async_create_refresh_token(user, CLIENT_ID) + assert token is not None + assert token.client_id == CLIENT_ID + + +async def test_refresh_token_not_requires_client_for_system_user(hass): + """Test that we can add a system user.""" + manager = await auth.auth_manager_from_config(hass, []) + user = await manager.async_create_system_user('Hass.io') + assert user.system_generated is True + + with pytest.raises(ValueError): + await manager.async_create_refresh_token(user, CLIENT_ID) + + token = await manager.async_create_refresh_token(user) + assert token is not None + assert token.client_id is None + + +async def test_cannot_deactive_owner(mock_hass): + """Test that we cannot deactive the owner.""" + manager = await auth.auth_manager_from_config(mock_hass, []) + owner = MockUser( + is_owner=True, + ).add_to_auth_manager(manager) + + with pytest.raises(ValueError): + await manager.async_deactivate_user(owner) diff --git a/tests/common.py b/tests/common.py index f53d1c2be2ba51..81e4774ccd4779 100644 --- a/tests/common.py +++ b/tests/common.py @@ -1,7 +1,9 @@ """Test the helper method for writing tests.""" import asyncio +from collections import OrderedDict from datetime import timedelta import functools as ft +import json import os import sys from unittest.mock import patch, MagicMock, Mock @@ -10,12 +12,14 @@ import threading from contextlib import contextmanager -from homeassistant import auth, core as ha, data_entry_flow, config_entries +from homeassistant import auth, core as ha, config_entries +from homeassistant.auth import ( + models as auth_models, auth_store, providers as auth_providers) from homeassistant.setup import setup_component, async_setup_component from homeassistant.config import async_process_component_config from homeassistant.helpers import ( - intent, entity, restore_state, entity_registry, - entity_platform) + intent, entity, restore_state, entity_registry, + entity_platform, storage) from homeassistant.util.unit_system import METRIC_SYSTEM import homeassistant.util.dt as date_util import homeassistant.util.yaml as yaml @@ -30,6 +34,8 @@ _TEST_INSTANCE_PORT = SERVER_PORT _LOGGER = logging.getLogger(__name__) INSTANCES = [] +CLIENT_ID = 'https://example.com/app' +CLIENT_REDIRECT_URI = 'https://example.com/app/callback' def threadsafe_callback_factory(func): @@ -110,10 +116,8 @@ def stop_hass(): def async_test_home_assistant(loop): """Return a Home Assistant object pointing at test config dir.""" hass = ha.HomeAssistant(loop) - hass.config_entries = config_entries.ConfigEntries(hass, {}) - hass.config_entries._entries = [] hass.config.async_load = Mock() - store = auth.AuthStore(hass) + store = auth_store.AuthStore(hass) hass.auth = auth.AuthManager(hass, store, {}) ensure_auth_manager_loaded(hass.auth) INSTANCES.append(hass) @@ -137,6 +141,10 @@ def async_add_job(target, *args): hass.config.units = METRIC_SYSTEM hass.config.skip_pip = True + hass.config_entries = config_entries.ConfigEntries(hass, {}) + hass.config_entries._entries = [] + hass.config_entries._store._async_ensure_stop_listener = lambda: None + hass.state = ha.CoreState.running # Mock async_start @@ -179,7 +187,7 @@ def async_mock_service(hass, domain, service, schema=None): """Set up a fake service & return a calls log list to this service.""" calls = [] - @asyncio.coroutine + @ha.callback def mock_service_log(call): # pylint: disable=unnecessary-lambda """Mock service call.""" calls.append(call) @@ -258,7 +266,7 @@ def mock_state_change_event(hass, new_state, old_state=None): if old_state: event_data['old_state'] = old_state - hass.bus.fire(EVENT_STATE_CHANGED, event_data) + hass.bus.fire(EVENT_STATE_CHANGED, event_data, context=new_state.context) @asyncio.coroutine @@ -303,13 +311,21 @@ def mock_registry(hass, mock_entries=None): return registry -class MockUser(auth.User): +class MockUser(auth_models.User): """Mock a user in Home Assistant.""" - def __init__(self, id='mock-id', is_owner=True, is_active=True, - name='Mock User'): + def __init__(self, id=None, is_owner=False, is_active=True, + name='Mock User', system_generated=False): """Initialize mock user.""" - super().__init__(id, is_owner, is_active, name) + kwargs = { + 'is_owner': is_owner, + 'is_active': is_active, + 'name': name, + 'system_generated': system_generated + } + if id is not None: + kwargs['id'] = id + super().__init__(**kwargs) def add_to_hass(self, hass): """Test helper to add entry to hass.""" @@ -317,21 +333,35 @@ def add_to_hass(self, hass): def add_to_auth_manager(self, auth_mgr): """Test helper to add entry to hass.""" - auth_mgr._store.users[self.id] = self + ensure_auth_manager_loaded(auth_mgr) + auth_mgr._store._users[self.id] = self return self +async def register_auth_provider(hass, config): + """Helper to register an auth provider.""" + provider = await auth_providers.auth_provider_from_config( + hass, hass.auth._store, config) + assert provider is not None, 'Invalid config specified' + key = (provider.type, provider.id) + providers = hass.auth._providers + + if key in providers: + raise ValueError('Provider already registered') + + providers[key] = provider + return provider + + @ha.callback def ensure_auth_manager_loaded(auth_mgr): """Ensure an auth manager is considered loaded.""" store = auth_mgr._store - if store.clients is None: - store.clients = {} - if store.users is None: - store.users = {} + if store._users is None: + store._users = OrderedDict() -class MockModule(object): +class MockModule: """Representation of a fake module.""" # pylint: disable=invalid-name @@ -367,19 +397,22 @@ def __init__(self, domain=None, dependencies=None, setup=None, self.async_unload_entry = async_unload_entry -class MockPlatform(object): +class MockPlatform: """Provide a fake platform.""" # pylint: disable=invalid-name def __init__(self, setup_platform=None, dependencies=None, platform_schema=None, async_setup_platform=None, - async_setup_entry=None): + async_setup_entry=None, scan_interval=None): """Initialize the platform.""" self.DEPENDENCIES = dependencies or [] if platform_schema is not None: self.PLATFORM_SCHEMA = platform_schema + if scan_interval is not None: + self.SCAN_INTERVAL = scan_interval + if setup_platform is not None: # We run this in executor, wrap it in function self.setup_platform = lambda *args: setup_platform(*args) @@ -469,21 +502,20 @@ def last_call(self, method=None): """Return the last call.""" if not self.calls: return None - elif method is None: + if method is None: return self.calls[-1] - else: - try: - return next(call for call in reversed(self.calls) - if call[0] == method) - except StopIteration: - return None + try: + return next(call for call in reversed(self.calls) + if call[0] == method) + except StopIteration: + return None class MockConfigEntry(config_entries.ConfigEntry): """Helper for creating config entries that adds some defaults.""" def __init__(self, *, domain='test', data=None, version=0, entry_id=None, - source=data_entry_flow.SOURCE_USER, title='Mock Title', + source=config_entries.SOURCE_USER, title='Mock Title', state=None): """Initialize a mock config entry.""" kwargs = { @@ -700,3 +732,57 @@ def _handle(self, attr): if attr in self._values: return self._values[attr] return getattr(super(), attr) + + +@contextmanager +def mock_storage(data=None): + """Mock storage. + + Data is a dict {'key': {'version': version, 'data': data}} + + Written data will be converted to JSON to ensure JSON parsing works. + """ + if data is None: + data = {} + + orig_load = storage.Store._async_load + + async def mock_async_load(store): + """Mock version of load.""" + if store._data is None: + # No data to load + if store.key not in data: + return None + + mock_data = data.get(store.key) + + if 'data' not in mock_data or 'version' not in mock_data: + _LOGGER.error('Mock data needs "version" and "data"') + raise ValueError('Mock data needs "version" and "data"') + + store._data = mock_data + + # Route through original load so that we trigger migration + loaded = await orig_load(store) + _LOGGER.info('Loading data for %s: %s', store.key, loaded) + return loaded + + def mock_write_data(store, path, data_to_write): + """Mock version of write data.""" + # To ensure that the data can be serialized + _LOGGER.info('Writing data to %s: %s', store.key, data_to_write) + data[store.key] = json.loads(json.dumps(data_to_write)) + + with patch('homeassistant.helpers.storage.Store._async_load', + side_effect=mock_async_load, autospec=True), \ + patch('homeassistant.helpers.storage.Store._write_data', + side_effect=mock_write_data, autospec=True): + yield data + + +async def flush_store(store): + """Make sure all delayed writes of a store are written.""" + if store._data is None: + return + + await store._async_handle_write_data() diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index afa4d19b5d91c2..cf8535653a96f4 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -124,7 +124,7 @@ def discovery_test(device, hass, expected_endpoints=1): if expected_endpoints == 1: return endpoints[0] - elif expected_endpoints > 1: + if expected_endpoints > 1: return endpoints return None @@ -1225,7 +1225,7 @@ def reported_properties(hass, endpoint): return _ReportedProperties(msg['context']['properties']) -class _ReportedProperties(object): +class _ReportedProperties: def __init__(self, properties): self.properties = properties diff --git a/tests/components/auth/__init__.py b/tests/components/auth/__init__.py index 3e5a59e8386a8b..ce94d1ecbfadaf 100644 --- a/tests/components/auth/__init__.py +++ b/tests/components/auth/__init__.py @@ -1,6 +1,4 @@ """Tests for the auth component.""" -from aiohttp.helpers import BasicAuth - from homeassistant import auth from homeassistant.setup import async_setup_component @@ -16,9 +14,6 @@ 'name': 'Test Name' }] }] -CLIENT_ID = 'test-id' -CLIENT_SECRET = 'test-secret' -CLIENT_AUTH = BasicAuth(CLIENT_ID, CLIENT_SECRET) async def async_setup_auth(hass, aiohttp_client, provider_configs=BASE_CONFIG, @@ -31,8 +26,6 @@ async def async_setup_auth(hass, aiohttp_client, provider_configs=BASE_CONFIG, 'api_password': 'bla' } }) - client = auth.Client('Test Client', CLIENT_ID, CLIENT_SECRET) - hass.auth._store.clients[client.id] = client if setup_api: await async_setup_component(hass, 'api', {}) return await aiohttp_client(hass.http.app) diff --git a/tests/components/auth/test_client.py b/tests/components/auth/test_client.py deleted file mode 100644 index 2995a6ac81a2ff..00000000000000 --- a/tests/components/auth/test_client.py +++ /dev/null @@ -1,70 +0,0 @@ -"""Tests for the client validator.""" -from aiohttp.helpers import BasicAuth -import pytest - -from homeassistant.setup import async_setup_component -from homeassistant.components.auth.client import verify_client -from homeassistant.components.http.view import HomeAssistantView - -from . import async_setup_auth - - -@pytest.fixture -def mock_view(hass): - """Register a view that verifies client id/secret.""" - hass.loop.run_until_complete(async_setup_component(hass, 'http', {})) - - clients = [] - - class ClientView(HomeAssistantView): - url = '/' - name = 'bla' - - @verify_client - async def get(self, request, client_id): - """Handle GET request.""" - clients.append(client_id) - - hass.http.register_view(ClientView) - return clients - - -async def test_verify_client(hass, aiohttp_client, mock_view): - """Test that verify client can extract client auth from a request.""" - http_client = await async_setup_auth(hass, aiohttp_client) - client = await hass.auth.async_create_client('Hello') - - resp = await http_client.get('/', auth=BasicAuth(client.id, client.secret)) - assert resp.status == 200 - assert mock_view == [client.id] - - -async def test_verify_client_no_auth_header(hass, aiohttp_client, mock_view): - """Test that verify client will decline unknown client id.""" - http_client = await async_setup_auth(hass, aiohttp_client) - - resp = await http_client.get('/') - assert resp.status == 401 - assert mock_view == [] - - -async def test_verify_client_invalid_client_id(hass, aiohttp_client, - mock_view): - """Test that verify client will decline unknown client id.""" - http_client = await async_setup_auth(hass, aiohttp_client) - client = await hass.auth.async_create_client('Hello') - - resp = await http_client.get('/', auth=BasicAuth('invalid', client.secret)) - assert resp.status == 401 - assert mock_view == [] - - -async def test_verify_client_invalid_client_secret(hass, aiohttp_client, - mock_view): - """Test that verify client will decline incorrect client secret.""" - http_client = await async_setup_auth(hass, aiohttp_client) - client = await hass.auth.async_create_client('Hello') - - resp = await http_client.get('/', auth=BasicAuth(client.id, 'invalid')) - assert resp.status == 401 - assert mock_view == [] diff --git a/tests/components/auth/test_indieauth.py b/tests/components/auth/test_indieauth.py new file mode 100644 index 00000000000000..75e61af2e715f1 --- /dev/null +++ b/tests/components/auth/test_indieauth.py @@ -0,0 +1,152 @@ +"""Tests for the client validator.""" +from unittest.mock import patch + +import pytest + +from homeassistant.components.auth import indieauth + +from tests.common import mock_coro + + +def test_client_id_scheme(): + """Test we enforce valid scheme.""" + assert indieauth._parse_client_id('http://ex.com/') + assert indieauth._parse_client_id('https://ex.com/') + + with pytest.raises(ValueError): + indieauth._parse_client_id('ftp://ex.com') + + +def test_client_id_path(): + """Test we enforce valid path.""" + assert indieauth._parse_client_id('http://ex.com').path == '/' + assert indieauth._parse_client_id('http://ex.com/hello').path == '/hello' + assert indieauth._parse_client_id( + 'http://ex.com/hello/.world').path == '/hello/.world' + assert indieauth._parse_client_id( + 'http://ex.com/hello./.world').path == '/hello./.world' + + with pytest.raises(ValueError): + indieauth._parse_client_id('http://ex.com/.') + + with pytest.raises(ValueError): + indieauth._parse_client_id('http://ex.com/hello/./yo') + + with pytest.raises(ValueError): + indieauth._parse_client_id('http://ex.com/hello/../yo') + + +def test_client_id_fragment(): + """Test we enforce valid fragment.""" + with pytest.raises(ValueError): + indieauth._parse_client_id('http://ex.com/#yoo') + + +def test_client_id_user_pass(): + """Test we enforce valid username/password.""" + with pytest.raises(ValueError): + indieauth._parse_client_id('http://user@ex.com/') + + with pytest.raises(ValueError): + indieauth._parse_client_id('http://user:pass@ex.com/') + + +def test_client_id_hostname(): + """Test we enforce valid hostname.""" + assert indieauth._parse_client_id('http://www.home-assistant.io/') + assert indieauth._parse_client_id('http://[::1]') + assert indieauth._parse_client_id('http://127.0.0.1') + assert indieauth._parse_client_id('http://10.0.0.0') + assert indieauth._parse_client_id('http://10.255.255.255') + assert indieauth._parse_client_id('http://172.16.0.0') + assert indieauth._parse_client_id('http://172.31.255.255') + assert indieauth._parse_client_id('http://192.168.0.0') + assert indieauth._parse_client_id('http://192.168.255.255') + + with pytest.raises(ValueError): + assert indieauth._parse_client_id('http://255.255.255.255/') + with pytest.raises(ValueError): + assert indieauth._parse_client_id('http://11.0.0.0/') + with pytest.raises(ValueError): + assert indieauth._parse_client_id('http://172.32.0.0/') + with pytest.raises(ValueError): + assert indieauth._parse_client_id('http://192.167.0.0/') + + +def test_parse_url_lowercase_host(): + """Test we update empty paths.""" + assert indieauth._parse_url('http://ex.com/hello').path == '/hello' + assert indieauth._parse_url('http://EX.COM/hello').hostname == 'ex.com' + + parts = indieauth._parse_url('http://EX.COM:123/HELLO') + assert parts.netloc == 'ex.com:123' + assert parts.path == '/HELLO' + + +def test_parse_url_path(): + """Test we update empty paths.""" + assert indieauth._parse_url('http://ex.com').path == '/' + + +async def test_verify_redirect_uri(): + """Test that we verify redirect uri correctly.""" + assert await indieauth.verify_redirect_uri( + None, + 'http://ex.com', + 'http://ex.com/callback' + ) + + with patch.object(indieauth, 'fetch_redirect_uris', + side_effect=lambda *_: mock_coro([])): + # Different domain + assert not await indieauth.verify_redirect_uri( + None, + 'http://ex.com', + 'http://different.com/callback' + ) + + # Different scheme + assert not await indieauth.verify_redirect_uri( + None, + 'http://ex.com', + 'https://ex.com/callback' + ) + + # Different subdomain + assert not await indieauth.verify_redirect_uri( + None, + 'https://sub1.ex.com', + 'https://sub2.ex.com/callback' + ) + + +async def test_find_link_tag(hass, aioclient_mock): + """Test finding link tag.""" + aioclient_mock.get("http://127.0.0.1:8000", text=""" + + + + + + + + ... + +""") + redirect_uris = await indieauth.fetch_redirect_uris( + hass, "http://127.0.0.1:8000") + + assert redirect_uris == [ + "hass://oauth2_redirect", + "http://127.0.0.1:8000/beer", + ] + + +async def test_find_link_tag_max_size(hass, aioclient_mock): + """Test finding link tag.""" + text = ("0" * 1024 * 10) + '' + aioclient_mock.get("http://127.0.0.1:8000", text=text) + redirect_uris = await indieauth.fetch_redirect_uris( + hass, "http://127.0.0.1:8000") + + assert redirect_uris == [] diff --git a/tests/components/auth/test_init.py b/tests/components/auth/test_init.py index 5d9bf6b98cc7e7..f1a1bb5bd3cc97 100644 --- a/tests/components/auth/test_init.py +++ b/tests/components/auth/test_init.py @@ -1,21 +1,34 @@ """Integration tests for the auth component.""" -from . import async_setup_auth, CLIENT_AUTH +from datetime import timedelta +from unittest.mock import patch +from homeassistant.auth.models import Credentials +from homeassistant.setup import async_setup_component +from homeassistant.util.dt import utcnow +from homeassistant.components import auth -async def test_login_new_user_and_refresh_token(hass, aiohttp_client): +from . import async_setup_auth + +from tests.common import CLIENT_ID, CLIENT_REDIRECT_URI + + +async def test_login_new_user_and_trying_refresh_token(hass, aiohttp_client): """Test logging in with new user and refreshing tokens.""" client = await async_setup_auth(hass, aiohttp_client, setup_api=True) resp = await client.post('/auth/login_flow', json={ - 'handler': ['insecure_example', None] - }, auth=CLIENT_AUTH) + 'client_id': CLIENT_ID, + 'handler': ['insecure_example', None], + 'redirect_uri': CLIENT_REDIRECT_URI, + }) assert resp.status == 200 step = await resp.json() resp = await client.post( '/auth/login_flow/{}'.format(step['flow_id']), json={ + 'client_id': CLIENT_ID, 'username': 'test-user', 'password': 'test-pass', - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 step = await resp.json() @@ -23,25 +36,33 @@ async def test_login_new_user_and_refresh_token(hass, aiohttp_client): # Exchange code for tokens resp = await client.post('/auth/token', data={ - 'grant_type': 'authorization_code', - 'code': code - }, auth=CLIENT_AUTH) + 'client_id': CLIENT_ID, + 'grant_type': 'authorization_code', + 'code': code + }) assert resp.status == 200 tokens = await resp.json() - assert hass.auth.async_get_access_token(tokens['access_token']) is not None + assert ( + await hass.auth.async_validate_access_token(tokens['access_token']) + is not None + ) # Use refresh token to get more tokens. resp = await client.post('/auth/token', data={ + 'client_id': CLIENT_ID, 'grant_type': 'refresh_token', 'refresh_token': tokens['refresh_token'] - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 tokens = await resp.json() assert 'refresh_token' not in tokens - assert hass.auth.async_get_access_token(tokens['access_token']) is not None + assert ( + await hass.auth.async_validate_access_token(tokens['access_token']) + is not None + ) # Test using access token to hit API. resp = await client.get('/api/') @@ -51,3 +72,154 @@ async def test_login_new_user_and_refresh_token(hass, aiohttp_client): 'authorization': 'Bearer {}'.format(tokens['access_token']) }) assert resp.status == 200 + + +def test_credential_store_expiration(): + """Test that the credential store will not return expired tokens.""" + store, retrieve = auth._create_cred_store() + client_id = 'bla' + credentials = 'creds' + now = utcnow() + + with patch('homeassistant.util.dt.utcnow', return_value=now): + code = store(client_id, credentials) + + with patch('homeassistant.util.dt.utcnow', + return_value=now + timedelta(minutes=10)): + assert retrieve(client_id, code) is None + + with patch('homeassistant.util.dt.utcnow', return_value=now): + code = store(client_id, credentials) + + with patch('homeassistant.util.dt.utcnow', + return_value=now + timedelta(minutes=9, seconds=59)): + assert retrieve(client_id, code) == credentials + + +async def test_ws_current_user(hass, hass_ws_client, hass_access_token): + """Test the current user command with homeassistant creds.""" + assert await async_setup_component(hass, 'auth', { + 'http': { + 'api_password': 'bla' + } + }) + + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + user = refresh_token.user + credential = Credentials(auth_provider_type='homeassistant', + auth_provider_id=None, + data={}, id='test-id') + user.credentials.append(credential) + assert len(user.credentials) == 1 + + with patch('homeassistant.auth.AuthManager.active', return_value=True): + client = await hass_ws_client(hass, hass_access_token) + + await client.send_json({ + 'id': 5, + 'type': auth.WS_TYPE_CURRENT_USER, + }) + + result = await client.receive_json() + assert result['success'], result + + user_dict = result['result'] + + assert user_dict['name'] == user.name + assert user_dict['id'] == user.id + assert user_dict['is_owner'] == user.is_owner + assert len(user_dict['credentials']) == 1 + + hass_cred = user_dict['credentials'][0] + assert hass_cred['auth_provider_type'] == 'homeassistant' + assert hass_cred['auth_provider_id'] is None + assert 'data' not in hass_cred + + +async def test_cors_on_token(hass, aiohttp_client): + """Test logging in with new user and refreshing tokens.""" + client = await async_setup_auth(hass, aiohttp_client) + + resp = await client.options('/auth/token', headers={ + 'origin': 'http://example.com', + 'Access-Control-Request-Method': 'POST', + }) + assert resp.headers['Access-Control-Allow-Origin'] == 'http://example.com' + assert resp.headers['Access-Control-Allow-Methods'] == 'POST' + + resp = await client.post('/auth/token', headers={ + 'origin': 'http://example.com' + }) + assert resp.headers['Access-Control-Allow-Origin'] == 'http://example.com' + + +async def test_refresh_token_system_generated(hass, aiohttp_client): + """Test that we can get access tokens for system generated user.""" + client = await async_setup_auth(hass, aiohttp_client) + user = await hass.auth.async_create_system_user('Test System') + refresh_token = await hass.auth.async_create_refresh_token(user, None) + + resp = await client.post('/auth/token', data={ + 'client_id': 'https://this-is-not-allowed-for-system-users.com/', + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token.token, + }) + + assert resp.status == 400 + result = await resp.json() + assert result['error'] == 'invalid_request' + + resp = await client.post('/auth/token', data={ + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token.token, + }) + + assert resp.status == 200 + tokens = await resp.json() + assert ( + await hass.auth.async_validate_access_token(tokens['access_token']) + is not None + ) + + +async def test_refresh_token_different_client_id(hass, aiohttp_client): + """Test that we verify client ID.""" + client = await async_setup_auth(hass, aiohttp_client) + user = await hass.auth.async_create_user('Test User') + refresh_token = await hass.auth.async_create_refresh_token(user, CLIENT_ID) + + # No client ID + resp = await client.post('/auth/token', data={ + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token.token, + }) + + assert resp.status == 400 + result = await resp.json() + assert result['error'] == 'invalid_request' + + # Different client ID + resp = await client.post('/auth/token', data={ + 'client_id': 'http://example-different.com', + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token.token, + }) + + assert resp.status == 400 + result = await resp.json() + assert result['error'] == 'invalid_request' + + # Correct + resp = await client.post('/auth/token', data={ + 'client_id': CLIENT_ID, + 'grant_type': 'refresh_token', + 'refresh_token': refresh_token.token, + }) + + assert resp.status == 200 + tokens = await resp.json() + assert ( + await hass.auth.async_validate_access_token(tokens['access_token']) + is not None + ) diff --git a/tests/components/auth/test_init_link_user.py b/tests/components/auth/test_init_link_user.py index 44695bce2025dd..e209e0ee85696b 100644 --- a/tests/components/auth/test_init_link_user.py +++ b/tests/components/auth/test_init_link_user.py @@ -1,5 +1,7 @@ """Tests for the link user flow.""" -from . import async_setup_auth, CLIENT_AUTH, CLIENT_ID +from . import async_setup_auth + +from tests.common import CLIENT_ID, CLIENT_REDIRECT_URI async def async_get_code(hass, aiohttp_client): @@ -23,49 +25,25 @@ async def async_get_code(hass, aiohttp_client): }] }] client = await async_setup_auth(hass, aiohttp_client, config) - - resp = await client.post('/auth/login_flow', json={ - 'handler': ['insecure_example', None] - }, auth=CLIENT_AUTH) - assert resp.status == 200 - step = await resp.json() - - resp = await client.post( - '/auth/login_flow/{}'.format(step['flow_id']), json={ - 'username': 'test-user', - 'password': 'test-pass', - }, auth=CLIENT_AUTH) - - assert resp.status == 200 - step = await resp.json() - code = step['result'] - - # Exchange code for tokens - resp = await client.post('/auth/token', data={ - 'grant_type': 'authorization_code', - 'code': code - }, auth=CLIENT_AUTH) - - assert resp.status == 200 - tokens = await resp.json() - - access_token = hass.auth.async_get_access_token(tokens['access_token']) - assert access_token is not None - user = access_token.refresh_token.user - assert len(user.credentials) == 1 + user = await hass.auth.async_create_user(name='Hello') + refresh_token = await hass.auth.async_create_refresh_token(user, CLIENT_ID) + access_token = hass.auth.async_create_access_token(refresh_token) # Now authenticate with the 2nd flow resp = await client.post('/auth/login_flow', json={ - 'handler': ['insecure_example', '2nd auth'] - }, auth=CLIENT_AUTH) + 'client_id': CLIENT_ID, + 'handler': ['insecure_example', '2nd auth'], + 'redirect_uri': CLIENT_REDIRECT_URI, + }) assert resp.status == 200 step = await resp.json() resp = await client.post( '/auth/login_flow/{}'.format(step['flow_id']), json={ + 'client_id': CLIENT_ID, 'username': '2nd-user', 'password': '2nd-pass', - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 step = await resp.json() @@ -74,7 +52,7 @@ async def async_get_code(hass, aiohttp_client): 'user': user, 'code': step['result'], 'client': client, - 'tokens': tokens, + 'access_token': access_token, } @@ -83,18 +61,17 @@ async def test_link_user(hass, aiohttp_client): info = await async_get_code(hass, aiohttp_client) client = info['client'] code = info['code'] - tokens = info['tokens'] # Link user resp = await client.post('/auth/link_user', json={ 'client_id': CLIENT_ID, 'code': code }, headers={ - 'authorization': 'Bearer {}'.format(tokens['access_token']) + 'authorization': 'Bearer {}'.format(info['access_token']) }) assert resp.status == 200 - assert len(info['user'].credentials) == 2 + assert len(info['user'].credentials) == 1 async def test_link_user_invalid_client_id(hass, aiohttp_client): @@ -102,36 +79,34 @@ async def test_link_user_invalid_client_id(hass, aiohttp_client): info = await async_get_code(hass, aiohttp_client) client = info['client'] code = info['code'] - tokens = info['tokens'] # Link user resp = await client.post('/auth/link_user', json={ 'client_id': 'invalid', 'code': code }, headers={ - 'authorization': 'Bearer {}'.format(tokens['access_token']) + 'authorization': 'Bearer {}'.format(info['access_token']) }) assert resp.status == 400 - assert len(info['user'].credentials) == 1 + assert len(info['user'].credentials) == 0 async def test_link_user_invalid_code(hass, aiohttp_client): """Test linking a user to new credentials.""" info = await async_get_code(hass, aiohttp_client) client = info['client'] - tokens = info['tokens'] # Link user resp = await client.post('/auth/link_user', json={ 'client_id': CLIENT_ID, 'code': 'invalid' }, headers={ - 'authorization': 'Bearer {}'.format(tokens['access_token']) + 'authorization': 'Bearer {}'.format(info['access_token']) }) assert resp.status == 400 - assert len(info['user'].credentials) == 1 + assert len(info['user'].credentials) == 0 async def test_link_user_invalid_auth(hass, aiohttp_client): @@ -147,4 +122,4 @@ async def test_link_user_invalid_auth(hass, aiohttp_client): }, headers={'authorization': 'Bearer invalid'}) assert resp.status == 401 - assert len(info['user'].credentials) == 1 + assert len(info['user'].credentials) == 0 diff --git a/tests/components/auth/test_init_login_flow.py b/tests/components/auth/test_login_flow.py similarity index 53% rename from tests/components/auth/test_init_login_flow.py rename to tests/components/auth/test_login_flow.py index 96fece6506b5f3..8b6108067c5221 100644 --- a/tests/components/auth/test_init_login_flow.py +++ b/tests/components/auth/test_login_flow.py @@ -1,13 +1,14 @@ """Tests for the login flow.""" -from aiohttp.helpers import BasicAuth +from . import async_setup_auth -from . import async_setup_auth, CLIENT_AUTH +from tests.common import CLIENT_ID, CLIENT_REDIRECT_URI async def test_fetch_auth_providers(hass, aiohttp_client): """Test fetching auth providers.""" client = await async_setup_auth(hass, aiohttp_client) - resp = await client.get('/auth/providers', auth=CLIENT_AUTH) + resp = await client.get('/auth/providers') + assert resp.status == 200 assert await resp.json() == [{ 'name': 'Example', 'type': 'insecure_example', @@ -15,14 +16,6 @@ async def test_fetch_auth_providers(hass, aiohttp_client): }] -async def test_fetch_auth_providers_require_valid_client(hass, aiohttp_client): - """Test fetching auth providers.""" - client = await async_setup_auth(hass, aiohttp_client) - resp = await client.get('/auth/providers', - auth=BasicAuth('invalid', 'bla')) - assert resp.status == 401 - - async def test_cannot_get_flows_in_progress(hass, aiohttp_client): """Test we cannot get flows in progress.""" client = await async_setup_auth(hass, aiohttp_client, []) @@ -34,17 +27,20 @@ async def test_invalid_username_password(hass, aiohttp_client): """Test we cannot get flows in progress.""" client = await async_setup_auth(hass, aiohttp_client) resp = await client.post('/auth/login_flow', json={ - 'handler': ['insecure_example', None] - }, auth=CLIENT_AUTH) + 'client_id': CLIENT_ID, + 'handler': ['insecure_example', None], + 'redirect_uri': CLIENT_REDIRECT_URI + }) assert resp.status == 200 step = await resp.json() # Incorrect username resp = await client.post( '/auth/login_flow/{}'.format(step['flow_id']), json={ + 'client_id': CLIENT_ID, 'username': 'wrong-user', 'password': 'test-pass', - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 step = await resp.json() @@ -55,12 +51,41 @@ async def test_invalid_username_password(hass, aiohttp_client): # Incorrect password resp = await client.post( '/auth/login_flow/{}'.format(step['flow_id']), json={ + 'client_id': CLIENT_ID, 'username': 'test-user', 'password': 'wrong-pass', - }, auth=CLIENT_AUTH) + }) assert resp.status == 200 step = await resp.json() assert step['step_id'] == 'init' assert step['errors']['base'] == 'invalid_auth' + + +async def test_login_exist_user(hass, aiohttp_client): + """Test logging in with exist user.""" + client = await async_setup_auth(hass, aiohttp_client, setup_api=True) + cred = await hass.auth.auth_providers[0].async_get_or_create_credentials( + {'username': 'test-user'}) + await hass.auth.async_get_or_create_user(cred) + + resp = await client.post('/auth/login_flow', json={ + 'client_id': CLIENT_ID, + 'handler': ['insecure_example', None], + 'redirect_uri': CLIENT_REDIRECT_URI, + }) + assert resp.status == 200 + step = await resp.json() + + resp = await client.post( + '/auth/login_flow/{}'.format(step['flow_id']), json={ + 'client_id': CLIENT_ID, + 'username': 'test-user', + 'password': 'test-pass', + }) + + assert resp.status == 200 + step = await resp.json() + assert step['type'] == 'create_entry' + assert len(step['result']) > 1 diff --git a/tests/components/automation/test_event.py b/tests/components/automation/test_event.py index df9ab69e7e8716..aea6e517e3853e 100644 --- a/tests/components/automation/test_event.py +++ b/tests/components/automation/test_event.py @@ -26,7 +26,7 @@ def record_call(service): self.hass.services.register('test', 'automation', record_call) def tearDown(self): - """"Stop everything that was started.""" + """Stop everything that was started.""" self.hass.stop() def test_if_fires_on_event(self): diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 7a8c097a730e82..b1990fb80aac50 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -207,6 +207,7 @@ def test_trigger_service_ignoring_condition(self): """Test triggers.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { + 'alias': 'test', 'trigger': [ { 'platform': 'event', @@ -228,7 +229,9 @@ def test_trigger_service_ignoring_condition(self): self.hass.block_till_done() assert len(self.calls) == 0 - self.hass.services.call('automation', 'trigger', blocking=True) + self.hass.services.call('automation', 'trigger', + {'entity_id': 'automation.test'}, + blocking=True) self.hass.block_till_done() assert len(self.calls) == 1 @@ -434,10 +437,12 @@ def test_reload_config_service(self): } } }}): - automation.reload(self.hass) - self.hass.block_till_done() - # De-flake ?! - self.hass.block_till_done() + with patch('homeassistant.config.find_config_file', + return_value=''): + automation.reload(self.hass) + self.hass.block_till_done() + # De-flake ?! + self.hass.block_till_done() assert self.hass.states.get('automation.hello') is None assert self.hass.states.get('automation.bye') is not None @@ -482,8 +487,10 @@ def test_reload_config_when_invalid_config(self): with patch('homeassistant.config.load_yaml_config_file', autospec=True, return_value={automation.DOMAIN: 'not valid'}): - automation.reload(self.hass) - self.hass.block_till_done() + with patch('homeassistant.config.find_config_file', + return_value=''): + automation.reload(self.hass) + self.hass.block_till_done() assert self.hass.states.get('automation.hello') is None @@ -518,8 +525,10 @@ def test_reload_config_handles_load_fails(self): with patch('homeassistant.config.load_yaml_config_file', side_effect=HomeAssistantError('bla')): - automation.reload(self.hass) - self.hass.block_till_done() + with patch('homeassistant.config.find_config_file', + return_value=''): + automation.reload(self.hass) + self.hass.block_till_done() assert self.hass.states.get('automation.hello') is not None diff --git a/tests/components/automation/test_numeric_state.py b/tests/components/automation/test_numeric_state.py index 63ca4b5cd1a02e..de453675a577f8 100644 --- a/tests/components/automation/test_numeric_state.py +++ b/tests/components/automation/test_numeric_state.py @@ -35,7 +35,7 @@ def tearDown(self): # pylint: disable=invalid-name self.hass.stop() def test_if_fires_on_entity_change_below(self): - """"Test the firing with changed entity.""" + """Test the firing with changed entity.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -62,7 +62,7 @@ def test_if_fires_on_entity_change_below(self): self.assertEqual(1, len(self.calls)) def test_if_fires_on_entity_change_over_to_below(self): - """"Test the firing with changed entity.""" + """Test the firing with changed entity.""" self.hass.states.set('test.entity', 11) self.hass.block_till_done() @@ -85,7 +85,7 @@ def test_if_fires_on_entity_change_over_to_below(self): self.assertEqual(1, len(self.calls)) def test_if_fires_on_entities_change_over_to_below(self): - """"Test the firing with changed entities.""" + """Test the firing with changed entities.""" self.hass.states.set('test.entity_1', 11) self.hass.states.set('test.entity_2', 11) self.hass.block_till_done() @@ -115,7 +115,7 @@ def test_if_fires_on_entities_change_over_to_below(self): self.assertEqual(2, len(self.calls)) def test_if_not_fires_on_entity_change_below_to_below(self): - """"Test the firing with changed entity.""" + """Test the firing with changed entity.""" self.hass.states.set('test.entity', 11) self.hass.block_till_done() @@ -148,7 +148,7 @@ def test_if_not_fires_on_entity_change_below_to_below(self): self.assertEqual(1, len(self.calls)) def test_if_not_below_fires_on_entity_change_to_equal(self): - """"Test the firing with changed entity.""" + """Test the firing with changed entity.""" self.hass.states.set('test.entity', 11) self.hass.block_till_done() @@ -171,7 +171,7 @@ def test_if_not_below_fires_on_entity_change_to_equal(self): self.assertEqual(0, len(self.calls)) def test_if_fires_on_initial_entity_below(self): - """"Test the firing when starting with a match.""" + """Test the firing when starting with a match.""" self.hass.states.set('test.entity', 9) self.hass.block_till_done() @@ -194,7 +194,7 @@ def test_if_fires_on_initial_entity_below(self): self.assertEqual(1, len(self.calls)) def test_if_fires_on_initial_entity_above(self): - """"Test the firing when starting with a match.""" + """Test the firing when starting with a match.""" self.hass.states.set('test.entity', 11) self.hass.block_till_done() @@ -217,7 +217,7 @@ def test_if_fires_on_initial_entity_above(self): self.assertEqual(1, len(self.calls)) def test_if_fires_on_entity_change_above(self): - """"Test the firing with changed entity.""" + """Test the firing with changed entity.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -236,7 +236,7 @@ def test_if_fires_on_entity_change_above(self): self.assertEqual(1, len(self.calls)) def test_if_fires_on_entity_change_below_to_above(self): - """"Test the firing with changed entity.""" + """Test the firing with changed entity.""" # set initial state self.hass.states.set('test.entity', 9) self.hass.block_till_done() @@ -260,7 +260,7 @@ def test_if_fires_on_entity_change_below_to_above(self): self.assertEqual(1, len(self.calls)) def test_if_not_fires_on_entity_change_above_to_above(self): - """"Test the firing with changed entity.""" + """Test the firing with changed entity.""" # set initial state self.hass.states.set('test.entity', 9) self.hass.block_till_done() @@ -289,7 +289,7 @@ def test_if_not_fires_on_entity_change_above_to_above(self): self.assertEqual(1, len(self.calls)) def test_if_not_above_fires_on_entity_change_to_equal(self): - """"Test the firing with changed entity.""" + """Test the firing with changed entity.""" # set initial state self.hass.states.set('test.entity', 9) self.hass.block_till_done() @@ -313,7 +313,7 @@ def test_if_not_above_fires_on_entity_change_to_equal(self): self.assertEqual(0, len(self.calls)) def test_if_fires_on_entity_change_below_range(self): - """"Test the firing with changed entity.""" + """Test the firing with changed entity.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -333,7 +333,7 @@ def test_if_fires_on_entity_change_below_range(self): self.assertEqual(1, len(self.calls)) def test_if_fires_on_entity_change_below_above_range(self): - """"Test the firing with changed entity.""" + """Test the firing with changed entity.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -353,7 +353,7 @@ def test_if_fires_on_entity_change_below_above_range(self): self.assertEqual(0, len(self.calls)) def test_if_fires_on_entity_change_over_to_below_range(self): - """"Test the firing with changed entity.""" + """Test the firing with changed entity.""" self.hass.states.set('test.entity', 11) self.hass.block_till_done() @@ -377,7 +377,7 @@ def test_if_fires_on_entity_change_over_to_below_range(self): self.assertEqual(1, len(self.calls)) def test_if_fires_on_entity_change_over_to_below_above_range(self): - """"Test the firing with changed entity.""" + """Test the firing with changed entity.""" self.hass.states.set('test.entity', 11) self.hass.block_till_done() @@ -401,7 +401,7 @@ def test_if_fires_on_entity_change_over_to_below_above_range(self): self.assertEqual(0, len(self.calls)) def test_if_not_fires_if_entity_not_match(self): - """"Test if not fired with non matching entity.""" + """Test if not fired with non matching entity.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -420,7 +420,7 @@ def test_if_not_fires_if_entity_not_match(self): self.assertEqual(0, len(self.calls)) def test_if_fires_on_entity_change_below_with_attribute(self): - """"Test attributes change.""" + """Test attributes change.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -439,7 +439,7 @@ def test_if_fires_on_entity_change_below_with_attribute(self): self.assertEqual(1, len(self.calls)) def test_if_not_fires_on_entity_change_not_below_with_attribute(self): - """"Test attributes.""" + """Test attributes.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -458,7 +458,7 @@ def test_if_not_fires_on_entity_change_not_below_with_attribute(self): self.assertEqual(0, len(self.calls)) def test_if_fires_on_attribute_change_with_attribute_below(self): - """"Test attributes change.""" + """Test attributes change.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -478,7 +478,7 @@ def test_if_fires_on_attribute_change_with_attribute_below(self): self.assertEqual(1, len(self.calls)) def test_if_not_fires_on_attribute_change_with_attribute_not_below(self): - """"Test attributes change.""" + """Test attributes change.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -498,7 +498,7 @@ def test_if_not_fires_on_attribute_change_with_attribute_not_below(self): self.assertEqual(0, len(self.calls)) def test_if_not_fires_on_entity_change_with_attribute_below(self): - """"Test attributes change.""" + """Test attributes change.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -518,7 +518,7 @@ def test_if_not_fires_on_entity_change_with_attribute_below(self): self.assertEqual(0, len(self.calls)) def test_if_not_fires_on_entity_change_with_not_attribute_below(self): - """"Test attributes change.""" + """Test attributes change.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -538,7 +538,7 @@ def test_if_not_fires_on_entity_change_with_not_attribute_below(self): self.assertEqual(0, len(self.calls)) def test_fires_on_attr_change_with_attribute_below_and_multiple_attr(self): - """"Test attributes change.""" + """Test attributes change.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -559,7 +559,7 @@ def test_fires_on_attr_change_with_attribute_below_and_multiple_attr(self): self.assertEqual(1, len(self.calls)) def test_template_list(self): - """"Test template list.""" + """Test template list.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -581,7 +581,7 @@ def test_template_list(self): self.assertEqual(1, len(self.calls)) def test_template_string(self): - """"Test template string.""" + """Test template string.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -614,7 +614,7 @@ def test_template_string(self): self.calls[0].data['some']) def test_not_fires_on_attr_change_with_attr_not_below_multiple_attr(self): - """"Test if not fired changed attributes.""" + """Test if not fired changed attributes.""" assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { 'trigger': { @@ -635,7 +635,7 @@ def test_not_fires_on_attr_change_with_attr_not_below_multiple_attr(self): self.assertEqual(0, len(self.calls)) def test_if_action(self): - """"Test if action.""" + """Test if action.""" entity_id = 'domain.test_entity' assert setup_component(self.hass, automation.DOMAIN, { automation.DOMAIN: { diff --git a/tests/components/binary_sensor/test_bayesian.py b/tests/components/binary_sensor/test_bayesian.py index 3b403c3702f088..c3242e09e78233 100644 --- a/tests/components/binary_sensor/test_bayesian.py +++ b/tests/components/binary_sensor/test_bayesian.py @@ -154,6 +154,37 @@ def test_sensor_state(self): assert state.state == 'off' + def test_threshold(self): + """Test sensor on probabilty threshold limits.""" + config = { + 'binary_sensor': { + 'name': + 'Test_Binary', + 'platform': + 'bayesian', + 'observations': [{ + 'platform': 'state', + 'entity_id': 'sensor.test_monitored', + 'to_state': 'on', + 'prob_given_true': 1.0, + }], + 'prior': + 0.5, + 'probability_threshold': + 1.0, + } + } + + assert setup_component(self.hass, 'binary_sensor', config) + + self.hass.states.set('sensor.test_monitored', 'on') + self.hass.block_till_done() + + state = self.hass.states.get('binary_sensor.test_binary') + self.assertAlmostEqual(1.0, state.attributes.get('probability')) + + assert state.state == 'on' + def test_multiple_observations(self): """Test sensor with multiple observations of same entity.""" config = { diff --git a/tests/components/binary_sensor/test_command_line.py b/tests/components/binary_sensor/test_command_line.py index d01b62e4c12cd8..07389c7c8a9cfb 100644 --- a/tests/components/binary_sensor/test_command_line.py +++ b/tests/components/binary_sensor/test_command_line.py @@ -24,7 +24,9 @@ def test_setup(self): config = {'name': 'Test', 'command': 'echo 1', 'payload_on': '1', - 'payload_off': '0'} + 'payload_off': '0', + 'command_timeout': 15 + } devices = [] @@ -43,7 +45,7 @@ def add_dev_callback(devs, update): def test_template(self): """Test setting the state with a template.""" - data = command_line.CommandSensorData(self.hass, 'echo 10') + data = command_line.CommandSensorData(self.hass, 'echo 10', 15) entity = command_line.CommandBinarySensor( self.hass, data, 'test', None, '1.0', '0', @@ -53,7 +55,7 @@ def test_template(self): def test_sensor_off(self): """Test setting the state with a template.""" - data = command_line.CommandSensorData(self.hass, 'echo 0') + data = command_line.CommandSensorData(self.hass, 'echo 0', 15) entity = command_line.CommandBinarySensor( self.hass, data, 'test', None, '1', '0', None) diff --git a/tests/components/binary_sensor/test_deconz.py b/tests/components/binary_sensor/test_deconz.py index 88dd0dae737af0..2e33e28fa578cc 100644 --- a/tests/components/binary_sensor/test_deconz.py +++ b/tests/components/binary_sensor/test_deconz.py @@ -26,7 +26,7 @@ } -async def setup_bridge(hass, data): +async def setup_bridge(hass, data, allow_clip_sensor=True): """Load the deCONZ binary sensor platform.""" from pydeconz import DeconzSession loop = Mock() @@ -41,7 +41,8 @@ async def setup_bridge(hass, data): hass.data[deconz.DATA_DECONZ_UNSUB] = [] hass.data[deconz.DATA_DECONZ_ID] = {} config_entry = config_entries.ConfigEntry( - 1, deconz.DOMAIN, 'Mock Title', {'host': 'mock-host'}, 'test') + 1, deconz.DOMAIN, 'Mock Title', + {'host': 'mock-host', 'allow_clip_sensor': allow_clip_sensor}, 'test') await hass.config_entries.async_forward_entry_setup( config_entry, 'binary_sensor') # To flush out the service call to update the group @@ -77,3 +78,16 @@ async def test_add_new_sensor(hass): async_dispatcher_send(hass, 'deconz_new_sensor', [sensor]) await hass.async_block_till_done() assert "binary_sensor.name" in hass.data[deconz.DATA_DECONZ_ID] + + +async def test_do_not_allow_clip_sensor(hass): + """Test that clip sensors can be ignored.""" + data = {} + await setup_bridge(hass, data, allow_clip_sensor=False) + sensor = Mock() + sensor.name = 'name' + sensor.type = 'CLIPPresence' + sensor.register_async_callback = Mock() + async_dispatcher_send(hass, 'deconz_new_sensor', [sensor]) + await hass.async_block_till_done() + assert len(hass.data[deconz.DATA_DECONZ_ID]) == 0 diff --git a/tests/components/binary_sensor/test_ffmpeg.py b/tests/components/binary_sensor/test_ffmpeg.py index aadafadd4a65aa..da9350008d865c 100644 --- a/tests/components/binary_sensor/test_ffmpeg.py +++ b/tests/components/binary_sensor/test_ffmpeg.py @@ -7,7 +7,7 @@ get_test_home_assistant, assert_setup_component, mock_coro) -class TestFFmpegNoiseSetup(object): +class TestFFmpegNoiseSetup: """Test class for ffmpeg.""" def setup_method(self): @@ -72,7 +72,7 @@ def test_setup_component_start_callback(self, mock_ffmpeg): assert entity.state == 'on' -class TestFFmpegMotionSetup(object): +class TestFFmpegMotionSetup: """Test class for ffmpeg.""" def setup_method(self): diff --git a/tests/components/binary_sensor/test_mqtt.py b/tests/components/binary_sensor/test_mqtt.py index 9b5cf7aa736eeb..71eba2df95039f 100644 --- a/tests/components/binary_sensor/test_mqtt.py +++ b/tests/components/binary_sensor/test_mqtt.py @@ -77,6 +77,25 @@ def test_invalid_device_class(self): state = self.hass.states.get('binary_sensor.test') self.assertIsNone(state) + def test_unique_id(self): + """Test unique id option only creates one sensor per unique_id.""" + assert setup_component(self.hass, binary_sensor.DOMAIN, { + binary_sensor.DOMAIN: [{ + 'platform': 'mqtt', + 'name': 'Test 1', + 'state_topic': 'test-topic', + 'unique_id': 'TOTALLY_UNIQUE' + }, { + 'platform': 'mqtt', + 'name': 'Test 2', + 'state_topic': 'test-topic', + 'unique_id': 'TOTALLY_UNIQUE' + }] + }) + fire_mqtt_message(self.hass, 'test-topic', 'payload') + self.hass.block_till_done() + assert len(self.hass.states.all()) == 1 + def test_availability_without_topic(self): """Test availability without defined availability topic.""" self.assertTrue(setup_component(self.hass, binary_sensor.DOMAIN, { diff --git a/tests/components/binary_sensor/test_nx584.py b/tests/components/binary_sensor/test_nx584.py index d94d887c641a31..4d1d85d30fb9d8 100644 --- a/tests/components/binary_sensor/test_nx584.py +++ b/tests/components/binary_sensor/test_nx584.py @@ -113,7 +113,7 @@ def test_setup_no_partitions(self): self._test_assert_graceful_fail({}) def test_setup_version_too_old(self): - """"Test if version is too old.""" + """Test if version is too old.""" nx584_client.Client.return_value.get_version.return_value = '1.0' self._test_assert_graceful_fail({}) diff --git a/tests/components/binary_sensor/test_ring.py b/tests/components/binary_sensor/test_ring.py index 889282b56dd98f..e557050ae48782 100644 --- a/tests/components/binary_sensor/test_ring.py +++ b/tests/components/binary_sensor/test_ring.py @@ -44,6 +44,8 @@ def tearDown(self): @requests_mock.Mocker() def test_binary_sensor(self, mock): """Test the Ring sensor class and methods.""" + mock.post('https://oauth.ring.com/oauth/token', + text=load_fixture('ring_oauth.json')) mock.post('https://api.ring.com/clients_api/session', text=load_fixture('ring_session.json')) mock.get('https://api.ring.com/clients_api/ring_devices', diff --git a/tests/components/binary_sensor/test_template.py b/tests/components/binary_sensor/test_template.py index 18c095f4bc12c2..62623a04f3c276 100644 --- a/tests/components/binary_sensor/test_template.py +++ b/tests/components/binary_sensor/test_template.py @@ -31,7 +31,7 @@ def teardown_method(self, method): self.hass.stop() def test_setup(self): - """"Test the setup.""" + """Test the setup.""" config = { 'binary_sensor': { 'platform': 'template', @@ -49,7 +49,7 @@ def test_setup(self): self.hass, 'binary_sensor', config) def test_setup_no_sensors(self): - """"Test setup with no sensors.""" + """Test setup with no sensors.""" with assert_setup_component(0): assert setup.setup_component(self.hass, 'binary_sensor', { 'binary_sensor': { @@ -58,7 +58,7 @@ def test_setup_no_sensors(self): }) def test_setup_invalid_device(self): - """"Test the setup with invalid devices.""" + """Test the setup with invalid devices.""" with assert_setup_component(0): assert setup.setup_component(self.hass, 'binary_sensor', { 'binary_sensor': { @@ -70,7 +70,7 @@ def test_setup_invalid_device(self): }) def test_setup_invalid_device_class(self): - """"Test setup with invalid sensor class.""" + """Test setup with invalid sensor class.""" with assert_setup_component(0): assert setup.setup_component(self.hass, 'binary_sensor', { 'binary_sensor': { @@ -85,7 +85,7 @@ def test_setup_invalid_device_class(self): }) def test_setup_invalid_missing_template(self): - """"Test setup with invalid and missing template.""" + """Test setup with invalid and missing template.""" with assert_setup_component(0): assert setup.setup_component(self.hass, 'binary_sensor', { 'binary_sensor': { @@ -161,7 +161,7 @@ def test_entity_picture_template(self): assert state.attributes['entity_picture'] == '/local/sensor.png' def test_attributes(self): - """"Test the attributes.""" + """Test the attributes.""" vs = run_callback_threadsafe( self.hass.loop, template.BinarySensorTemplate, self.hass, 'parent', 'Parent', 'motion', @@ -182,7 +182,7 @@ def test_attributes(self): self.assertTrue(vs.is_on) def test_event(self): - """"Test the event.""" + """Test the event.""" config = { 'binary_sensor': { 'platform': 'template', @@ -214,7 +214,7 @@ def test_event(self): @mock.patch('homeassistant.helpers.template.Template.render') def test_update_template_error(self, mock_render): - """"Test the template update error.""" + """Test the template update error.""" vs = run_callback_threadsafe( self.hass.loop, template.BinarySensorTemplate, self.hass, 'parent', 'Parent', 'motion', diff --git a/tests/components/binary_sensor/test_trend.py b/tests/components/binary_sensor/test_trend.py index c1083cc1857b6e..b77f9060b40696 100644 --- a/tests/components/binary_sensor/test_trend.py +++ b/tests/components/binary_sensor/test_trend.py @@ -273,8 +273,7 @@ def test_missing_attribute(self): state = self.hass.states.get('binary_sensor.test_trend_sensor') assert state.state == 'off' - def test_invalid_name_does_not_create(self): \ - # pylint: disable=invalid-name + def test_invalid_name_does_not_create(self): """Test invalid name.""" with assert_setup_component(0): assert setup.setup_component(self.hass, 'binary_sensor', { @@ -290,8 +289,7 @@ def test_invalid_name_does_not_create(self): \ }) assert self.hass.states.all() == [] - def test_invalid_sensor_does_not_create(self): \ - # pylint: disable=invalid-name + def test_invalid_sensor_does_not_create(self): """Test invalid sensor.""" with assert_setup_component(0): assert setup.setup_component(self.hass, 'binary_sensor', { diff --git a/tests/components/binary_sensor/test_workday.py b/tests/components/binary_sensor/test_workday.py index af7e856e417502..893745ce3ded80 100644 --- a/tests/components/binary_sensor/test_workday.py +++ b/tests/components/binary_sensor/test_workday.py @@ -12,7 +12,7 @@ FUNCTION_PATH = 'homeassistant.components.binary_sensor.workday.get_date' -class TestWorkdaySetup(object): +class TestWorkdaySetup: """Test class for workday sensor.""" def setup_method(self): diff --git a/tests/components/calendar/test_caldav.py b/tests/components/calendar/test_caldav.py index 11dd0cb963535d..c5dadbc56eaca2 100644 --- a/tests/components/calendar/test_caldav.py +++ b/tests/components/calendar/test_caldav.py @@ -19,7 +19,7 @@ DEVICE_DATA = { "name": "Private Calendar", - "device_id": "Private Calendar" + "device_id": "Private Calendar", } EVENTS = [ @@ -163,6 +163,7 @@ class TestComponentsWebDavCalendar(unittest.TestCase): def setUp(self): """Set up things to be run when tests are started.""" self.hass = get_test_home_assistant() + self.hass.http = Mock() self.calendar = _mock_calendar("Private") # pylint: disable=invalid-name @@ -255,7 +256,7 @@ def test_ongoing_event(self, mock_now): "start_time": "2017-11-27 17:00:00", "end_time": "2017-11-27 18:00:00", "location": "Hamburg", - "description": "Surprisingly rainy" + "description": "Surprisingly rainy", }) @patch('homeassistant.util.dt.now', return_value=_local_datetime(17, 30)) @@ -274,7 +275,7 @@ def test_just_ended_event(self, mock_now): "start_time": "2017-11-27 17:00:00", "end_time": "2017-11-27 18:00:00", "location": "Hamburg", - "description": "Surprisingly rainy" + "description": "Surprisingly rainy", }) @patch('homeassistant.util.dt.now', return_value=_local_datetime(17, 00)) @@ -293,7 +294,7 @@ def test_ongoing_event_different_tz(self, mock_now): "start_time": "2017-11-27 16:30:00", "description": "Sunny day", "end_time": "2017-11-27 17:30:00", - "location": "San Francisco" + "location": "San Francisco", }) @patch('homeassistant.util.dt.now', return_value=_local_datetime(8, 30)) @@ -311,7 +312,7 @@ def test_ongoing_event_with_offset(self, mock_now): "start_time": "2017-11-27 10:00:00", "end_time": "2017-11-27 11:00:00", "location": "Hamburg", - "description": "Surprisingly shiny" + "description": "Surprisingly shiny", }) @patch('homeassistant.util.dt.now', return_value=_local_datetime(12, 00)) @@ -332,7 +333,7 @@ def test_matching_filter(self, mock_now): "start_time": "2017-11-27 17:00:00", "end_time": "2017-11-27 18:00:00", "location": "Hamburg", - "description": "Surprisingly rainy" + "description": "Surprisingly rainy", }) @patch('homeassistant.util.dt.now', return_value=_local_datetime(12, 00)) @@ -353,7 +354,7 @@ def test_matching_filter_real_regexp(self, mock_now): "start_time": "2017-11-27 17:00:00", "end_time": "2017-11-27 18:00:00", "location": "Hamburg", - "description": "Surprisingly rainy" + "description": "Surprisingly rainy", }) @patch('homeassistant.util.dt.now', return_value=_local_datetime(20, 00)) @@ -395,5 +396,5 @@ def test_all_day_event_returned(self, mock_now): "start_time": "2017-11-27 00:00:00", "end_time": "2017-11-28 00:00:00", "location": "Hamburg", - "description": "What a beautiful day" + "description": "What a beautiful day", }) diff --git a/tests/components/calendar/test_demo.py b/tests/components/calendar/test_demo.py new file mode 100644 index 00000000000000..09c6a06a54ec0c --- /dev/null +++ b/tests/components/calendar/test_demo.py @@ -0,0 +1 @@ +"""The tests for the demo calendar component.""" diff --git a/tests/components/calendar/test_google.py b/tests/components/calendar/test_google.py index 9f94ea9f44c370..d176cd758b43a9 100644 --- a/tests/components/calendar/test_google.py +++ b/tests/components/calendar/test_google.py @@ -27,6 +27,7 @@ class TestComponentsGoogleCalendar(unittest.TestCase): def setUp(self): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() + self.hass.http = Mock() # Set our timezone to CST/Regina so we can check calculations # This keeps UTC-6 all year round @@ -99,7 +100,7 @@ def test_all_day_event(self, mock_next_event): 'start_time': '{} 00:00:00'.format(event['start']['date']), 'end_time': '{} 00:00:00'.format(event['end']['date']), 'location': event['location'], - 'description': event['description'] + 'description': event['description'], }) @patch('homeassistant.components.calendar.google.GoogleCalendarData') @@ -160,7 +161,7 @@ def test_future_event(self, mock_next_event): (one_hour_from_now + dt_util.dt.timedelta(minutes=60)) .strftime(DATE_STR_FORMAT), 'location': '', - 'description': '' + 'description': '', }) @patch('homeassistant.components.calendar.google.GoogleCalendarData') @@ -222,7 +223,7 @@ def test_in_progress_event(self, mock_next_event): (middle_of_event + dt_util.dt.timedelta(minutes=60)) .strftime(DATE_STR_FORMAT), 'location': '', - 'description': '' + 'description': '', }) @patch('homeassistant.components.calendar.google.GoogleCalendarData') @@ -285,7 +286,7 @@ def test_offset_in_progress_event(self, mock_next_event): (middle_of_event + dt_util.dt.timedelta(minutes=60)) .strftime(DATE_STR_FORMAT), 'location': '', - 'description': '' + 'description': '', }) @pytest.mark.skip @@ -352,7 +353,7 @@ def test_all_day_offset_in_progress_event(self, mock_next_event): 'start_time': '{} 06:00:00'.format(event['start']['date']), 'end_time': '{} 06:00:00'.format(event['end']['date']), 'location': event['location'], - 'description': event['description'] + 'description': event['description'], }) @patch('homeassistant.components.calendar.google.GoogleCalendarData') @@ -419,7 +420,7 @@ def test_all_day_offset_event(self, mock_next_event): 'start_time': '{} 00:00:00'.format(event['start']['date']), 'end_time': '{} 00:00:00'.format(event['end']['date']), 'location': event['location'], - 'description': event['description'] + 'description': event['description'], }) @MockDependency("httplib2") diff --git a/tests/components/calendar/test_init.py b/tests/components/calendar/test_init.py index 164c3f57f52495..a5f6a751b46f2e 100644 --- a/tests/components/calendar/test_init.py +++ b/tests/components/calendar/test_init.py @@ -1 +1,38 @@ """The tests for the calendar component.""" +from datetime import timedelta + +from homeassistant.bootstrap import async_setup_component +import homeassistant.util.dt as dt_util + + +async def test_events_http_api(hass, aiohttp_client): + """Test the calendar demo view.""" + await async_setup_component(hass, 'calendar', + {'calendar': {'platform': 'demo'}}) + client = await aiohttp_client(hass.http.app) + response = await client.get( + '/api/calendars/calendar.calendar_2') + assert response.status == 400 + start = dt_util.now() + end = start + timedelta(days=1) + response = await client.get( + '/api/calendars/calendar.calendar_1?start={}&end={}'.format( + start.isoformat(), end.isoformat())) + assert response.status == 200 + events = await response.json() + assert events[0]['summary'] == 'Future Event' + assert events[0]['title'] == 'Future Event' + + +async def test_calendars_http_api(hass, aiohttp_client): + """Test the calendar demo view.""" + await async_setup_component(hass, 'calendar', + {'calendar': {'platform': 'demo'}}) + client = await aiohttp_client(hass.http.app) + response = await client.get('/api/calendars') + assert response.status == 200 + data = await response.json() + assert data == [ + {'entity_id': 'calendar.calendar_1', 'name': 'Calendar 1'}, + {'entity_id': 'calendar.calendar_2', 'name': 'Calendar 2'} + ] diff --git a/tests/components/camera/test_demo.py b/tests/components/camera/test_demo.py index 51e04fca351789..b901b723c0be0b 100644 --- a/tests/components/camera/test_demo.py +++ b/tests/components/camera/test_demo.py @@ -1,14 +1,89 @@ """The tests for local file camera component.""" -import asyncio +from unittest.mock import mock_open, patch, PropertyMock + +import pytest + from homeassistant.components import camera +from homeassistant.components.camera import STATE_STREAMING, STATE_IDLE +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component -@asyncio.coroutine -def test_motion_detection(hass): +@pytest.fixture +def demo_camera(hass): + """Initialize a demo camera platform.""" + hass.loop.run_until_complete(async_setup_component(hass, 'camera', { + camera.DOMAIN: { + 'platform': 'demo' + } + })) + return hass.data['camera'].get_entity('camera.demo_camera') + + +async def test_init_state_is_streaming(hass, demo_camera): + """Demo camera initialize as streaming.""" + assert demo_camera.state == STATE_STREAMING + + mock_on_img = mock_open(read_data=b'ON') + with patch('homeassistant.components.camera.demo.open', mock_on_img, + create=True): + image = await camera.async_get_image(hass, demo_camera.entity_id) + assert mock_on_img.called + assert mock_on_img.call_args_list[0][0][0][-6:] \ + in ['_0.jpg', '_1.jpg', '_2.jpg', '_3.jpg'] + assert image.content == b'ON' + + +async def test_turn_on_state_back_to_streaming(hass, demo_camera): + """After turn on state back to streaming.""" + assert demo_camera.state == STATE_STREAMING + await camera.async_turn_off(hass, demo_camera.entity_id) + await hass.async_block_till_done() + + assert demo_camera.state == STATE_IDLE + + await camera.async_turn_on(hass, demo_camera.entity_id) + await hass.async_block_till_done() + + assert demo_camera.state == STATE_STREAMING + + +async def test_turn_off_image(hass, demo_camera): + """After turn off, Demo camera raise error.""" + await camera.async_turn_off(hass, demo_camera.entity_id) + await hass.async_block_till_done() + + with pytest.raises(HomeAssistantError) as error: + await camera.async_get_image(hass, demo_camera.entity_id) + assert error.args[0] == 'Camera is off' + + +async def test_turn_off_invalid_camera(hass, demo_camera): + """Turn off non-exist camera should quietly fail.""" + assert demo_camera.state == STATE_STREAMING + await camera.async_turn_off(hass, 'camera.invalid_camera') + await hass.async_block_till_done() + + assert demo_camera.state == STATE_STREAMING + + +async def test_turn_off_unsupport_camera(hass, demo_camera): + """Turn off unsupported camera should quietly fail.""" + assert demo_camera.state == STATE_STREAMING + with patch('homeassistant.components.camera.demo.DemoCamera' + '.supported_features', new_callable=PropertyMock) as m: + m.return_value = 0 + + await camera.async_turn_off(hass, demo_camera.entity_id) + await hass.async_block_till_done() + + assert demo_camera.state == STATE_STREAMING + + +async def test_motion_detection(hass): """Test motion detection services.""" # Setup platform - yield from async_setup_component(hass, 'camera', { + await async_setup_component(hass, 'camera', { 'camera': { 'platform': 'demo' } @@ -20,7 +95,7 @@ def test_motion_detection(hass): # Call service to turn on motion detection camera.enable_motion_detection(hass, 'camera.demo_camera') - yield from hass.async_block_till_done() + await hass.async_block_till_done() # Check if state has been updated. state = hass.states.get('camera.demo_camera') diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index d0f1425a595df0..cf902ca177901a 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -30,7 +30,7 @@ def mock_camera(hass): yield -class TestSetupCamera(object): +class TestSetupCamera: """Test class for setup camera.""" def setup_method(self): @@ -53,7 +53,7 @@ def test_setup_component(self): setup_component(self.hass, camera.DOMAIN, config) -class TestGetImage(object): +class TestGetImage: """Test class for camera.""" def setup_method(self): diff --git a/tests/components/camera/test_local_file.py b/tests/components/camera/test_local_file.py index 40517ea1298b29..0a57512aabd557 100644 --- a/tests/components/camera/test_local_file.py +++ b/tests/components/camera/test_local_file.py @@ -2,10 +2,6 @@ import asyncio from unittest import mock -# Using third party package because of a bug reading binary data in Python 3.4 -# https://bugs.python.org/issue23004 -from mock_open import MockOpen - from homeassistant.components.camera import DOMAIN from homeassistant.components.camera.local_file import ( SERVICE_UPDATE_FILE_PATH) @@ -30,7 +26,7 @@ def test_loading_file(hass, aiohttp_client): client = yield from aiohttp_client(hass.http.app) - m_open = MockOpen(read_data=b'hello') + m_open = mock.mock_open(read_data=b'hello') with mock.patch( 'homeassistant.components.camera.local_file.open', m_open, create=True @@ -90,7 +86,7 @@ def test_camera_content_type(hass, aiohttp_client): client = yield from aiohttp_client(hass.http.app) image = 'hello' - m_open = MockOpen(read_data=image.encode()) + m_open = mock.mock_open(read_data=image.encode()) with mock.patch('homeassistant.components.camera.local_file.open', m_open, create=True): resp_1 = yield from client.get('/api/camera_proxy/camera.test_jpg') diff --git a/tests/components/camera/test_push.py b/tests/components/camera/test_push.py new file mode 100644 index 00000000000000..78053e540f5cd8 --- /dev/null +++ b/tests/components/camera/test_push.py @@ -0,0 +1,63 @@ +"""The tests for generic camera component.""" +import io + +from datetime import timedelta + +from homeassistant import core as ha +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util +from tests.components.auth import async_setup_auth + + +async def test_bad_posting(aioclient_mock, hass, aiohttp_client): + """Test that posting to wrong api endpoint fails.""" + await async_setup_component(hass, 'camera', { + 'camera': { + 'platform': 'push', + 'name': 'config_test', + }}) + + client = await async_setup_auth(hass, aiohttp_client) + + # missing file + resp = await client.post('/api/camera_push/camera.config_test') + assert resp.status == 400 + + files = {'image': io.BytesIO(b'fake')} + + # wrong entity + resp = await client.post('/api/camera_push/camera.wrong', data=files) + assert resp.status == 400 + + +async def test_posting_url(aioclient_mock, hass, aiohttp_client): + """Test that posting to api endpoint works.""" + await async_setup_component(hass, 'camera', { + 'camera': { + 'platform': 'push', + 'name': 'config_test', + }}) + + client = await async_setup_auth(hass, aiohttp_client) + files = {'image': io.BytesIO(b'fake')} + + # initial state + camera_state = hass.states.get('camera.config_test') + assert camera_state.state == 'idle' + + # post image + resp = await client.post('/api/camera_push/camera.config_test', data=files) + assert resp.status == 200 + + # state recording + camera_state = hass.states.get('camera.config_test') + assert camera_state.state == 'recording' + + # await timeout + shifted_time = dt_util.utcnow() + timedelta(seconds=15) + hass.bus.async_fire(ha.EVENT_TIME_CHANGED, {ha.ATTR_NOW: shifted_time}) + await hass.async_block_till_done() + + # back to initial state + camera_state = hass.states.get('camera.config_test') + assert camera_state.state == 'idle' diff --git a/tests/components/camera/test_uvc.py b/tests/components/camera/test_uvc.py index 40b4fb2d8e2612..18292d32a024fc 100644 --- a/tests/components/camera/test_uvc.py +++ b/tests/components/camera/test_uvc.py @@ -7,6 +7,7 @@ from uvcclient import camera from uvcclient import nvr +from homeassistant.exceptions import PlatformNotReady from homeassistant.setup import setup_component from homeassistant.components.camera import uvc from tests.common import get_test_home_assistant @@ -26,7 +27,7 @@ def tearDown(self): @mock.patch('uvcclient.nvr.UVCRemote') @mock.patch.object(uvc, 'UnifiVideoCamera') def test_setup_full_config(self, mock_uvc, mock_remote): - """"Test the setup with full configuration.""" + """Test the setup with full configuration.""" config = { 'platform': 'uvc', 'nvr': 'foo', @@ -34,21 +35,20 @@ def test_setup_full_config(self, mock_uvc, mock_remote): 'port': 123, 'key': 'secret', } - fake_cameras = [ + mock_cameras = [ {'uuid': 'one', 'name': 'Front', 'id': 'id1'}, {'uuid': 'two', 'name': 'Back', 'id': 'id2'}, {'uuid': 'three', 'name': 'Old AirCam', 'id': 'id3'}, ] - def fake_get_camera(uuid): - """"Create a fake camera.""" + def mock_get_camera(uuid): + """Create a mock camera.""" if uuid == 'id3': return {'model': 'airCam'} - else: - return {'model': 'UVC'} + return {'model': 'UVC'} - mock_remote.return_value.index.return_value = fake_cameras - mock_remote.return_value.get_camera.side_effect = fake_get_camera + mock_remote.return_value.index.return_value = mock_cameras + mock_remote.return_value.get_camera.side_effect = mock_get_camera mock_remote.return_value.server_version = (3, 2, 0) assert setup_component(self.hass, 'camera', {'camera': config}) @@ -65,17 +65,17 @@ def fake_get_camera(uuid): @mock.patch('uvcclient.nvr.UVCRemote') @mock.patch.object(uvc, 'UnifiVideoCamera') def test_setup_partial_config(self, mock_uvc, mock_remote): - """"Test the setup with partial configuration.""" + """Test the setup with partial configuration.""" config = { 'platform': 'uvc', 'nvr': 'foo', 'key': 'secret', } - fake_cameras = [ + mock_cameras = [ {'uuid': 'one', 'name': 'Front', 'id': 'id1'}, {'uuid': 'two', 'name': 'Back', 'id': 'id2'}, ] - mock_remote.return_value.index.return_value = fake_cameras + mock_remote.return_value.index.return_value = mock_cameras mock_remote.return_value.get_camera.return_value = {'model': 'UVC'} mock_remote.return_value.server_version = (3, 2, 0) @@ -99,11 +99,11 @@ def test_setup_partial_config_v31x(self, mock_uvc, mock_remote): 'nvr': 'foo', 'key': 'secret', } - fake_cameras = [ + mock_cameras = [ {'uuid': 'one', 'name': 'Front', 'id': 'id1'}, {'uuid': 'two', 'name': 'Back', 'id': 'id2'}, ] - mock_remote.return_value.index.return_value = fake_cameras + mock_remote.return_value.index.return_value = mock_cameras mock_remote.return_value.get_camera.return_value = {'model': 'UVC'} mock_remote.return_value.server_version = (3, 1, 3) @@ -133,26 +133,69 @@ def test_setup_incomplete_config(self, mock_uvc): @mock.patch.object(uvc, 'UnifiVideoCamera') @mock.patch('uvcclient.nvr.UVCRemote') - def test_setup_nvr_errors(self, mock_remote, mock_uvc): - """Test for NVR errors.""" - errors = [nvr.NotAuthorized, nvr.NvrError, - requests.exceptions.ConnectionError] + def setup_nvr_errors_during_indexing(self, error, mock_remote, mock_uvc): + """Setup test for NVR errors during indexing.""" config = { 'platform': 'uvc', 'nvr': 'foo', 'key': 'secret', } - for error in errors: - mock_remote.return_value.index.side_effect = error - assert setup_component(self.hass, 'camera', config) - assert not mock_uvc.called + mock_remote.return_value.index.side_effect = error + assert setup_component(self.hass, 'camera', {'camera': config}) + assert not mock_uvc.called + + def test_setup_nvr_error_during_indexing_notauthorized(self): + """Test for error: nvr.NotAuthorized.""" + self.setup_nvr_errors_during_indexing(nvr.NotAuthorized) + + def test_setup_nvr_error_during_indexing_nvrerror(self): + """Test for error: nvr.NvrError.""" + self.setup_nvr_errors_during_indexing(nvr.NvrError) + self.assertRaises(PlatformNotReady) + + def test_setup_nvr_error_during_indexing_connectionerror(self): + """Test for error: requests.exceptions.ConnectionError.""" + self.setup_nvr_errors_during_indexing( + requests.exceptions.ConnectionError) + self.assertRaises(PlatformNotReady) + + @mock.patch.object(uvc, 'UnifiVideoCamera') + @mock.patch('uvcclient.nvr.UVCRemote.__init__') + def setup_nvr_errors_during_initialization(self, error, mock_remote, + mock_uvc): + """Setup test for NVR errors during initialization.""" + config = { + 'platform': 'uvc', + 'nvr': 'foo', + 'key': 'secret', + } + mock_remote.return_value = None + mock_remote.side_effect = error + assert setup_component(self.hass, 'camera', {'camera': config}) + assert not mock_remote.index.called + assert not mock_uvc.called + + def test_setup_nvr_error_during_initialization_notauthorized(self): + """Test for error: nvr.NotAuthorized.""" + self.setup_nvr_errors_during_initialization(nvr.NotAuthorized) + + def test_setup_nvr_error_during_initialization_nvrerror(self): + """Test for error: nvr.NvrError.""" + self.setup_nvr_errors_during_initialization(nvr.NvrError) + self.assertRaises(PlatformNotReady) + + def test_setup_nvr_error_during_initialization_connectionerror(self): + """Test for error: requests.exceptions.ConnectionError.""" + self.setup_nvr_errors_during_initialization( + requests.exceptions.ConnectionError) + self.assertRaises(PlatformNotReady) class TestUVC(unittest.TestCase): """Test class for UVC.""" def setup_method(self, method): - """"Setup the mock camera.""" + """Setup the mock camera.""" self.nvr = mock.MagicMock() self.uuid = 'uuid' self.name = 'name' @@ -171,7 +214,7 @@ def setup_method(self, method): self.nvr.server_version = (3, 2, 0) def test_properties(self): - """"Test the properties.""" + """Test the properties.""" self.assertEqual(self.name, self.uvc.name) self.assertTrue(self.uvc.is_recording) self.assertEqual('Ubiquiti', self.uvc.brand) @@ -180,7 +223,7 @@ def test_properties(self): @mock.patch('uvcclient.store.get_info_store') @mock.patch('uvcclient.camera.UVCCameraClientV320') def test_login(self, mock_camera, mock_store): - """"Test the login.""" + """Test the login.""" self.uvc._login() self.assertEqual(mock_camera.call_count, 1) self.assertEqual( @@ -205,11 +248,11 @@ def test_login_v31x(self, mock_camera, mock_store): @mock.patch('uvcclient.store.get_info_store') @mock.patch('uvcclient.camera.UVCCameraClientV320') def test_login_tries_both_addrs_and_caches(self, mock_camera, mock_store): - """"Test the login tries.""" + """Test the login tries.""" responses = [0] - def fake_login(*a): - """Fake login.""" + def mock_login(*a): + """Mock login.""" try: responses.pop(0) raise socket.error @@ -217,7 +260,7 @@ def fake_login(*a): pass mock_store.return_value.get_camera_password.return_value = None - mock_camera.return_value.login.side_effect = fake_login + mock_camera.return_value.login.side_effect = mock_login self.uvc._login() self.assertEqual(2, mock_camera.call_count) self.assertEqual('host-b', self.uvc._connect_addr) @@ -234,13 +277,13 @@ def fake_login(*a): @mock.patch('uvcclient.store.get_info_store') @mock.patch('uvcclient.camera.UVCCameraClientV320') def test_login_fails_both_properly(self, mock_camera, mock_store): - """"Test if login fails properly.""" + """Test if login fails properly.""" mock_camera.return_value.login.side_effect = socket.error self.assertEqual(None, self.uvc._login()) self.assertEqual(None, self.uvc._connect_addr) def test_camera_image_tries_login_bails_on_failure(self): - """"Test retrieving failure.""" + """Test retrieving failure.""" with mock.patch.object(self.uvc, '_login') as mock_login: mock_login.return_value = False self.assertEqual(None, self.uvc.camera_image()) @@ -248,23 +291,23 @@ def test_camera_image_tries_login_bails_on_failure(self): self.assertEqual(mock_login.call_args, mock.call()) def test_camera_image_logged_in(self): - """"Test the login state.""" + """Test the login state.""" self.uvc._camera = mock.MagicMock() self.assertEqual(self.uvc._camera.get_snapshot.return_value, self.uvc.camera_image()) def test_camera_image_error(self): - """"Test the camera image error.""" + """Test the camera image error.""" self.uvc._camera = mock.MagicMock() self.uvc._camera.get_snapshot.side_effect = camera.CameraConnectError self.assertEqual(None, self.uvc.camera_image()) def test_camera_image_reauths(self): - """"Test the re-authentication.""" + """Test the re-authentication.""" responses = [0] - def fake_snapshot(): - """Fake snapshot.""" + def mock_snapshot(): + """Mock snapshot.""" try: responses.pop() raise camera.CameraAuthError() @@ -273,7 +316,7 @@ def fake_snapshot(): return 'image' self.uvc._camera = mock.MagicMock() - self.uvc._camera.get_snapshot.side_effect = fake_snapshot + self.uvc._camera.get_snapshot.side_effect = mock_snapshot with mock.patch.object(self.uvc, '_login') as mock_login: self.assertEqual('image', self.uvc.camera_image()) self.assertEqual(mock_login.call_count, 1) @@ -281,7 +324,7 @@ def fake_snapshot(): self.assertEqual([], responses) def test_camera_image_reauths_only_once(self): - """"Test if the re-authentication only happens once.""" + """Test if the re-authentication only happens once.""" self.uvc._camera = mock.MagicMock() self.uvc._camera.get_snapshot.side_effect = camera.CameraAuthError with mock.patch.object(self.uvc, '_login') as mock_login: diff --git a/tests/components/cast/__init__.py b/tests/components/cast/__init__.py new file mode 100644 index 00000000000000..7e904dce00af63 --- /dev/null +++ b/tests/components/cast/__init__.py @@ -0,0 +1 @@ +"""Tests for the Cast component.""" diff --git a/tests/components/cast/test_init.py b/tests/components/cast/test_init.py new file mode 100644 index 00000000000000..1ffbd375b753a7 --- /dev/null +++ b/tests/components/cast/test_init.py @@ -0,0 +1,54 @@ +"""Tests for the Cast config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries, data_entry_flow +from homeassistant.setup import async_setup_component +from homeassistant.components import cast + +from tests.common import MockDependency, mock_coro + + +async def test_creating_entry_sets_up_media_player(hass): + """Test setting up Cast loads the media player.""" + with patch('homeassistant.components.media_player.cast.async_setup_entry', + return_value=mock_coro(True)) as mock_setup, \ + MockDependency('pychromecast', 'discovery'), \ + patch('pychromecast.discovery.discover_chromecasts', + return_value=True): + result = await hass.config_entries.flow.async_init( + cast.DOMAIN, context={'source': config_entries.SOURCE_USER}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + await hass.async_block_till_done() + + assert len(mock_setup.mock_calls) == 1 + + +async def test_configuring_cast_creates_entry(hass): + """Test that specifying config will create an entry.""" + with patch('homeassistant.components.cast.async_setup_entry', + return_value=mock_coro(True)) as mock_setup, \ + MockDependency('pychromecast', 'discovery'), \ + patch('pychromecast.discovery.discover_chromecasts', + return_value=True): + await async_setup_component(hass, cast.DOMAIN, { + 'cast': { + 'some_config': 'to_trigger_import' + } + }) + await hass.async_block_till_done() + + assert len(mock_setup.mock_calls) == 1 + + +async def test_not_configuring_cast_not_creates_entry(hass): + """Test that no config will not create an entry.""" + with patch('homeassistant.components.cast.async_setup_entry', + return_value=mock_coro(True)) as mock_setup, \ + MockDependency('pychromecast', 'discovery'), \ + patch('pychromecast.discovery.discover_chromecasts', + return_value=True): + await async_setup_component(hass, cast.DOMAIN, {}) + await hass.async_block_till_done() + + assert len(mock_setup.mock_calls) == 0 diff --git a/tests/components/climate/test_fritzbox.py b/tests/components/climate/test_fritzbox.py new file mode 100644 index 00000000000000..ccffef9e547b9e --- /dev/null +++ b/tests/components/climate/test_fritzbox.py @@ -0,0 +1,172 @@ +"""The tests for the demo climate component.""" +import unittest +from unittest.mock import Mock, patch + +import requests + +from homeassistant.components.climate.fritzbox import FritzboxThermostat + + +class TestFritzboxClimate(unittest.TestCase): + """Test Fritz!Box heating thermostats.""" + + def setUp(self): + """Create a mock device to test on.""" + self.device = Mock() + self.device.name = 'Test Thermostat' + self.device.actual_temperature = 18.0 + self.device.target_temperature = 19.5 + self.device.comfort_temperature = 22.0 + self.device.eco_temperature = 16.0 + self.device.present = True + self.device.device_lock = True + self.device.lock = False + self.device.battery_low = True + self.device.set_target_temperature = Mock() + self.device.update = Mock() + mock_fritz = Mock() + mock_fritz.login = Mock() + self.thermostat = FritzboxThermostat(self.device, mock_fritz) + + def test_init(self): + """Test instance creation.""" + self.assertEqual(18.0, self.thermostat._current_temperature) + self.assertEqual(19.5, self.thermostat._target_temperature) + self.assertEqual(22.0, self.thermostat._comfort_temperature) + self.assertEqual(16.0, self.thermostat._eco_temperature) + + def test_supported_features(self): + """Test supported features property.""" + self.assertEqual(129, self.thermostat.supported_features) + + def test_available(self): + """Test available property.""" + self.assertTrue(self.thermostat.available) + self.thermostat._device.present = False + self.assertFalse(self.thermostat.available) + + def test_name(self): + """Test name property.""" + self.assertEqual('Test Thermostat', self.thermostat.name) + + def test_temperature_unit(self): + """Test temperature_unit property.""" + self.assertEqual('°C', self.thermostat.temperature_unit) + + def test_precision(self): + """Test precision property.""" + self.assertEqual(0.5, self.thermostat.precision) + + def test_current_temperature(self): + """Test current_temperature property incl. special temperatures.""" + self.assertEqual(18, self.thermostat.current_temperature) + + def test_target_temperature(self): + """Test target_temperature property.""" + self.assertEqual(19.5, self.thermostat.target_temperature) + + self.thermostat._target_temperature = 126.5 + self.assertEqual(None, self.thermostat.target_temperature) + + self.thermostat._target_temperature = 127.0 + self.assertEqual(None, self.thermostat.target_temperature) + + @patch.object(FritzboxThermostat, 'set_operation_mode') + def test_set_temperature_operation_mode(self, mock_set_op): + """Test set_temperature by operation_mode.""" + self.thermostat.set_temperature(operation_mode='test_mode') + mock_set_op.assert_called_once_with('test_mode') + + def test_set_temperature_temperature(self): + """Test set_temperature by temperature.""" + self.thermostat.set_temperature(temperature=23.0) + self.thermostat._device.set_target_temperature.\ + assert_called_once_with(23.0) + + @patch.object(FritzboxThermostat, 'set_operation_mode') + def test_set_temperature_none(self, mock_set_op): + """Test set_temperature with no arguments.""" + self.thermostat.set_temperature() + mock_set_op.assert_not_called() + self.thermostat._device.set_target_temperature.assert_not_called() + + @patch.object(FritzboxThermostat, 'set_operation_mode') + def test_set_temperature_operation_mode_precedence(self, mock_set_op): + """Test set_temperature for precedence of operation_mode arguement.""" + self.thermostat.set_temperature(operation_mode='test_mode', + temperature=23.0) + mock_set_op.assert_called_once_with('test_mode') + self.thermostat._device.set_target_temperature.assert_not_called() + + def test_current_operation(self): + """Test operation mode property for different temperatures.""" + self.thermostat._target_temperature = 127.0 + self.assertEqual('on', self.thermostat.current_operation) + self.thermostat._target_temperature = 126.5 + self.assertEqual('off', self.thermostat.current_operation) + self.thermostat._target_temperature = 22.0 + self.assertEqual('heat', self.thermostat.current_operation) + self.thermostat._target_temperature = 16.0 + self.assertEqual('eco', self.thermostat.current_operation) + self.thermostat._target_temperature = 12.5 + self.assertEqual('manual', self.thermostat.current_operation) + + def test_operation_list(self): + """Test operation_list property.""" + self.assertEqual(['heat', 'eco', 'off', 'on'], + self.thermostat.operation_list) + + @patch.object(FritzboxThermostat, 'set_temperature') + def test_set_operation_mode(self, mock_set_temp): + """Test set_operation_mode by all modes and with a non-existing one.""" + values = { + 'heat': 22.0, + 'eco': 16.0, + 'on': 30.0, + 'off': 0.0} + for mode, temp in values.items(): + print(mode, temp) + + mock_set_temp.reset_mock() + self.thermostat.set_operation_mode(mode) + mock_set_temp.assert_called_once_with(temperature=temp) + + mock_set_temp.reset_mock() + self.thermostat.set_operation_mode('non_existing_mode') + mock_set_temp.assert_not_called() + + def test_min_max_temperature(self): + """Test min_temp and max_temp properties.""" + self.assertEqual(8.0, self.thermostat.min_temp) + self.assertEqual(28.0, self.thermostat.max_temp) + + def test_device_state_attributes(self): + """Test device_state property.""" + attr = self.thermostat.device_state_attributes + self.assertEqual(attr['device_locked'], True) + self.assertEqual(attr['locked'], False) + self.assertEqual(attr['battery_low'], True) + + def test_update(self): + """Test update function.""" + device = Mock() + device.update = Mock() + device.actual_temperature = 10.0 + device.target_temperature = 11.0 + device.comfort_temperature = 12.0 + device.eco_temperature = 13.0 + self.thermostat._device = device + + self.thermostat.update() + + device.update.assert_called_once_with() + self.assertEqual(10.0, self.thermostat._current_temperature) + self.assertEqual(11.0, self.thermostat._target_temperature) + self.assertEqual(12.0, self.thermostat._comfort_temperature) + self.assertEqual(13.0, self.thermostat._eco_temperature) + + def test_update_http_error(self): + """Test exception handling of update function.""" + self.device.update.side_effect = requests.exceptions.HTTPError + self.thermostat.update() + self.thermostat._fritz.login.assert_called_once_with() diff --git a/tests/components/climate/test_honeywell.py b/tests/components/climate/test_honeywell.py index b12c0c38f3ae12..69df11715e91a0 100644 --- a/tests/components/climate/test_honeywell.py +++ b/tests/components/climate/test_honeywell.py @@ -320,7 +320,7 @@ def test_set_temperature(self): self.device.set_temperature.call_args, mock.call('House', 25) ) - def test_set_operation_mode(self: unittest.TestCase) -> None: + def test_set_operation_mode(self) -> None: """Test setting the system operation.""" self.round1.set_operation_mode('cool') self.assertEqual('cool', self.round1.current_operation) @@ -384,7 +384,7 @@ def test_set_temp(self): self.assertEqual(74, self.device.setpoint_cool) self.assertEqual(74, self.honeywell.target_temperature) - def test_set_operation_mode(self: unittest.TestCase) -> None: + def test_set_operation_mode(self) -> None: """Test setting the operation mode.""" self.honeywell.set_operation_mode('cool') self.assertEqual('cool', self.device.system_mode) diff --git a/tests/components/climate/test_mqtt.py b/tests/components/climate/test_mqtt.py index 663393503aca86..5db77331cd4038 100644 --- a/tests/components/climate/test_mqtt.py +++ b/tests/components/climate/test_mqtt.py @@ -9,9 +9,9 @@ from homeassistant.components import climate from homeassistant.const import STATE_OFF, STATE_UNAVAILABLE from homeassistant.components.climate import ( - SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, - SUPPORT_FAN_MODE, SUPPORT_SWING_MODE, SUPPORT_HOLD_MODE, - SUPPORT_AWAY_MODE, SUPPORT_AUX_HEAT) + SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, + SUPPORT_FAN_MODE, SUPPORT_SWING_MODE, SUPPORT_HOLD_MODE, + SUPPORT_AWAY_MODE, SUPPORT_AUX_HEAT, DEFAULT_MIN_TEMP, DEFAULT_MAX_TEMP) from tests.common import (get_test_home_assistant, mock_mqtt_component, fire_mqtt_message, mock_component) @@ -53,6 +53,8 @@ def test_setup_params(self): self.assertEqual("low", state.attributes.get('fan_mode')) self.assertEqual("off", state.attributes.get('swing_mode')) self.assertEqual("off", state.attributes.get('operation_mode')) + self.assertEqual(DEFAULT_MIN_TEMP, state.attributes.get('min_temp')) + self.assertEqual(DEFAULT_MAX_TEMP, state.attributes.get('max_temp')) def test_supported_features(self): """Test the supported_features.""" @@ -135,6 +137,37 @@ def test_set_operation_pessimistic(self): self.assertEqual("cool", state.attributes.get('operation_mode')) self.assertEqual("cool", state.state) + def test_set_operation_with_power_command(self): + """Test setting of new operation mode with power command enabled.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config['climate']['power_command_topic'] = 'power-command' + assert setup_component(self.hass, climate.DOMAIN, config) + + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("off", state.attributes.get('operation_mode')) + self.assertEqual("off", state.state) + climate.set_operation_mode(self.hass, "on", ENTITY_CLIMATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("on", state.attributes.get('operation_mode')) + self.assertEqual("on", state.state) + self.mock_publish.async_publish.assert_has_calls([ + unittest.mock.call('power-command', 'ON', 0, False), + unittest.mock.call('mode-topic', 'on', 0, False) + ]) + self.mock_publish.async_publish.reset_mock() + + climate.set_operation_mode(self.hass, "off", ENTITY_CLIMATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual("off", state.attributes.get('operation_mode')) + self.assertEqual("off", state.state) + self.mock_publish.async_publish.assert_has_calls([ + unittest.mock.call('power-command', 'OFF', 0, False), + unittest.mock.call('mode-topic', 'off', 0, False) + ]) + self.mock_publish.async_publish.reset_mock() + def test_set_fan_mode_bad_attr(self): """Test setting fan mode without required attribute.""" assert setup_component(self.hass, climate.DOMAIN, DEFAULT_CONFIG) @@ -239,6 +272,8 @@ def test_set_target_temperature(self): self.assertEqual(21, state.attributes.get('temperature')) climate.set_operation_mode(self.hass, 'heat', ENTITY_CLIMATE) self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('heat', state.attributes.get('operation_mode')) self.mock_publish.async_publish.assert_called_once_with( 'mode-topic', 'heat', 0, False) self.mock_publish.async_publish.reset_mock() @@ -250,6 +285,21 @@ def test_set_target_temperature(self): self.mock_publish.async_publish.assert_called_once_with( 'temperature-topic', 47, 0, False) + # also test directly supplying the operation mode to set_temperature + self.mock_publish.async_publish.reset_mock() + climate.set_temperature(self.hass, temperature=21, + operation_mode="cool", + entity_id=ENTITY_CLIMATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('cool', state.attributes.get('operation_mode')) + self.assertEqual(21, state.attributes.get('temperature')) + self.mock_publish.async_publish.assert_has_calls([ + unittest.mock.call('mode-topic', 'cool', 0, False), + unittest.mock.call('temperature-topic', 21, 0, False) + ]) + self.mock_publish.async_publish.reset_mock() + def test_set_target_temperature_pessimistic(self): """Test setting the target temperature.""" config = copy.deepcopy(DEFAULT_CONFIG) @@ -506,13 +556,28 @@ def test_set_with_templates(self): state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual("on", state.attributes.get('swing_mode')) - # Temperature + # Temperature - with valid value self.assertEqual(21, state.attributes.get('temperature')) fire_mqtt_message(self.hass, 'temperature-state', '"1031"') self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual(1031, state.attributes.get('temperature')) + # Temperature - with invalid value + with self.assertLogs(level='ERROR') as log: + fire_mqtt_message(self.hass, 'temperature-state', '"-INVALID-"') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + # make sure, the invalid value gets logged... + self.assertEqual(len(log.output), 1) + self.assertEqual(len(log.records), 1) + self.assertIn( + "Could not parse temperature from -INVALID-", + log.output[0] + ) + # ... but the actual value stays unchanged. + self.assertEqual(1031, state.attributes.get('temperature')) + # Away Mode self.assertEqual('off', state.attributes.get('away_mode')) fire_mqtt_message(self.hass, 'away-state', '"ON"') @@ -520,6 +585,17 @@ def test_set_with_templates(self): state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual('on', state.attributes.get('away_mode')) + # Away Mode with JSON values + fire_mqtt_message(self.hass, 'away-state', 'false') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('off', state.attributes.get('away_mode')) + + fire_mqtt_message(self.hass, 'away-state', 'true') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('on', state.attributes.get('away_mode')) + # Hold Mode self.assertEqual(None, state.attributes.get('hold_mode')) fire_mqtt_message(self.hass, 'hold-state', """ @@ -536,8 +612,40 @@ def test_set_with_templates(self): state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual('on', state.attributes.get('aux_heat')) + # anything other than 'switchmeon' should turn Aux mode off + fire_mqtt_message(self.hass, 'aux-state', 'somerandomstring') + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_CLIMATE) + self.assertEqual('off', state.attributes.get('aux_heat')) + # Current temperature fire_mqtt_message(self.hass, 'current-temperature', '"74656"') self.hass.block_till_done() state = self.hass.states.get(ENTITY_CLIMATE) self.assertEqual(74656, state.attributes.get('current_temperature')) + + def test_min_temp_custom(self): + """Test a custom min temp.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config['climate']['min_temp'] = 26 + + assert setup_component(self.hass, climate.DOMAIN, config) + + state = self.hass.states.get(ENTITY_CLIMATE) + min_temp = state.attributes.get('min_temp') + + self.assertIsInstance(min_temp, float) + self.assertEqual(26, state.attributes.get('min_temp')) + + def test_max_temp_custom(self): + """Test a custom max temp.""" + config = copy.deepcopy(DEFAULT_CONFIG) + config['climate']['max_temp'] = 60 + + assert setup_component(self.hass, climate.DOMAIN, config) + + state = self.hass.states.get(ENTITY_CLIMATE) + max_temp = state.attributes.get('max_temp') + + self.assertIsInstance(max_temp, float) + self.assertEqual(60, max_temp) diff --git a/tests/components/climate/test_zwave.py b/tests/components/climate/test_zwave.py index fbd6ea7f798078..39a85ab493f545 100644 --- a/tests/components/climate/test_zwave.py +++ b/tests/components/climate/test_zwave.py @@ -1,9 +1,9 @@ """Test Z-Wave climate devices.""" import pytest -from homeassistant.components.climate import zwave +from homeassistant.components.climate import zwave, STATE_COOL, STATE_HEAT from homeassistant.const import ( - TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) + STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) from tests.mock.zwave import ( MockNode, MockValue, MockEntityValues, value_changed) @@ -46,6 +46,24 @@ def device_zxt_120(hass, mock_openzwave): yield device +@pytest.fixture +def device_mapping(hass, mock_openzwave): + """Fixture to provide a precreated climate device. Test state mapping.""" + node = MockNode() + values = MockEntityValues( + primary=MockValue(data=1, node=node), + temperature=MockValue(data=5, node=node, units=None), + mode=MockValue(data='Off', data_items=['Off', 'Cool', 'Heat'], + node=node), + fan_mode=MockValue(data='test2', data_items=[3, 4, 5], node=node), + operating_state=MockValue(data=6, node=node), + fan_state=MockValue(data=7, node=node), + ) + device = zwave.get_device(hass, node=node, values=values, node_config={}) + + yield device + + def test_zxt_120_swing_mode(device_zxt_120): """Test operation of the zxt 120 swing mode.""" device = device_zxt_120 @@ -109,6 +127,18 @@ def test_operation_value_set(device): assert device.values.mode.data == 'test_set' +def test_operation_value_set_mapping(device_mapping): + """Test values changed for climate device. Mapping.""" + device = device_mapping + assert device.values.mode.data == 'Off' + device.set_operation_mode(STATE_HEAT) + assert device.values.mode.data == 'Heat' + device.set_operation_mode(STATE_COOL) + assert device.values.mode.data == 'Cool' + device.set_operation_mode(STATE_OFF) + assert device.values.mode.data == 'Off' + + def test_fan_mode_value_set(device): """Test values changed for climate device.""" assert device.values.fan_mode.data == 'test2' @@ -140,6 +170,21 @@ def test_operation_value_changed(device): assert device.current_operation == 'test_updated' +def test_operation_value_changed_mapping(device_mapping): + """Test values changed for climate device. Mapping.""" + device = device_mapping + assert device.current_operation == 'off' + device.values.mode.data = 'Heat' + value_changed(device.values.mode) + assert device.current_operation == STATE_HEAT + device.values.mode.data = 'Cool' + value_changed(device.values.mode) + assert device.current_operation == STATE_COOL + device.values.mode.data = 'Off' + value_changed(device.values.mode) + assert device.current_operation == STATE_OFF + + def test_fan_mode_value_changed(device): """Test values changed for climate device.""" assert device.current_fan_mode == 'test2' diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 91f8ab8316ded0..014cdb1c6c6f18 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -73,8 +73,7 @@ def test_constructor_loads_info_from_config(): assert cl.relayer == 'test-relayer' -@asyncio.coroutine -def test_initialize_loads_info(mock_os, hass): +async def test_initialize_loads_info(mock_os, hass): """Test initialize will load info from config file.""" mock_os.path.isfile.return_value = True mopen = mock_open(read_data=json.dumps({ @@ -88,8 +87,10 @@ def test_initialize_loads_info(mock_os, hass): cl.iot.connect.return_value = mock_coro() with patch('homeassistant.components.cloud.open', mopen, create=True), \ + patch('homeassistant.components.cloud.Cloud._fetch_jwt_keyset', + return_value=mock_coro(True)), \ patch('homeassistant.components.cloud.Cloud._decode_claims'): - yield from cl.async_start(None) + await cl.async_start(None) assert cl.id_token == 'test-id-token' assert cl.access_token == 'test-access-token' diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py index 81b1e3150853ae..1b580d0eb9b70b 100644 --- a/tests/components/cloud/test_iot.py +++ b/tests/components/cloud/test_iot.py @@ -210,7 +210,7 @@ def test_cloud_connect_invalid_auth(mock_client, caplog, mock_cloud): """Test invalid auth detected by server.""" conn = iot.CloudIoT(mock_cloud) mock_client.receive.side_effect = \ - client_exceptions.WSServerHandshakeError(None, None, code=401) + client_exceptions.WSServerHandshakeError(None, None, status=401) yield from conn.connect() diff --git a/tests/components/config/test_auth.py b/tests/components/config/test_auth.py new file mode 100644 index 00000000000000..cd04eedf08eb29 --- /dev/null +++ b/tests/components/config/test_auth.py @@ -0,0 +1,219 @@ +"""Test config entries API.""" +from unittest.mock import PropertyMock, patch + +import pytest + +from homeassistant.auth import models as auth_models +from homeassistant.components.config import auth as auth_config + +from tests.common import MockUser, CLIENT_ID + + +@pytest.fixture(autouse=True) +def auth_active(hass): + """Mock that auth is active.""" + with patch('homeassistant.auth.AuthManager.active', + PropertyMock(return_value=True)): + yield + + +@pytest.fixture(autouse=True) +def setup_config(hass, aiohttp_client): + """Fixture that sets up the auth provider homeassistant module.""" + hass.loop.run_until_complete(auth_config.async_setup(hass)) + + +async def test_list_requires_owner(hass, hass_ws_client, hass_access_token): + """Test get users requires auth.""" + client = await hass_ws_client(hass, hass_access_token) + + await client.send_json({ + 'id': 5, + 'type': auth_config.WS_TYPE_LIST, + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'unauthorized' + + +async def test_list(hass, hass_ws_client): + """Test get users.""" + owner = MockUser( + id='abc', + name='Test Owner', + is_owner=True, + ).add_to_hass(hass) + + owner.credentials.append(auth_models.Credentials( + auth_provider_type='homeassistant', + auth_provider_id=None, + data={}, + )) + + system = MockUser( + id='efg', + name='Test Hass.io', + system_generated=True + ).add_to_hass(hass) + + inactive = MockUser( + id='hij', + name='Inactive User', + is_active=False, + ).add_to_hass(hass) + + refresh_token = await hass.auth.async_create_refresh_token( + owner, CLIENT_ID) + access_token = hass.auth.async_create_access_token(refresh_token) + + client = await hass_ws_client(hass, access_token) + await client.send_json({ + 'id': 5, + 'type': auth_config.WS_TYPE_LIST, + }) + + result = await client.receive_json() + assert result['success'], result + data = result['result'] + assert len(data) == 3 + assert data[0] == { + 'id': owner.id, + 'name': 'Test Owner', + 'is_owner': True, + 'is_active': True, + 'system_generated': False, + 'credentials': [{'type': 'homeassistant'}] + } + assert data[1] == { + 'id': system.id, + 'name': 'Test Hass.io', + 'is_owner': False, + 'is_active': True, + 'system_generated': True, + 'credentials': [], + } + assert data[2] == { + 'id': inactive.id, + 'name': 'Inactive User', + 'is_owner': False, + 'is_active': False, + 'system_generated': False, + 'credentials': [], + } + + +async def test_delete_requires_owner(hass, hass_ws_client, hass_access_token): + """Test delete command requires an owner.""" + client = await hass_ws_client(hass, hass_access_token) + + await client.send_json({ + 'id': 5, + 'type': auth_config.WS_TYPE_DELETE, + 'user_id': 'abcd', + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'unauthorized' + + +async def test_delete_unable_self_account(hass, hass_ws_client, + hass_access_token): + """Test we cannot delete our own account.""" + client = await hass_ws_client(hass, hass_access_token) + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + + await client.send_json({ + 'id': 5, + 'type': auth_config.WS_TYPE_DELETE, + 'user_id': refresh_token.user.id, + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'unauthorized' + + +async def test_delete_unknown_user(hass, hass_ws_client, hass_access_token): + """Test we cannot delete an unknown user.""" + client = await hass_ws_client(hass, hass_access_token) + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + refresh_token.user.is_owner = True + + await client.send_json({ + 'id': 5, + 'type': auth_config.WS_TYPE_DELETE, + 'user_id': 'abcd', + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'not_found' + + +async def test_delete(hass, hass_ws_client, hass_access_token): + """Test delete command works.""" + client = await hass_ws_client(hass, hass_access_token) + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + refresh_token.user.is_owner = True + test_user = MockUser( + id='efg', + ).add_to_hass(hass) + + assert len(await hass.auth.async_get_users()) == 2 + + await client.send_json({ + 'id': 5, + 'type': auth_config.WS_TYPE_DELETE, + 'user_id': test_user.id, + }) + + result = await client.receive_json() + assert result['success'], result + assert len(await hass.auth.async_get_users()) == 1 + + +async def test_create(hass, hass_ws_client, hass_access_token): + """Test create command works.""" + client = await hass_ws_client(hass, hass_access_token) + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + refresh_token.user.is_owner = True + + assert len(await hass.auth.async_get_users()) == 1 + + await client.send_json({ + 'id': 5, + 'type': auth_config.WS_TYPE_CREATE, + 'name': 'Paulus', + }) + + result = await client.receive_json() + assert result['success'], result + assert len(await hass.auth.async_get_users()) == 2 + data_user = result['result']['user'] + user = await hass.auth.async_get_user(data_user['id']) + assert user is not None + assert user.name == data_user['name'] + assert user.is_active + assert not user.is_owner + assert not user.system_generated + + +async def test_create_requires_owner(hass, hass_ws_client, hass_access_token): + """Test create command requires an owner.""" + client = await hass_ws_client(hass, hass_access_token) + + await client.send_json({ + 'id': 5, + 'type': auth_config.WS_TYPE_CREATE, + 'name': 'YO', + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'unauthorized' diff --git a/tests/components/config/test_auth_provider_homeassistant.py b/tests/components/config/test_auth_provider_homeassistant.py new file mode 100644 index 00000000000000..a374083c2aba9e --- /dev/null +++ b/tests/components/config/test_auth_provider_homeassistant.py @@ -0,0 +1,321 @@ +"""Test config entries API.""" +import pytest + +from homeassistant.auth.providers import homeassistant as prov_ha +from homeassistant.components.config import ( + auth_provider_homeassistant as auth_ha) + +from tests.common import MockUser, register_auth_provider + + +@pytest.fixture(autouse=True) +def setup_config(hass): + """Fixture that sets up the auth provider homeassistant module.""" + hass.loop.run_until_complete(register_auth_provider(hass, { + 'type': 'homeassistant' + })) + hass.loop.run_until_complete(auth_ha.async_setup(hass)) + + +async def test_create_auth_system_generated_user(hass, hass_access_token, + hass_ws_client): + """Test we can't add auth to system generated users.""" + system_user = MockUser(system_generated=True).add_to_hass(hass) + client = await hass_ws_client(hass, hass_access_token) + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + refresh_token.user.is_owner = True + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_CREATE, + 'user_id': system_user.id, + 'username': 'test-user', + 'password': 'test-pass', + }) + + result = await client.receive_json() + + assert not result['success'], result + assert result['error']['code'] == 'system_generated' + + +async def test_create_auth_user_already_credentials(): + """Test we can't create auth for user with pre-existing credentials.""" + # assert False + + +async def test_create_auth_unknown_user(hass_ws_client, hass, + hass_access_token): + """Test create pointing at unknown user.""" + client = await hass_ws_client(hass, hass_access_token) + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + refresh_token.user.is_owner = True + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_CREATE, + 'user_id': 'test-id', + 'username': 'test-user', + 'password': 'test-pass', + }) + + result = await client.receive_json() + + assert not result['success'], result + assert result['error']['code'] == 'not_found' + + +async def test_create_auth_requires_owner(hass, hass_ws_client, + hass_access_token): + """Test create requires owner to call API.""" + client = await hass_ws_client(hass, hass_access_token) + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_CREATE, + 'user_id': 'test-id', + 'username': 'test-user', + 'password': 'test-pass', + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'unauthorized' + + +async def test_create_auth(hass, hass_ws_client, hass_access_token, + hass_storage): + """Test create auth command works.""" + client = await hass_ws_client(hass, hass_access_token) + user = MockUser().add_to_hass(hass) + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + refresh_token.user.is_owner = True + + assert len(user.credentials) == 0 + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_CREATE, + 'user_id': user.id, + 'username': 'test-user', + 'password': 'test-pass', + }) + + result = await client.receive_json() + assert result['success'], result + assert len(user.credentials) == 1 + creds = user.credentials[0] + assert creds.auth_provider_type == 'homeassistant' + assert creds.auth_provider_id is None + assert creds.data == { + 'username': 'test-user' + } + assert prov_ha.STORAGE_KEY in hass_storage + entry = hass_storage[prov_ha.STORAGE_KEY]['data']['users'][0] + assert entry['username'] == 'test-user' + + +async def test_create_auth_duplicate_username(hass, hass_ws_client, + hass_access_token, hass_storage): + """Test we can't create auth with a duplicate username.""" + client = await hass_ws_client(hass, hass_access_token) + user = MockUser().add_to_hass(hass) + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + refresh_token.user.is_owner = True + + hass_storage[prov_ha.STORAGE_KEY] = { + 'version': 1, + 'data': { + 'users': [{ + 'username': 'test-user' + }] + } + } + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_CREATE, + 'user_id': user.id, + 'username': 'test-user', + 'password': 'test-pass', + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'username_exists' + + +async def test_delete_removes_just_auth(hass_ws_client, hass, hass_storage, + hass_access_token): + """Test deleting an auth without being connected to a user.""" + client = await hass_ws_client(hass, hass_access_token) + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + refresh_token.user.is_owner = True + + hass_storage[prov_ha.STORAGE_KEY] = { + 'version': 1, + 'data': { + 'users': [{ + 'username': 'test-user' + }] + } + } + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_DELETE, + 'username': 'test-user', + }) + + result = await client.receive_json() + assert result['success'], result + assert len(hass_storage[prov_ha.STORAGE_KEY]['data']['users']) == 0 + + +async def test_delete_removes_credential(hass, hass_ws_client, + hass_access_token, hass_storage): + """Test deleting auth that is connected to a user.""" + client = await hass_ws_client(hass, hass_access_token) + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + refresh_token.user.is_owner = True + + user = MockUser().add_to_hass(hass) + user.credentials.append( + await hass.auth.auth_providers[0].async_get_or_create_credentials({ + 'username': 'test-user'})) + + hass_storage[prov_ha.STORAGE_KEY] = { + 'version': 1, + 'data': { + 'users': [{ + 'username': 'test-user' + }] + } + } + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_DELETE, + 'username': 'test-user', + }) + + result = await client.receive_json() + assert result['success'], result + assert len(hass_storage[prov_ha.STORAGE_KEY]['data']['users']) == 0 + + +async def test_delete_requires_owner(hass, hass_ws_client, hass_access_token): + """Test delete requires owner.""" + client = await hass_ws_client(hass, hass_access_token) + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_DELETE, + 'username': 'test-user', + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'unauthorized' + + +async def test_delete_unknown_auth(hass, hass_ws_client, hass_access_token): + """Test trying to delete an unknown auth username.""" + client = await hass_ws_client(hass, hass_access_token) + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + refresh_token.user.is_owner = True + + await client.send_json({ + 'id': 5, + 'type': auth_ha.WS_TYPE_DELETE, + 'username': 'test-user', + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'auth_not_found' + + +async def test_change_password(hass, hass_ws_client, hass_access_token): + """Test that change password succeeds with valid password.""" + provider = hass.auth.auth_providers[0] + await provider.async_initialize() + await hass.async_add_executor_job( + provider.data.add_auth, 'test-user', 'test-pass') + + credentials = await provider.async_get_or_create_credentials({ + 'username': 'test-user' + }) + + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + user = refresh_token.user + await hass.auth.async_link_user(user, credentials) + + client = await hass_ws_client(hass, hass_access_token) + await client.send_json({ + 'id': 6, + 'type': auth_ha.WS_TYPE_CHANGE_PASSWORD, + 'current_password': 'test-pass', + 'new_password': 'new-pass' + }) + + result = await client.receive_json() + assert result['success'], result + await provider.async_validate_login('test-user', 'new-pass') + + +async def test_change_password_wrong_pw(hass, hass_ws_client, + hass_access_token): + """Test that change password fails with invalid password.""" + provider = hass.auth.auth_providers[0] + await provider.async_initialize() + await hass.async_add_executor_job( + provider.data.add_auth, 'test-user', 'test-pass') + + credentials = await provider.async_get_or_create_credentials({ + 'username': 'test-user' + }) + + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + user = refresh_token.user + await hass.auth.async_link_user(user, credentials) + + client = await hass_ws_client(hass, hass_access_token) + await client.send_json({ + 'id': 6, + 'type': auth_ha.WS_TYPE_CHANGE_PASSWORD, + 'current_password': 'wrong-pass', + 'new_password': 'new-pass' + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'invalid_password' + with pytest.raises(prov_ha.InvalidAuth): + await provider.async_validate_login('test-user', 'new-pass') + + +async def test_change_password_no_creds(hass, hass_ws_client, + hass_access_token): + """Test that change password fails with no credentials.""" + client = await hass_ws_client(hass, hass_access_token) + + await client.send_json({ + 'id': 6, + 'type': auth_ha.WS_TYPE_CHANGE_PASSWORD, + 'current_password': 'test-pass', + 'new_password': 'new-pass' + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'credentials_not_found' diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 84d15578e13d99..ba053050f997fb 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -102,14 +102,17 @@ def test_initialize_flow(hass, client): """Test we can initialize a flow.""" class TestFlow(FlowHandler): @asyncio.coroutine - def async_step_init(self, user_input=None): + def async_step_user(self, user_input=None): schema = OrderedDict() schema[vol.Required('username')] = str schema[vol.Required('password')] = str return self.async_show_form( - step_id='init', + step_id='user', data_schema=schema, + description_placeholders={ + 'url': 'https://example.com', + }, errors={ 'username': 'Should be unique.' } @@ -127,7 +130,7 @@ def async_step_init(self, user_input=None): assert data == { 'type': 'form', 'handler': 'test', - 'step_id': 'init', + 'step_id': 'user', 'data_schema': [ { 'name': 'username', @@ -140,6 +143,9 @@ def async_step_init(self, user_input=None): 'type': 'string' } ], + 'description_placeholders': { + 'url': 'https://example.com', + }, 'errors': { 'username': 'Should be unique.' } @@ -151,7 +157,7 @@ def test_abort(hass, client): """Test a flow that aborts.""" class TestFlow(FlowHandler): @asyncio.coroutine - def async_step_init(self, user_input=None): + def async_step_user(self, user_input=None): return self.async_abort(reason='bla') with patch.dict(HANDLERS, {'test': TestFlow}): @@ -179,7 +185,7 @@ class TestFlow(FlowHandler): VERSION = 1 @asyncio.coroutine - def async_step_init(self, user_input=None): + def async_step_user(self, user_input=None): return self.async_create_entry( title='Test Entry', data={'secret': 'account_token'} @@ -196,7 +202,6 @@ def async_step_init(self, user_input=None): 'handler': 'test', 'title': 'Test Entry', 'type': 'create_entry', - 'source': 'user', 'version': 1, } @@ -212,7 +217,7 @@ class TestFlow(FlowHandler): VERSION = 1 @asyncio.coroutine - def async_step_init(self, user_input=None): + def async_step_user(self, user_input=None): return self.async_show_form( step_id='account', data_schema=vol.Schema({ @@ -242,6 +247,7 @@ def async_step_account(self, user_input=None): 'type': 'string' } ], + 'description_placeholders': None, 'errors': None } @@ -257,7 +263,6 @@ def async_step_account(self, user_input=None): 'type': 'create_entry', 'title': 'user-title', 'version': 1, - 'source': 'user', } @@ -279,7 +284,7 @@ def async_step_account(self, user_input=None): with patch.dict(HANDLERS, {'test': TestFlow}): form = yield from hass.config_entries.flow.async_init( - 'test', source='hassio') + 'test', context={'source': 'hassio'}) resp = yield from client.get('/api/config/config_entries/flow') assert resp.status == 200 @@ -288,7 +293,7 @@ def async_step_account(self, user_input=None): { 'flow_id': form['flow_id'], 'handler': 'test', - 'source': 'hassio' + 'context': {'source': 'hassio'} } ] @@ -298,13 +303,13 @@ def test_get_progress_flow(hass, client): """Test we can query the API for same result as we get from init a flow.""" class TestFlow(FlowHandler): @asyncio.coroutine - def async_step_init(self, user_input=None): + def async_step_user(self, user_input=None): schema = OrderedDict() schema[vol.Required('username')] = str schema[vol.Required('password')] = str return self.async_show_form( - step_id='init', + step_id='user', data_schema=schema, errors={ 'username': 'Should be unique.' diff --git a/tests/components/config/test_entity_registry.py b/tests/components/config/test_entity_registry.py index fd7c69994776d8..559f29372de4a7 100644 --- a/tests/components/config/test_entity_registry.py +++ b/tests/components/config/test_entity_registry.py @@ -1,18 +1,16 @@ """Test entity_registry API.""" import pytest -from homeassistant.setup import async_setup_component from homeassistant.helpers.entity_registry import RegistryEntry from homeassistant.components.config import entity_registry from tests.common import mock_registry, MockEntity, MockEntityPlatform @pytest.fixture -def client(hass, aiohttp_client): +def client(hass, hass_ws_client): """Fixture that can interact with the config manager API.""" - hass.loop.run_until_complete(async_setup_component(hass, 'http', {})) hass.loop.run_until_complete(entity_registry.async_setup(hass)) - yield hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + yield hass.loop.run_until_complete(hass_ws_client(hass)) async def test_get_entity(hass, client): @@ -31,27 +29,33 @@ async def test_get_entity(hass, client): ), }) - resp = await client.get( - '/api/config/entity_registry/test_domain.name') - assert resp.status == 200 - data = await resp.json() - assert data == { + await client.send_json({ + 'id': 5, + 'type': 'config/entity_registry/get', + 'entity_id': 'test_domain.name', + }) + msg = await client.receive_json() + + assert msg['result'] == { 'entity_id': 'test_domain.name', 'name': 'Hello World' } - resp = await client.get( - '/api/config/entity_registry/test_domain.no_name') - assert resp.status == 200 - data = await resp.json() - assert data == { + await client.send_json({ + 'id': 6, + 'type': 'config/entity_registry/get', + 'entity_id': 'test_domain.no_name', + }) + msg = await client.receive_json() + + assert msg['result'] == { 'entity_id': 'test_domain.no_name', 'name': None } -async def test_update_entity(hass, client): - """Test get entry.""" +async def test_update_entity_name(hass, client): + """Test updating entity name.""" mock_registry(hass, { 'test_domain.world': RegistryEntry( entity_id='test_domain.world', @@ -69,13 +73,16 @@ async def test_update_entity(hass, client): assert state is not None assert state.name == 'before update' - resp = await client.post( - '/api/config/entity_registry/test_domain.world', json={ - 'name': 'after update' - }) - assert resp.status == 200 - data = await resp.json() - assert data == { + await client.send_json({ + 'id': 6, + 'type': 'config/entity_registry/update', + 'entity_id': 'test_domain.world', + 'name': 'after update', + }) + + msg = await client.receive_json() + + assert msg['result'] == { 'entity_id': 'test_domain.world', 'name': 'after update' } @@ -85,7 +92,7 @@ async def test_update_entity(hass, client): async def test_update_entity_no_changes(hass, client): - """Test get entry.""" + """Test update entity with no changes.""" mock_registry(hass, { 'test_domain.world': RegistryEntry( entity_id='test_domain.world', @@ -103,13 +110,16 @@ async def test_update_entity_no_changes(hass, client): assert state is not None assert state.name == 'name of entity' - resp = await client.post( - '/api/config/entity_registry/test_domain.world', json={ - 'name': 'name of entity' - }) - assert resp.status == 200 - data = await resp.json() - assert data == { + await client.send_json({ + 'id': 6, + 'type': 'config/entity_registry/update', + 'entity_id': 'test_domain.world', + 'name': 'name of entity', + }) + + msg = await client.receive_json() + + assert msg['result'] == { 'entity_id': 'test_domain.world', 'name': 'name of entity' } @@ -119,16 +129,59 @@ async def test_update_entity_no_changes(hass, client): async def test_get_nonexisting_entity(client): - """Test get entry.""" - resp = await client.get( - '/api/config/entity_registry/test_domain.non_existing') - assert resp.status == 404 + """Test get entry with nonexisting entity.""" + await client.send_json({ + 'id': 6, + 'type': 'config/entity_registry/get', + 'entity_id': 'test_domain.no_name', + }) + msg = await client.receive_json() + + assert not msg['success'] async def test_update_nonexisting_entity(client): - """Test get entry.""" - resp = await client.post( - '/api/config/entity_registry/test_domain.non_existing', json={ - 'name': 'some name' - }) - assert resp.status == 404 + """Test update a nonexisting entity.""" + await client.send_json({ + 'id': 6, + 'type': 'config/entity_registry/update', + 'entity_id': 'test_domain.no_name', + 'name': 'new-name' + }) + msg = await client.receive_json() + + assert not msg['success'] + + +async def test_update_entity_id(hass, client): + """Test update entity id.""" + mock_registry(hass, { + 'test_domain.world': RegistryEntry( + entity_id='test_domain.world', + unique_id='1234', + # Using component.async_add_entities is equal to platform "domain" + platform='test_platform', + ) + }) + platform = MockEntityPlatform(hass) + entity = MockEntity(unique_id='1234') + await platform.async_add_entities([entity]) + + assert hass.states.get('test_domain.world') is not None + + await client.send_json({ + 'id': 6, + 'type': 'config/entity_registry/update', + 'entity_id': 'test_domain.world', + 'new_entity_id': 'test_domain.planet', + }) + + msg = await client.receive_json() + + assert msg['result'] == { + 'entity_id': 'test_domain.planet', + 'name': None + } + + assert hass.states.get('test_domain.world') is None + assert hass.states.get('test_domain.planet') is not None diff --git a/tests/components/config/test_zwave.py b/tests/components/config/test_zwave.py index 672bafeaf2825d..8aae5c0a28b92a 100644 --- a/tests/components/config/test_zwave.py +++ b/tests/components/config/test_zwave.py @@ -367,3 +367,192 @@ def test_save_config(hass, client): result = yield from resp.json() assert network.write_config.called assert result == {'message': 'Z-Wave configuration saved to file.'} + + +async def test_get_protection_values(hass, client): + """Test getting protection values on node.""" + network = hass.data[DATA_NETWORK] = MagicMock() + node = MockNode(node_id=18, + command_classes=[const.COMMAND_CLASS_PROTECTION]) + value = MockValue( + value_id=123456, + index=0, + instance=1, + command_class=const.COMMAND_CLASS_PROTECTION) + value.label = 'Protection Test' + value.data_items = ['Unprotected', 'Protection by Sequence', + 'No Operation Possible'] + value.data = 'Unprotected' + network.nodes = {18: node} + node.value = value + + node.get_protection_item.return_value = "Unprotected" + node.get_protection_items.return_value = value.data_items + node.get_protections.return_value = {value.value_id: 'Object'} + + resp = await client.get('/api/zwave/protection/18') + + assert resp.status == 200 + result = await resp.json() + assert node.get_protections.called + assert node.get_protection_item.called + assert node.get_protection_items.called + assert result == { + 'value_id': '123456', + 'selected': 'Unprotected', + 'options': ['Unprotected', 'Protection by Sequence', + 'No Operation Possible'] + } + + +async def test_get_protection_values_nonexisting_node(hass, client): + """Test getting protection values on node with wrong nodeid.""" + network = hass.data[DATA_NETWORK] = MagicMock() + node = MockNode(node_id=18, + command_classes=[const.COMMAND_CLASS_PROTECTION]) + value = MockValue( + value_id=123456, + index=0, + instance=1, + command_class=const.COMMAND_CLASS_PROTECTION) + value.label = 'Protection Test' + value.data_items = ['Unprotected', 'Protection by Sequence', + 'No Operation Possible'] + value.data = 'Unprotected' + network.nodes = {17: node} + node.value = value + + resp = await client.get('/api/zwave/protection/18') + + assert resp.status == 404 + result = await resp.json() + assert not node.get_protections.called + assert not node.get_protection_item.called + assert not node.get_protection_items.called + assert result == {'message': 'Node not found'} + + +async def test_get_protection_values_without_protectionclass(hass, client): + """Test getting protection values on node without protectionclass.""" + network = hass.data[DATA_NETWORK] = MagicMock() + node = MockNode(node_id=18) + value = MockValue( + value_id=123456, + index=0, + instance=1) + network.nodes = {18: node} + node.value = value + + resp = await client.get('/api/zwave/protection/18') + + assert resp.status == 200 + result = await resp.json() + assert not node.get_protections.called + assert not node.get_protection_item.called + assert not node.get_protection_items.called + assert result == {} + + +async def test_set_protection_value(hass, client): + """Test setting protection value on node.""" + network = hass.data[DATA_NETWORK] = MagicMock() + node = MockNode(node_id=18, + command_classes=[const.COMMAND_CLASS_PROTECTION]) + value = MockValue( + value_id=123456, + index=0, + instance=1, + command_class=const.COMMAND_CLASS_PROTECTION) + value.label = 'Protection Test' + value.data_items = ['Unprotected', 'Protection by Sequence', + 'No Operation Possible'] + value.data = 'Unprotected' + network.nodes = {18: node} + node.value = value + + resp = await client.post( + '/api/zwave/protection/18', data=json.dumps({ + 'value_id': '123456', 'selection': 'Protection by Sequence'})) + + assert resp.status == 200 + result = await resp.json() + assert node.set_protection.called + assert result == {'message': 'Protection setting succsessfully set'} + + +async def test_set_protection_value_failed(hass, client): + """Test setting protection value failed on node.""" + network = hass.data[DATA_NETWORK] = MagicMock() + node = MockNode(node_id=18, + command_classes=[const.COMMAND_CLASS_PROTECTION]) + value = MockValue( + value_id=123456, + index=0, + instance=1, + command_class=const.COMMAND_CLASS_PROTECTION) + value.label = 'Protection Test' + value.data_items = ['Unprotected', 'Protection by Sequence', + 'No Operation Possible'] + value.data = 'Unprotected' + network.nodes = {18: node} + node.value = value + node.set_protection.return_value = False + + resp = await client.post( + '/api/zwave/protection/18', data=json.dumps({ + 'value_id': '123456', 'selection': 'Protecton by Seuence'})) + + assert resp.status == 202 + result = await resp.json() + assert node.set_protection.called + assert result == {'message': 'Protection setting did not complete'} + + +async def test_set_protection_value_nonexisting_node(hass, client): + """Test setting protection value on nonexisting node.""" + network = hass.data[DATA_NETWORK] = MagicMock() + node = MockNode(node_id=17, + command_classes=[const.COMMAND_CLASS_PROTECTION]) + value = MockValue( + value_id=123456, + index=0, + instance=1, + command_class=const.COMMAND_CLASS_PROTECTION) + value.label = 'Protection Test' + value.data_items = ['Unprotected', 'Protection by Sequence', + 'No Operation Possible'] + value.data = 'Unprotected' + network.nodes = {17: node} + node.value = value + node.set_protection.return_value = False + + resp = await client.post( + '/api/zwave/protection/18', data=json.dumps({ + 'value_id': '123456', 'selection': 'Protecton by Seuence'})) + + assert resp.status == 404 + result = await resp.json() + assert not node.set_protection.called + assert result == {'message': 'Node not found'} + + +async def test_set_protection_value_missing_class(hass, client): + """Test setting protection value on node without protectionclass.""" + network = hass.data[DATA_NETWORK] = MagicMock() + node = MockNode(node_id=17) + value = MockValue( + value_id=123456, + index=0, + instance=1) + network.nodes = {17: node} + node.value = value + node.set_protection.return_value = False + + resp = await client.post( + '/api/zwave/protection/17', data=json.dumps({ + 'value_id': '123456', 'selection': 'Protecton by Seuence'})) + + assert resp.status == 404 + result = await resp.json() + assert not node.set_protection.called + assert result == {'message': 'No protection commandclass on this node'} diff --git a/tests/components/conftest.py b/tests/components/conftest.py index 53caeb807833eb..bb9b643296e678 100644 --- a/tests/components/conftest.py +++ b/tests/components/conftest.py @@ -2,21 +2,47 @@ import pytest from homeassistant.setup import async_setup_component +from homeassistant.components import websocket_api + +from tests.common import MockUser, CLIENT_ID @pytest.fixture def hass_ws_client(aiohttp_client): """Websocket client fixture connected to websocket server.""" - async def create_client(hass): + async def create_client(hass, access_token=None): """Create a websocket client.""" wapi = hass.components.websocket_api assert await async_setup_component(hass, 'websocket_api') client = await aiohttp_client(hass.http.app) websocket = await client.ws_connect(wapi.URL) + auth_resp = await websocket.receive_json() + + if auth_resp['type'] == wapi.TYPE_AUTH_OK: + assert access_token is None, \ + 'Access token given but no auth required' + return websocket + + assert access_token is not None, 'Access token required for fixture' + + await websocket.send_json({ + 'type': websocket_api.TYPE_AUTH, + 'access_token': access_token + }) + auth_ok = await websocket.receive_json() assert auth_ok['type'] == wapi.TYPE_AUTH_OK return websocket return create_client + + +@pytest.fixture +def hass_access_token(hass): + """Return an access token to access Home Assistant.""" + user = MockUser().add_to_hass(hass) + refresh_token = hass.loop.run_until_complete( + hass.auth.async_create_refresh_token(user, CLIENT_ID)) + yield hass.auth.async_create_access_token(refresh_token) diff --git a/tests/components/cover/test_init.py b/tests/components/cover/test_init.py old mode 100755 new mode 100644 diff --git a/tests/components/cover/test_mqtt.py b/tests/components/cover/test_mqtt.py index 23a7b32fc28920..aea6398e3ae874 100644 --- a/tests/components/cover/test_mqtt.py +++ b/tests/components/cover/test_mqtt.py @@ -2,8 +2,8 @@ import unittest from homeassistant.setup import setup_component -from homeassistant.const import STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN,\ - STATE_UNAVAILABLE +from homeassistant.const import STATE_OPEN, STATE_CLOSED, STATE_UNKNOWN, \ + STATE_UNAVAILABLE, ATTR_ASSUMED_STATE import homeassistant.components.cover as cover from homeassistant.components.cover.mqtt import MqttCover @@ -40,6 +40,7 @@ def test_state_via_state_topic(self): state = self.hass.states.get('cover.test') self.assertEqual(STATE_UNKNOWN, state.state) + self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) fire_mqtt_message(self.hass, 'state-topic', '0') self.hass.block_till_done() @@ -112,6 +113,7 @@ def test_optimistic_state_change(self): state = self.hass.states.get('cover.test') self.assertEqual(STATE_UNKNOWN, state.state) + self.assertTrue(state.attributes.get(ATTR_ASSUMED_STATE)) cover.open_cover(self.hass, 'cover.test') self.hass.block_till_done() diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py index d86475b35ef023..111cfbe969763e 100644 --- a/tests/components/deconz/test_config_flow.py +++ b/tests/components/deconz/test_config_flow.py @@ -21,7 +21,9 @@ async def test_flow_works(hass, aioclient_mock): flow = config_flow.DeconzFlowHandler() flow.hass = hass await flow.async_step_init() - result = await flow.async_step_link(user_input={}) + await flow.async_step_link(user_input={}) + result = await flow.async_step_options( + user_input={'allow_clip_sensor': True, 'allow_deconz_groups': True}) assert result['type'] == 'create_entry' assert result['title'] == 'deCONZ-id' @@ -29,7 +31,9 @@ async def test_flow_works(hass, aioclient_mock): 'bridgeid': 'id', 'host': '1.2.3.4', 'port': 80, - 'api_key': '1234567890ABCDEF' + 'api_key': '1234567890ABCDEF', + 'allow_clip_sensor': True, + 'allow_deconz_groups': True } @@ -153,7 +157,9 @@ async def test_bridge_discovery_config_file(hass): 'bridgeid': 'id', 'host': '1.2.3.4', 'port': 80, - 'api_key': '1234567890ABCDEF' + 'api_key': '1234567890ABCDEF', + 'allow_clip_sensor': True, + 'allow_deconz_groups': True } @@ -221,5 +227,30 @@ async def test_import_with_api_key(hass): 'bridgeid': 'id', 'host': '1.2.3.4', 'port': 80, - 'api_key': '1234567890ABCDEF' + 'api_key': '1234567890ABCDEF', + 'allow_clip_sensor': True, + 'allow_deconz_groups': True + } + + +async def test_options(hass, aioclient_mock): + """Test that options work and that bridgeid can be requested.""" + aioclient_mock.get('http://1.2.3.4:80/api/1234567890ABCDEF/config', + json={"bridgeid": "id"}) + flow = config_flow.DeconzFlowHandler() + flow.hass = hass + flow.deconz_config = {'host': '1.2.3.4', + 'port': 80, + 'api_key': '1234567890ABCDEF'} + result = await flow.async_step_options( + user_input={'allow_clip_sensor': False, 'allow_deconz_groups': False}) + assert result['type'] == 'create_entry' + assert result['title'] == 'deCONZ-id' + assert result['data'] == { + 'bridgeid': 'id', + 'host': '1.2.3.4', + 'port': 80, + 'api_key': '1234567890ABCDEF', + 'allow_clip_sensor': False, + 'allow_deconz_groups': False } diff --git a/tests/components/deconz/test_init.py b/tests/components/deconz/test_init.py index 888094deea61cd..c6fc130a4a41a6 100644 --- a/tests/components/deconz/test_init.py +++ b/tests/components/deconz/test_init.py @@ -91,7 +91,7 @@ async def test_setup_entry_successful(hass): """Test setup entry is successful.""" entry = Mock() entry.data = {'host': '1.2.3.4', 'port': 80, 'api_key': '1234567890ABCDEF'} - with patch.object(hass, 'async_add_job') as mock_add_job, \ + with patch.object(hass, 'async_create_task') as mock_add_job, \ patch.object(hass, 'config_entries') as mock_config_entries, \ patch('pydeconz.DeconzSession.async_load_parameters', return_value=mock_coro(True)): @@ -99,8 +99,8 @@ async def test_setup_entry_successful(hass): assert hass.data[deconz.DOMAIN] assert hass.data[deconz.DATA_DECONZ_ID] == {} assert len(hass.data[deconz.DATA_DECONZ_UNSUB]) == 1 - assert len(mock_add_job.mock_calls) == 4 - assert len(mock_config_entries.async_forward_entry_setup.mock_calls) == 4 + assert len(mock_add_job.mock_calls) == 5 + assert len(mock_config_entries.async_forward_entry_setup.mock_calls) == 5 assert mock_config_entries.async_forward_entry_setup.mock_calls[0][1] == \ (entry, 'binary_sensor') assert mock_config_entries.async_forward_entry_setup.mock_calls[1][1] == \ @@ -109,6 +109,8 @@ async def test_setup_entry_successful(hass): (entry, 'scene') assert mock_config_entries.async_forward_entry_setup.mock_calls[3][1] == \ (entry, 'sensor') + assert mock_config_entries.async_forward_entry_setup.mock_calls[4][1] == \ + (entry, 'switch') async def test_unload_entry(hass): @@ -172,3 +174,21 @@ async def test_add_new_remote(hass): async_dispatcher_send(hass, 'deconz_new_sensor', [remote]) await hass.async_block_till_done() assert len(hass.data[deconz.DATA_DECONZ_EVENT]) == 1 + + +async def test_do_not_allow_clip_sensor(hass): + """Test that clip sensors can be ignored.""" + entry = Mock() + entry.data = {'host': '1.2.3.4', 'port': 80, + 'api_key': '1234567890ABCDEF', 'allow_clip_sensor': False} + remote = Mock() + remote.name = 'name' + remote.type = 'CLIPSwitch' + remote.register_async_callback = Mock() + with patch('pydeconz.DeconzSession.async_load_parameters', + return_value=mock_coro(True)): + assert await deconz.async_setup_entry(hass, entry) is True + + async_dispatcher_send(hass, 'deconz_new_sensor', [remote]) + await hass.async_block_till_done() + assert len(hass.data[deconz.DATA_DECONZ_EVENT]) == 0 diff --git a/tests/components/device_tracker/test_asuswrt.py b/tests/components/device_tracker/test_asuswrt.py index 0cbece6d1b0c03..956b407eeaa25f 100644 --- a/tests/components/device_tracker/test_asuswrt.py +++ b/tests/components/device_tracker/test_asuswrt.py @@ -168,8 +168,7 @@ def test_scan_devices(self): scanner.last_results = WAKE_DEVICES self.assertEqual(list(WAKE_DEVICES), scanner.scan_devices()) - def test_password_or_pub_key_required(self): \ - # pylint: disable=invalid-name + def test_password_or_pub_key_required(self): """Test creating an AsusWRT scanner without a pass or pubkey.""" with assert_setup_component(0, DOMAIN): assert setup_component( @@ -183,8 +182,7 @@ def test_password_or_pub_key_required(self): \ @mock.patch( 'homeassistant.components.device_tracker.asuswrt.AsusWrtDeviceScanner', return_value=mock.MagicMock()) - def test_get_scanner_with_password_no_pubkey(self, asuswrt_mock): \ - # pylint: disable=invalid-name + def test_get_scanner_with_password_no_pubkey(self, asuswrt_mock): """Test creating an AsusWRT scanner with a password and no pubkey.""" conf_dict = { DOMAIN: { @@ -213,8 +211,7 @@ def test_get_scanner_with_password_no_pubkey(self, asuswrt_mock): \ @mock.patch( 'homeassistant.components.device_tracker.asuswrt.AsusWrtDeviceScanner', return_value=mock.MagicMock()) - def test_get_scanner_with_pubkey_no_password(self, asuswrt_mock): \ - # pylint: disable=invalid-name + def test_get_scanner_with_pubkey_no_password(self, asuswrt_mock): """Test creating an AsusWRT scanner with a pubkey and no password.""" conf_dict = { device_tracker.DOMAIN: { @@ -292,8 +289,7 @@ def test_ssh_login_with_password(self): password='fake_pass', port=22) ) - def test_ssh_login_without_password_or_pubkey(self): \ - # pylint: disable=invalid-name + def test_ssh_login_without_password_or_pubkey(self): """Test that login is not called without password or pub_key.""" ssh = mock.MagicMock() ssh_mock = mock.patch('pexpect.pxssh.pxssh', return_value=ssh) @@ -363,8 +359,7 @@ def test_telnet_login_with_password(self): mock.call(b'#') ) - def test_telnet_login_without_password(self): \ - # pylint: disable=invalid-name + def test_telnet_login_without_password(self): """Test that login is not called without password or pub_key.""" telnet = mock.MagicMock() telnet_mock = mock.patch('telnetlib.Telnet', return_value=telnet) diff --git a/tests/components/device_tracker/test_bt_home_hub_5.py b/tests/components/device_tracker/test_bt_home_hub_5.py deleted file mode 100644 index fd9692ec2b47c0..00000000000000 --- a/tests/components/device_tracker/test_bt_home_hub_5.py +++ /dev/null @@ -1,53 +0,0 @@ -"""The tests for the BT Home Hub 5 device tracker platform.""" -import unittest -from unittest.mock import patch - -from homeassistant.components.device_tracker import bt_home_hub_5 -from homeassistant.const import CONF_HOST - -patch_file = 'homeassistant.components.device_tracker.bt_home_hub_5' - - -def _get_homehub_data(url): - """Return mock homehub data.""" - return ''' - [ - { - "mac": "AA:BB:CC:DD:EE:FF, - "hostname": "hostname", - "ip": "192.168.1.43", - "ipv6": "", - "name": "hostname", - "activity": "1", - "os": "Unknown", - "device": "Unknown", - "time_first_seen": "2016/06/05 11:14:45", - "time_last_active": "2016/06/06 11:33:08", - "dhcp_option": "39043T90430T9TGK0EKGE5KGE3K904390K45GK054", - "port": "wl0", - "ipv6_ll": "fe80::gd67:ghrr:fuud:4332", - "activity_ip": "1", - "activity_ipv6_ll": "0", - "activity_ipv6": "0", - "device_oui": "NA", - "device_serial": "NA", - "device_class": "NA" - } - ] - ''' - - -class TestBTHomeHub5DeviceTracker(unittest.TestCase): - """Test BT Home Hub 5 device tracker platform.""" - - @patch('{}._get_homehub_data'.format(patch_file), new=_get_homehub_data) - def test_config_minimal(self): - """Test the setup with minimal configuration.""" - config = { - 'device_tracker': { - CONF_HOST: 'foo' - } - } - result = bt_home_hub_5.get_scanner(None, config) - - self.assertIsNotNone(result) diff --git a/tests/components/device_tracker/test_mqtt.py b/tests/components/device_tracker/test_mqtt.py index 78750e91f833b7..de7865517a8366 100644 --- a/tests/components/device_tracker/test_mqtt.py +++ b/tests/components/device_tracker/test_mqtt.py @@ -31,8 +31,7 @@ def tearDown(self): # pylint: disable=invalid-name except FileNotFoundError: pass - def test_ensure_device_tracker_platform_validation(self): \ - # pylint: disable=invalid-name + def test_ensure_device_tracker_platform_validation(self): """Test if platform validation was done.""" @asyncio.coroutine def mock_setup_scanner(hass, config, see, discovery_info=None): diff --git a/tests/components/device_tracker/test_mqtt_json.py b/tests/components/device_tracker/test_mqtt_json.py index 43f4fc3bbf3484..8ab6346f19be6b 100644 --- a/tests/components/device_tracker/test_mqtt_json.py +++ b/tests/components/device_tracker/test_mqtt_json.py @@ -41,8 +41,7 @@ def tearDown(self): # pylint: disable=invalid-name except FileNotFoundError: pass - def test_ensure_device_tracker_platform_validation(self): \ - # pylint: disable=invalid-name + def test_ensure_device_tracker_platform_validation(self): """Test if platform validation was done.""" @asyncio.coroutine def mock_setup_scanner(hass, config, see, discovery_info=None): diff --git a/tests/components/device_tracker/test_tomato.py b/tests/components/device_tracker/test_tomato.py index cce39ce43a79d6..0c20350a8459c5 100644 --- a/tests/components/device_tracker/test_tomato.py +++ b/tests/components/device_tracker/test_tomato.py @@ -22,9 +22,9 @@ def __init__(self, text, status_code): # Password: bar if args[0].headers['Authorization'] != 'Basic Zm9vOmJhcg==': return MockSessionResponse(None, 401) - elif "gimmie_bad_data" in args[0].body: + if "gimmie_bad_data" in args[0].body: return MockSessionResponse('This shouldn\'t (wldev = be here.;', 200) - elif "gimmie_good_data" in args[0].body: + if "gimmie_good_data" in args[0].body: return MockSessionResponse( "wldev = [ ['eth1','F4:F5:D8:AA:AA:AA'," "-42,5500,1000,7043,0],['eth1','58:EF:68:00:00:00'," diff --git a/tests/components/device_tracker/test_unifi_direct.py b/tests/components/device_tracker/test_unifi_direct.py index 8bc3a60146cb30..d1ede7211426c7 100644 --- a/tests/components/device_tracker/test_unifi_direct.py +++ b/tests/components/device_tracker/test_unifi_direct.py @@ -45,8 +45,7 @@ def teardown_method(self, _): @mock.patch(scanner_path, return_value=mock.MagicMock()) - def test_get_scanner(self, unifi_mock): \ - # pylint: disable=invalid-name + def test_get_scanner(self, unifi_mock): """Test creating an Unifi direct scanner with a password.""" conf_dict = { DOMAIN: { @@ -71,7 +70,7 @@ def test_get_scanner(self, unifi_mock): \ @patch('pexpect.pxssh.pxssh') def test_get_device_name(self, mock_ssh): - """"Testing MAC matching.""" + """Testing MAC matching.""" conf_dict = { DOMAIN: { CONF_PLATFORM: 'unifi_direct', @@ -95,7 +94,7 @@ def test_get_device_name(self, mock_ssh): @patch('pexpect.pxssh.pxssh.logout') @patch('pexpect.pxssh.pxssh.login') def test_failed_to_log_in(self, mock_login, mock_logout): - """"Testing exception at login results in False.""" + """Testing exception at login results in False.""" from pexpect import exceptions conf_dict = { @@ -120,7 +119,7 @@ def test_failed_to_log_in(self, mock_login, mock_logout): @patch('pexpect.pxssh.pxssh.sendline') def test_to_get_update(self, mock_sendline, mock_prompt, mock_login, mock_logout): - """"Testing exception in get_update matching.""" + """Testing exception in get_update matching.""" conf_dict = { DOMAIN: { CONF_PLATFORM: 'unifi_direct', diff --git a/tests/components/device_tracker/test_upc_connect.py b/tests/components/device_tracker/test_upc_connect.py index e45d70bc172d2f..6294ba3467a11f 100644 --- a/tests/components/device_tracker/test_upc_connect.py +++ b/tests/components/device_tracker/test_upc_connect.py @@ -33,7 +33,7 @@ def mock_load_config(): yield -class TestUPCConnect(object): +class TestUPCConnect: """Tests for the Ddwrt device tracker platform.""" def setup_method(self): diff --git a/tests/components/device_tracker/test_xiaomi.py b/tests/components/device_tracker/test_xiaomi.py index 19f25b514db593..0705fb2c39974f 100644 --- a/tests/components/device_tracker/test_xiaomi.py +++ b/tests/components/device_tracker/test_xiaomi.py @@ -55,21 +55,21 @@ def raise_for_status(self): "code": "401", "msg": "Invalid token" }, 200) - elif data and data.get('username', None) == TOKEN_TIMEOUT_USERNAME: + if data and data.get('username', None) == TOKEN_TIMEOUT_USERNAME: # deliver an expired token return MockResponse({ "url": "/cgi-bin/luci/;stok=ef5860/web/home", "token": "timedOut", "code": "0" }, 200) - elif str(args[0]).startswith(URL_AUTHORIZE): + if str(args[0]).startswith(URL_AUTHORIZE): # deliver an authorized token return MockResponse({ "url": "/cgi-bin/luci/;stok=ef5860/web/home", "token": "ef5860", "code": "0" }, 200) - elif str(args[0]).endswith("timedOut/" + URL_LIST_END) \ + if str(args[0]).endswith("timedOut/" + URL_LIST_END) \ and FIRST_CALL is True: FIRST_CALL = False # deliver an error when called with expired token @@ -77,7 +77,7 @@ def raise_for_status(self): "code": "401", "msg": "Invalid token" }, 200) - elif str(args[0]).endswith(URL_LIST_END): + if str(args[0]).endswith(URL_LIST_END): # deliver the device list return MockResponse({ "mac": "1C:98:EC:0E:D5:A4", @@ -149,8 +149,7 @@ def raise_for_status(self): ], "code": 0 }, 200) - else: - _LOGGER.debug('UNKNOWN ROUTE') + _LOGGER.debug('UNKNOWN ROUTE') class TestXiaomiDeviceScanner(unittest.TestCase): @@ -210,7 +209,7 @@ def test_config_full(self, xiaomi_mock): @patch('requests.get', side_effect=mocked_requests) @patch('requests.post', side_effect=mocked_requests) def test_invalid_credential(self, mock_get, mock_post): - """"Testing invalid credential handling.""" + """Testing invalid credential handling.""" config = { DOMAIN: xiaomi.PLATFORM_SCHEMA({ CONF_PLATFORM: xiaomi.DOMAIN, @@ -224,7 +223,7 @@ def test_invalid_credential(self, mock_get, mock_post): @patch('requests.get', side_effect=mocked_requests) @patch('requests.post', side_effect=mocked_requests) def test_valid_credential(self, mock_get, mock_post): - """"Testing valid refresh.""" + """Testing valid refresh.""" config = { DOMAIN: xiaomi.PLATFORM_SCHEMA({ CONF_PLATFORM: xiaomi.DOMAIN, @@ -244,7 +243,7 @@ def test_valid_credential(self, mock_get, mock_post): @patch('requests.get', side_effect=mocked_requests) @patch('requests.post', side_effect=mocked_requests) def test_token_timed_out(self, mock_get, mock_post): - """"Testing refresh with a timed out token. + """Testing refresh with a timed out token. New token is requested and list is downloaded a second time. """ diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 1617f327d27baa..c99d273a4580a7 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -130,10 +130,10 @@ def hue_client(loop, hass_hue, aiohttp_client): } }) - HueUsernameView().register(web_app.router) - HueAllLightsStateView(config).register(web_app.router) - HueOneLightStateView(config).register(web_app.router) - HueOneLightChangeView(config).register(web_app.router) + HueUsernameView().register(web_app, web_app.router) + HueAllLightsStateView(config).register(web_app, web_app.router) + HueOneLightStateView(config).register(web_app, web_app.router) + HueOneLightChangeView(config).register(web_app, web_app.router) return loop.run_until_complete(aiohttp_client(web_app)) diff --git a/tests/components/emulated_hue/test_upnp.py b/tests/components/emulated_hue/test_upnp.py index 555802f9a2c7d2..8315de34e061d8 100644 --- a/tests/components/emulated_hue/test_upnp.py +++ b/tests/components/emulated_hue/test_upnp.py @@ -89,7 +89,7 @@ def test_description_xml(self): # Make sure the XML is parsable try: ET.fromstring(result.text) - except: # noqa: E722 # pylint: disable=bare-except + except: # noqa: E722 pylint: disable=bare-except self.fail('description.xml is not valid XML!') def test_create_username(self): diff --git a/tests/components/fan/test_dyson.py b/tests/components/fan/test_dyson.py index 49338e123e364c..2953ea2754ba02 100644 --- a/tests/components/fan/test_dyson.py +++ b/tests/components/fan/test_dyson.py @@ -2,8 +2,11 @@ import unittest from unittest import mock +from homeassistant.setup import setup_component +from homeassistant.components import dyson as dyson_parent from homeassistant.components.dyson import DYSON_DEVICES -from homeassistant.components.fan import dyson +from homeassistant.components.fan import (dyson, ATTR_SPEED, ATTR_SPEED_LIST, + ATTR_OSCILLATING) from tests.common import get_test_home_assistant from libpurecoollink.const import FanSpeed, FanMode, NightMode, Oscillation from libpurecoollink.dyson_pure_state import DysonPureCoolState @@ -91,6 +94,45 @@ def _add_device(devices): self.hass.data[dyson.DYSON_DEVICES] = [device_fan, device_non_fan] dyson.setup_platform(self.hass, None, _add_device) + @mock.patch('libpurecoollink.dyson.DysonAccount.devices', + return_value=[_get_device_on()]) + @mock.patch('libpurecoollink.dyson.DysonAccount.login', return_value=True) + def test_get_state_attributes(self, mocked_login, mocked_devices): + """Test async added to hass.""" + setup_component(self.hass, dyson_parent.DOMAIN, { + dyson_parent.DOMAIN: { + dyson_parent.CONF_USERNAME: "email", + dyson_parent.CONF_PASSWORD: "password", + dyson_parent.CONF_LANGUAGE: "US", + } + }) + self.hass.block_till_done() + state = self.hass.states.get("{}.{}".format( + dyson.DOMAIN, + mocked_devices.return_value[0].name)) + + assert dyson.ATTR_IS_NIGHT_MODE in state.attributes + assert dyson.ATTR_IS_AUTO_MODE in state.attributes + assert ATTR_SPEED in state.attributes + assert ATTR_SPEED_LIST in state.attributes + assert ATTR_OSCILLATING in state.attributes + + @mock.patch('libpurecoollink.dyson.DysonAccount.devices', + return_value=[_get_device_on()]) + @mock.patch('libpurecoollink.dyson.DysonAccount.login', return_value=True) + def test_async_added_to_hass(self, mocked_login, mocked_devices): + """Test async added to hass.""" + setup_component(self.hass, dyson_parent.DOMAIN, { + dyson_parent.DOMAIN: { + dyson_parent.CONF_USERNAME: "email", + dyson_parent.CONF_PASSWORD: "password", + dyson_parent.CONF_LANGUAGE: "US", + } + }) + self.hass.block_till_done() + self.assertEqual(len(self.hass.data[dyson.DYSON_DEVICES]), 1) + assert mocked_devices.return_value[0].add_message_listener.called + def test_dyson_set_speed(self): """Test set fan speed.""" device = _get_device_on() diff --git a/tests/components/fan/test_mqtt.py b/tests/components/fan/test_mqtt.py index ec68492ed1e9b8..9060d7b9986f24 100644 --- a/tests/components/fan/test_mqtt.py +++ b/tests/components/fan/test_mqtt.py @@ -18,7 +18,7 @@ def setUp(self): # pylint: disable=invalid-name self.mock_publish = mock_mqtt_component(self.hass) def tearDown(self): # pylint: disable=invalid-name - """"Stop everything that was started.""" + """Stop everything that was started.""" self.hass.stop() def test_default_availability_payload(self): diff --git a/tests/components/fan/test_template.py b/tests/components/fan/test_template.py index 719a3f96aedd41..53eb9e8e2d486d 100644 --- a/tests/components/fan/test_template.py +++ b/tests/components/fan/test_template.py @@ -6,7 +6,8 @@ import homeassistant.components as components from homeassistant.const import STATE_ON, STATE_OFF from homeassistant.components.fan import ( - ATTR_SPEED, ATTR_OSCILLATING, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH) + ATTR_SPEED, ATTR_OSCILLATING, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, + ATTR_DIRECTION, DIRECTION_FORWARD, DIRECTION_REVERSE) from tests.common import ( get_test_home_assistant, assert_setup_component) @@ -20,6 +21,8 @@ _SPEED_INPUT_SELECT = 'input_select.speed' # Represent for fan's oscillating _OSC_INPUT = 'input_select.osc' +# Represent for fan's direction +_DIRECTION_INPUT_SELECT = 'input_select.direction' class TestTemplateFan: @@ -71,7 +74,7 @@ def test_missing_optional_config(self): self.hass.start() self.hass.block_till_done() - self._verify(STATE_ON, None, None) + self._verify(STATE_ON, None, None, None) def test_missing_value_template_config(self): """Test: missing 'value_template' will fail.""" @@ -185,6 +188,8 @@ def test_templates_with_entities(self): "{{ states('input_select.speed') }}", 'oscillating_template': "{{ states('input_select.osc') }}", + 'direction_template': + "{{ states('input_select.direction') }}", 'turn_on': { 'service': 'script.fan_on' }, @@ -199,14 +204,15 @@ def test_templates_with_entities(self): self.hass.start() self.hass.block_till_done() - self._verify(STATE_OFF, None, None) + self._verify(STATE_OFF, None, None, None) self.hass.states.set(_STATE_INPUT_BOOLEAN, True) self.hass.states.set(_SPEED_INPUT_SELECT, SPEED_MEDIUM) self.hass.states.set(_OSC_INPUT, 'True') + self.hass.states.set(_DIRECTION_INPUT_SELECT, DIRECTION_FORWARD) self.hass.block_till_done() - self._verify(STATE_ON, SPEED_MEDIUM, True) + self._verify(STATE_ON, SPEED_MEDIUM, True, DIRECTION_FORWARD) def test_templates_with_valid_values(self): """Test templates with valid values.""" @@ -222,6 +228,8 @@ def test_templates_with_valid_values(self): "{{ 'medium' }}", 'oscillating_template': "{{ 1 == 1 }}", + 'direction_template': + "{{ 'forward' }}", 'turn_on': { 'service': 'script.fan_on' @@ -237,7 +245,7 @@ def test_templates_with_valid_values(self): self.hass.start() self.hass.block_till_done() - self._verify(STATE_ON, SPEED_MEDIUM, True) + self._verify(STATE_ON, SPEED_MEDIUM, True, DIRECTION_FORWARD) def test_templates_invalid_values(self): """Test templates with invalid values.""" @@ -253,6 +261,8 @@ def test_templates_invalid_values(self): "{{ '0' }}", 'oscillating_template': "{{ 'xyz' }}", + 'direction_template': + "{{ 'right' }}", 'turn_on': { 'service': 'script.fan_on' @@ -268,7 +278,7 @@ def test_templates_invalid_values(self): self.hass.start() self.hass.block_till_done() - self._verify(STATE_OFF, None, None) + self._verify(STATE_OFF, None, None, None) # End of template tests # @@ -283,7 +293,7 @@ def test_on_off(self): # verify assert self.hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_ON - self._verify(STATE_ON, None, None) + self._verify(STATE_ON, None, None, None) # Turn off fan components.fan.turn_off(self.hass, _TEST_FAN) @@ -291,7 +301,7 @@ def test_on_off(self): # verify assert self.hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_OFF - self._verify(STATE_OFF, None, None) + self._verify(STATE_OFF, None, None, None) def test_on_with_speed(self): """Test turn on with speed.""" @@ -304,7 +314,7 @@ def test_on_with_speed(self): # verify assert self.hass.states.get(_STATE_INPUT_BOOLEAN).state == STATE_ON assert self.hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH - self._verify(STATE_ON, SPEED_HIGH, None) + self._verify(STATE_ON, SPEED_HIGH, None, None) def test_set_speed(self): """Test set valid speed.""" @@ -320,7 +330,7 @@ def test_set_speed(self): # verify assert self.hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH - self._verify(STATE_ON, SPEED_HIGH, None) + self._verify(STATE_ON, SPEED_HIGH, None, None) # Set fan's speed to medium components.fan.set_speed(self.hass, _TEST_FAN, SPEED_MEDIUM) @@ -328,7 +338,7 @@ def test_set_speed(self): # verify assert self.hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_MEDIUM - self._verify(STATE_ON, SPEED_MEDIUM, None) + self._verify(STATE_ON, SPEED_MEDIUM, None, None) def test_set_invalid_speed_from_initial_stage(self): """Test set invalid speed when fan is in initial state.""" @@ -344,7 +354,7 @@ def test_set_invalid_speed_from_initial_stage(self): # verify speed is unchanged assert self.hass.states.get(_SPEED_INPUT_SELECT).state == '' - self._verify(STATE_ON, None, None) + self._verify(STATE_ON, None, None, None) def test_set_invalid_speed(self): """Test set invalid speed when fan has valid speed.""" @@ -360,7 +370,7 @@ def test_set_invalid_speed(self): # verify assert self.hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH - self._verify(STATE_ON, SPEED_HIGH, None) + self._verify(STATE_ON, SPEED_HIGH, None, None) # Set fan's speed to 'invalid' components.fan.set_speed(self.hass, _TEST_FAN, 'invalid') @@ -368,7 +378,7 @@ def test_set_invalid_speed(self): # verify speed is unchanged assert self.hass.states.get(_SPEED_INPUT_SELECT).state == SPEED_HIGH - self._verify(STATE_ON, SPEED_HIGH, None) + self._verify(STATE_ON, SPEED_HIGH, None, None) def test_custom_speed_list(self): """Test set custom speed list.""" @@ -384,7 +394,7 @@ def test_custom_speed_list(self): # verify assert self.hass.states.get(_SPEED_INPUT_SELECT).state == '1' - self._verify(STATE_ON, '1', None) + self._verify(STATE_ON, '1', None, None) # Set fan's speed to 'medium' which is invalid components.fan.set_speed(self.hass, _TEST_FAN, SPEED_MEDIUM) @@ -392,7 +402,7 @@ def test_custom_speed_list(self): # verify that speed is unchanged assert self.hass.states.get(_SPEED_INPUT_SELECT).state == '1' - self._verify(STATE_ON, '1', None) + self._verify(STATE_ON, '1', None, None) def test_set_osc(self): """Test set oscillating.""" @@ -408,7 +418,7 @@ def test_set_osc(self): # verify assert self.hass.states.get(_OSC_INPUT).state == 'True' - self._verify(STATE_ON, None, True) + self._verify(STATE_ON, None, True, None) # Set fan's osc to False components.fan.oscillate(self.hass, _TEST_FAN, False) @@ -416,7 +426,7 @@ def test_set_osc(self): # verify assert self.hass.states.get(_OSC_INPUT).state == 'False' - self._verify(STATE_ON, None, False) + self._verify(STATE_ON, None, False, None) def test_set_invalid_osc_from_initial_state(self): """Test set invalid oscillating when fan is in initial state.""" @@ -432,7 +442,7 @@ def test_set_invalid_osc_from_initial_state(self): # verify assert self.hass.states.get(_OSC_INPUT).state == '' - self._verify(STATE_ON, None, None) + self._verify(STATE_ON, None, None, None) def test_set_invalid_osc(self): """Test set invalid oscillating when fan has valid osc.""" @@ -448,7 +458,7 @@ def test_set_invalid_osc(self): # verify assert self.hass.states.get(_OSC_INPUT).state == 'True' - self._verify(STATE_ON, None, True) + self._verify(STATE_ON, None, True, None) # Set fan's osc to False components.fan.oscillate(self.hass, _TEST_FAN, None) @@ -456,15 +466,85 @@ def test_set_invalid_osc(self): # verify osc is unchanged assert self.hass.states.get(_OSC_INPUT).state == 'True' - self._verify(STATE_ON, None, True) + self._verify(STATE_ON, None, True, None) - def _verify(self, expected_state, expected_speed, expected_oscillating): + def test_set_direction(self): + """Test set valid direction.""" + self._register_components() + + # Turn on fan + components.fan.turn_on(self.hass, _TEST_FAN) + self.hass.block_till_done() + + # Set fan's direction to forward + components.fan.set_direction(self.hass, _TEST_FAN, DIRECTION_FORWARD) + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_DIRECTION_INPUT_SELECT).state \ + == DIRECTION_FORWARD + self._verify(STATE_ON, None, None, DIRECTION_FORWARD) + + # Set fan's direction to reverse + components.fan.set_direction(self.hass, _TEST_FAN, DIRECTION_REVERSE) + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_DIRECTION_INPUT_SELECT).state \ + == DIRECTION_REVERSE + self._verify(STATE_ON, None, None, DIRECTION_REVERSE) + + def test_set_invalid_direction_from_initial_stage(self): + """Test set invalid direction when fan is in initial state.""" + self._register_components() + + # Turn on fan + components.fan.turn_on(self.hass, _TEST_FAN) + self.hass.block_till_done() + + # Set fan's direction to 'invalid' + components.fan.set_direction(self.hass, _TEST_FAN, 'invalid') + self.hass.block_till_done() + + # verify direction is unchanged + assert self.hass.states.get(_DIRECTION_INPUT_SELECT).state == '' + self._verify(STATE_ON, None, None, None) + + def test_set_invalid_direction(self): + """Test set invalid direction when fan has valid direction.""" + self._register_components() + + # Turn on fan + components.fan.turn_on(self.hass, _TEST_FAN) + self.hass.block_till_done() + + # Set fan's direction to forward + components.fan.set_direction(self.hass, _TEST_FAN, DIRECTION_FORWARD) + self.hass.block_till_done() + + # verify + assert self.hass.states.get(_DIRECTION_INPUT_SELECT).state == \ + DIRECTION_FORWARD + self._verify(STATE_ON, None, None, DIRECTION_FORWARD) + + # Set fan's direction to 'invalid' + components.fan.set_direction(self.hass, _TEST_FAN, 'invalid') + self.hass.block_till_done() + + # verify direction is unchanged + assert self.hass.states.get(_DIRECTION_INPUT_SELECT).state == \ + DIRECTION_FORWARD + self._verify(STATE_ON, None, None, DIRECTION_FORWARD) + + def _verify(self, expected_state, expected_speed, expected_oscillating, + expected_direction): """Verify fan's state, speed and osc.""" state = self.hass.states.get(_TEST_FAN) attributes = state.attributes assert state.state == expected_state assert attributes.get(ATTR_SPEED, None) == expected_speed assert attributes.get(ATTR_OSCILLATING, None) == expected_oscillating + assert attributes.get(ATTR_DIRECTION, None) == expected_direction def _register_components(self, speed_list=None): """Register basic components for testing.""" @@ -475,7 +555,7 @@ def _register_components(self, speed_list=None): {'input_boolean': {'state': None}} ) - with assert_setup_component(2, 'input_select'): + with assert_setup_component(3, 'input_select'): assert setup.setup_component(self.hass, 'input_select', { 'input_select': { 'speed': { @@ -488,6 +568,11 @@ def _register_components(self, speed_list=None): 'name': 'oscillating', 'options': ['', 'True', 'False'] }, + + 'direction': { + 'name': 'Direction', + 'options': ['', DIRECTION_FORWARD, DIRECTION_REVERSE] + }, } }) @@ -506,6 +591,8 @@ def _register_components(self, speed_list=None): "{{ states('input_select.speed') }}", 'oscillating_template': "{{ states('input_select.osc') }}", + 'direction_template': + "{{ states('input_select.direction') }}", 'turn_on': { 'service': 'input_boolean.turn_on', @@ -530,6 +617,14 @@ def _register_components(self, speed_list=None): 'entity_id': _OSC_INPUT, 'option': '{{ oscillating }}' } + }, + 'set_direction': { + 'service': 'input_select.select_option', + + 'data_template': { + 'entity_id': _DIRECTION_INPUT_SELECT, + 'option': '{{ direction }}' + } } } diff --git a/tests/components/frontend/__init__.py b/tests/components/frontend/__init__.py new file mode 100644 index 00000000000000..991a74dee7a1c7 --- /dev/null +++ b/tests/components/frontend/__init__.py @@ -0,0 +1 @@ +"""Tests for the frontend component.""" diff --git a/tests/components/frontend/test_init.py b/tests/components/frontend/test_init.py new file mode 100644 index 00000000000000..17bf3d953ef94f --- /dev/null +++ b/tests/components/frontend/test_init.py @@ -0,0 +1,361 @@ +"""The tests for Home Assistant frontend.""" +import asyncio +import re +from unittest.mock import patch + +import pytest + +from homeassistant.exceptions import HomeAssistantError +from homeassistant.setup import async_setup_component +from homeassistant.components.frontend import ( + DOMAIN, CONF_JS_VERSION, CONF_THEMES, CONF_EXTRA_HTML_URL, + CONF_EXTRA_HTML_URL_ES5) +from homeassistant.components import websocket_api as wapi + +from tests.common import mock_coro + + +CONFIG_THEMES = { + DOMAIN: { + CONF_THEMES: { + 'happy': { + 'primary-color': 'red' + } + } + } +} + + +@pytest.fixture +def mock_http_client(hass, aiohttp_client): + """Start the Hass HTTP component.""" + hass.loop.run_until_complete(async_setup_component(hass, 'frontend', {})) + return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + + +@pytest.fixture +def mock_http_client_with_themes(hass, aiohttp_client): + """Start the Hass HTTP component.""" + hass.loop.run_until_complete(async_setup_component(hass, 'frontend', { + DOMAIN: { + CONF_THEMES: { + 'happy': { + 'primary-color': 'red' + } + } + }})) + return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + + +@pytest.fixture +def mock_http_client_with_urls(hass, aiohttp_client): + """Start the Hass HTTP component.""" + hass.loop.run_until_complete(async_setup_component(hass, 'frontend', { + DOMAIN: { + CONF_JS_VERSION: 'auto', + CONF_EXTRA_HTML_URL: ["https://domain.com/my_extra_url.html"], + CONF_EXTRA_HTML_URL_ES5: + ["https://domain.com/my_extra_url_es5.html"] + }})) + return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + + +@asyncio.coroutine +def test_frontend_and_static(mock_http_client): + """Test if we can get the frontend.""" + resp = yield from mock_http_client.get('') + assert resp.status == 200 + assert 'cache-control' not in resp.headers + + text = yield from resp.text() + + # Test we can retrieve frontend.js + frontendjs = re.search( + r'(?P\/frontend_es5\/app-[A-Za-z0-9]{8}.js)', text) + + assert frontendjs is not None + resp = yield from mock_http_client.get(frontendjs.groups(0)[0]) + assert resp.status == 200 + assert 'public' in resp.headers.get('cache-control') + + +@asyncio.coroutine +def test_dont_cache_service_worker(mock_http_client): + """Test that we don't cache the service worker.""" + resp = yield from mock_http_client.get('/service_worker_es5.js') + assert resp.status == 200 + assert 'cache-control' not in resp.headers + + resp = yield from mock_http_client.get('/service_worker.js') + assert resp.status == 200 + assert 'cache-control' not in resp.headers + + +@asyncio.coroutine +def test_404(mock_http_client): + """Test for HTTP 404 error.""" + resp = yield from mock_http_client.get('/not-existing') + assert resp.status == 404 + + +@asyncio.coroutine +def test_we_cannot_POST_to_root(mock_http_client): + """Test that POST is not allow to root.""" + resp = yield from mock_http_client.post('/') + assert resp.status == 405 + + +@asyncio.coroutine +def test_states_routes(mock_http_client): + """All served by index.""" + resp = yield from mock_http_client.get('/states') + assert resp.status == 200 + + resp = yield from mock_http_client.get('/states/group.existing') + assert resp.status == 200 + + +async def test_themes_api(hass, hass_ws_client): + """Test that /api/themes returns correct data.""" + assert await async_setup_component(hass, 'frontend', CONFIG_THEMES) + client = await hass_ws_client(hass) + + await client.send_json({ + 'id': 5, + 'type': 'frontend/get_themes', + }) + msg = await client.receive_json() + + assert msg['result']['default_theme'] == 'default' + assert msg['result']['themes'] == {'happy': {'primary-color': 'red'}} + + +async def test_themes_set_theme(hass, hass_ws_client): + """Test frontend.set_theme service.""" + assert await async_setup_component(hass, 'frontend', CONFIG_THEMES) + client = await hass_ws_client(hass) + + await hass.services.async_call( + DOMAIN, 'set_theme', {'name': 'happy'}, blocking=True) + + await client.send_json({ + 'id': 5, + 'type': 'frontend/get_themes', + }) + msg = await client.receive_json() + + assert msg['result']['default_theme'] == 'happy' + + await hass.services.async_call( + DOMAIN, 'set_theme', {'name': 'default'}, blocking=True) + + await client.send_json({ + 'id': 6, + 'type': 'frontend/get_themes', + }) + msg = await client.receive_json() + + assert msg['result']['default_theme'] == 'default' + + +async def test_themes_set_theme_wrong_name(hass, hass_ws_client): + """Test frontend.set_theme service called with wrong name.""" + assert await async_setup_component(hass, 'frontend', CONFIG_THEMES) + client = await hass_ws_client(hass) + + await hass.services.async_call( + DOMAIN, 'set_theme', {'name': 'wrong'}, blocking=True) + + await client.send_json({ + 'id': 5, + 'type': 'frontend/get_themes', + }) + + msg = await client.receive_json() + + assert msg['result']['default_theme'] == 'default' + + +async def test_themes_reload_themes(hass, hass_ws_client): + """Test frontend.reload_themes service.""" + assert await async_setup_component(hass, 'frontend', CONFIG_THEMES) + client = await hass_ws_client(hass) + + with patch('homeassistant.components.frontend.load_yaml_config_file', + return_value={DOMAIN: { + CONF_THEMES: { + 'sad': {'primary-color': 'blue'} + }}}): + await hass.services.async_call( + DOMAIN, 'set_theme', {'name': 'happy'}, blocking=True) + await hass.services.async_call(DOMAIN, 'reload_themes', blocking=True) + + await client.send_json({ + 'id': 5, + 'type': 'frontend/get_themes', + }) + + msg = await client.receive_json() + + assert msg['result']['themes'] == {'sad': {'primary-color': 'blue'}} + assert msg['result']['default_theme'] == 'default' + + +async def test_missing_themes(hass, hass_ws_client): + """Test that themes API works when themes are not defined.""" + await async_setup_component(hass, 'frontend') + + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'frontend/get_themes', + }) + + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == wapi.TYPE_RESULT + assert msg['success'] + assert msg['result']['default_theme'] == 'default' + assert msg['result']['themes'] == {} + + +@asyncio.coroutine +def test_extra_urls(mock_http_client_with_urls): + """Test that extra urls are loaded.""" + resp = yield from mock_http_client_with_urls.get('/states?latest') + assert resp.status == 200 + text = yield from resp.text() + assert text.find("href='https://domain.com/my_extra_url.html'") >= 0 + + +@asyncio.coroutine +def test_extra_urls_es5(mock_http_client_with_urls): + """Test that es5 extra urls are loaded.""" + resp = yield from mock_http_client_with_urls.get('/states?es5') + assert resp.status == 200 + text = yield from resp.text() + assert text.find("href='https://domain.com/my_extra_url_es5.html'") >= 0 + + +async def test_get_panels(hass, hass_ws_client): + """Test get_panels command.""" + await async_setup_component(hass, 'frontend') + await hass.components.frontend.async_register_built_in_panel( + 'map', 'Map', 'mdi:account-location') + + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'get_panels', + }) + + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == wapi.TYPE_RESULT + assert msg['success'] + assert msg['result']['map']['component_name'] == 'map' + assert msg['result']['map']['url_path'] == 'map' + assert msg['result']['map']['icon'] == 'mdi:account-location' + assert msg['result']['map']['title'] == 'Map' + + +async def test_get_translations(hass, hass_ws_client): + """Test get_translations command.""" + await async_setup_component(hass, 'frontend') + client = await hass_ws_client(hass) + + with patch('homeassistant.components.frontend.async_get_translations', + side_effect=lambda hass, lang: mock_coro({'lang': lang})): + await client.send_json({ + 'id': 5, + 'type': 'frontend/get_translations', + 'language': 'nl', + }) + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == wapi.TYPE_RESULT + assert msg['success'] + assert msg['result'] == {'resources': {'lang': 'nl'}} + + +async def test_lovelace_ui(hass, hass_ws_client): + """Test lovelace_ui command.""" + await async_setup_component(hass, 'frontend') + client = await hass_ws_client(hass) + + with patch('homeassistant.components.frontend.load_yaml', + return_value={'hello': 'world'}): + await client.send_json({ + 'id': 5, + 'type': 'frontend/lovelace_config', + }) + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == wapi.TYPE_RESULT + assert msg['success'] + assert msg['result'] == {'hello': 'world'} + + +async def test_lovelace_ui_not_found(hass, hass_ws_client): + """Test lovelace_ui command cannot find file.""" + await async_setup_component(hass, 'frontend') + client = await hass_ws_client(hass) + + with patch('homeassistant.components.frontend.load_yaml', + side_effect=FileNotFoundError): + await client.send_json({ + 'id': 5, + 'type': 'frontend/lovelace_config', + }) + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == wapi.TYPE_RESULT + assert msg['success'] is False + assert msg['error']['code'] == 'file_not_found' + + +async def test_lovelace_ui_load_err(hass, hass_ws_client): + """Test lovelace_ui command cannot find file.""" + await async_setup_component(hass, 'frontend') + client = await hass_ws_client(hass) + + with patch('homeassistant.components.frontend.load_yaml', + side_effect=HomeAssistantError): + await client.send_json({ + 'id': 5, + 'type': 'frontend/lovelace_config', + }) + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == wapi.TYPE_RESULT + assert msg['success'] is False + assert msg['error']['code'] == 'load_error' + + +async def test_auth_load(mock_http_client): + """Test auth component loaded by default.""" + resp = await mock_http_client.get('/auth/providers') + assert resp.status == 200 + + +async def test_onboarding_load(mock_http_client): + """Test onboarding component loaded by default.""" + resp = await mock_http_client.get('/api/onboarding') + assert resp.status == 200 + + +async def test_auth_authorize(mock_http_client): + """Test the authorize endpoint works.""" + resp = await mock_http_client.get('/auth/authorize?hello=world') + assert resp.url.query_string == 'hello=world' + assert resp.url.path == '/frontend_es5/authorize.html' + + resp = await mock_http_client.get('/auth/authorize?latest&hello=world') + assert resp.url.query_string == 'latest&hello=world' + assert resp.url.path == '/frontend_latest/authorize.html' diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index cdaf4200c974e3..66e7747e06a776 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -74,8 +74,8 @@ async def test_sync_message(hass): 'willReportState': False, 'attributes': { 'colorModel': 'rgb', - 'temperatureMinK': 6535, - 'temperatureMaxK': 2000, + 'temperatureMinK': 2000, + 'temperatureMaxK': 6535, }, 'roomHint': 'Living Room' }] diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index e6336e05246038..1f7ee011e61bbf 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -414,8 +414,8 @@ async def test_color_temperature_light(hass): })) assert trt.sync_attributes() == { - 'temperatureMinK': 5000, - 'temperatureMaxK': 2000, + 'temperatureMinK': 2000, + 'temperatureMaxK': 5000, } assert trt.query_attributes() == { diff --git a/tests/components/group/test_init.py b/tests/components/group/test_init.py index 31ad70e8abac22..a5e9bbc0b820c2 100644 --- a/tests/components/group/test_init.py +++ b/tests/components/group/test_init.py @@ -365,8 +365,10 @@ def test_reloading_groups(self): 'icon': 'mdi:work', 'view': True, }}}): - group.reload(self.hass) - self.hass.block_till_done() + with patch('homeassistant.config.find_config_file', + return_value=''): + group.reload(self.hass) + self.hass.block_till_done() assert sorted(self.hass.states.entity_ids()) == \ ['group.all_tests', 'group.hello'] diff --git a/tests/components/hassio/test_http.py b/tests/components/hassio/test_http.py index ed425ad8cca593..ce260225097a12 100644 --- a/tests/components/hassio/test_http.py +++ b/tests/components/hassio/test_http.py @@ -47,8 +47,8 @@ def test_auth_required_forward_request(hassio_client): @asyncio.coroutine @pytest.mark.parametrize( 'build_type', [ - 'es5/index.html', 'es5/hassio-app.html', 'latest/index.html', - 'latest/hassio-app.html' + 'app/index.html', 'app/hassio-app.html', 'app/index.html', + 'app/hassio-app.html', 'app/some-chunk.js', 'app/app.js', ]) def test_forward_request_no_auth_for_panel(hassio_client, build_type): """Test no auth needed for .""" @@ -61,7 +61,7 @@ def test_forward_request_no_auth_for_panel(hassio_client, build_type): '_create_response') as mresp: mresp.return_value = 'response' resp = yield from hassio_client.get( - '/api/hassio/app-{}'.format(build_type)) + '/api/hassio/{}'.format(build_type)) # Check we got right response assert resp.status == 200 diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index e17419e7fd5fec..4fd59dd3f7aaeb 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -3,26 +3,43 @@ import os from unittest.mock import patch, Mock +import pytest + from homeassistant.setup import async_setup_component -from homeassistant.components.hassio import async_check_config +from homeassistant.components.hassio import ( + STORAGE_KEY, async_check_config) from tests.common import mock_coro -@asyncio.coroutine -def test_setup_api_ping(hass, aioclient_mock): - """Test setup with API ping.""" +MOCK_ENVIRON = { + 'HASSIO': '127.0.0.1', + 'HASSIO_TOKEN': 'abcdefgh', +} + + +@pytest.fixture(autouse=True) +def mock_all(aioclient_mock): + """Mock all setup requests.""" + aioclient_mock.post( + "http://127.0.0.1/homeassistant/options", json={'result': 'ok'}) aioclient_mock.get( "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) + aioclient_mock.post( + "http://127.0.0.1/supervisor/options", json={'result': 'ok'}) aioclient_mock.get( "http://127.0.0.1/homeassistant/info", json={ 'result': 'ok', 'data': {'last_version': '10.0'}}) - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): + +@asyncio.coroutine +def test_setup_api_ping(hass, aioclient_mock): + """Test setup with API ping.""" + with patch.dict(os.environ, MOCK_ENVIRON): result = yield from async_setup_component(hass, 'hassio', {}) assert result - assert aioclient_mock.call_count == 2 + assert aioclient_mock.call_count == 3 assert hass.components.hassio.get_homeassistant_version() == "10.0" assert hass.components.hassio.is_hassio() @@ -30,15 +47,7 @@ def test_setup_api_ping(hass, aioclient_mock): @asyncio.coroutine def test_setup_api_push_api_data(hass, aioclient_mock): """Test setup with API push.""" - aioclient_mock.get( - "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) - aioclient_mock.get( - "http://127.0.0.1/homeassistant/info", json={ - 'result': 'ok', 'data': {'last_version': '10.0'}}) - aioclient_mock.post( - "http://127.0.0.1/homeassistant/options", json={'result': 'ok'}) - - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): + with patch.dict(os.environ, MOCK_ENVIRON): result = yield from async_setup_component(hass, 'hassio', { 'http': { 'api_password': "123456", @@ -58,15 +67,7 @@ def test_setup_api_push_api_data(hass, aioclient_mock): @asyncio.coroutine def test_setup_api_push_api_data_server_host(hass, aioclient_mock): """Test setup with API push with active server host.""" - aioclient_mock.get( - "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) - aioclient_mock.get( - "http://127.0.0.1/homeassistant/info", json={ - 'result': 'ok', 'data': {'last_version': '10.0'}}) - aioclient_mock.post( - "http://127.0.0.1/homeassistant/options", json={'result': 'ok'}) - - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): + with patch.dict(os.environ, MOCK_ENVIRON): result = yield from async_setup_component(hass, 'hassio', { 'http': { 'api_password': "123456", @@ -84,19 +85,65 @@ def test_setup_api_push_api_data_server_host(hass, aioclient_mock): assert not aioclient_mock.mock_calls[1][2]['watchdog'] -@asyncio.coroutine -def test_setup_api_push_api_data_default(hass, aioclient_mock): +async def test_setup_api_push_api_data_default(hass, aioclient_mock, + hass_storage): """Test setup with API push default data.""" - aioclient_mock.get( - "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) - aioclient_mock.get( - "http://127.0.0.1/homeassistant/info", json={ - 'result': 'ok', 'data': {'last_version': '10.0'}}) - aioclient_mock.post( - "http://127.0.0.1/homeassistant/options", json={'result': 'ok'}) + with patch.dict(os.environ, MOCK_ENVIRON), \ + patch('homeassistant.auth.AuthManager.active', return_value=True): + result = await async_setup_component(hass, 'hassio', { + 'http': {}, + 'hassio': {} + }) + assert result - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): - result = yield from async_setup_component(hass, 'hassio', { + assert aioclient_mock.call_count == 3 + assert not aioclient_mock.mock_calls[1][2]['ssl'] + assert aioclient_mock.mock_calls[1][2]['password'] is None + assert aioclient_mock.mock_calls[1][2]['port'] == 8123 + refresh_token = aioclient_mock.mock_calls[1][2]['refresh_token'] + hassio_user = await hass.auth.async_get_user( + hass_storage[STORAGE_KEY]['data']['hassio_user'] + ) + assert hassio_user is not None + assert hassio_user.system_generated + for token in hassio_user.refresh_tokens.values(): + if token.token == refresh_token: + break + else: + assert False, 'refresh token not found' + + +async def test_setup_api_push_api_data_no_auth(hass, aioclient_mock, + hass_storage): + """Test setup with API push default data.""" + with patch.dict(os.environ, MOCK_ENVIRON): + result = await async_setup_component(hass, 'hassio', { + 'http': {}, + 'hassio': {} + }) + assert result + + assert aioclient_mock.call_count == 3 + assert not aioclient_mock.mock_calls[1][2]['ssl'] + assert aioclient_mock.mock_calls[1][2]['password'] is None + assert aioclient_mock.mock_calls[1][2]['port'] == 8123 + assert aioclient_mock.mock_calls[1][2]['refresh_token'] is None + + +async def test_setup_api_existing_hassio_user(hass, aioclient_mock, + hass_storage): + """Test setup with API push default data.""" + user = await hass.auth.async_create_system_user('Hass.io test') + token = await hass.auth.async_create_refresh_token(user) + hass_storage[STORAGE_KEY] = { + 'version': 1, + 'data': { + 'hassio_user': user.id + } + } + with patch.dict(os.environ, MOCK_ENVIRON), \ + patch('homeassistant.auth.AuthManager.active', return_value=True): + result = await async_setup_component(hass, 'hassio', { 'http': {}, 'hassio': {} }) @@ -106,20 +153,13 @@ def test_setup_api_push_api_data_default(hass, aioclient_mock): assert not aioclient_mock.mock_calls[1][2]['ssl'] assert aioclient_mock.mock_calls[1][2]['password'] is None assert aioclient_mock.mock_calls[1][2]['port'] == 8123 + assert aioclient_mock.mock_calls[1][2]['refresh_token'] == token.token @asyncio.coroutine def test_setup_core_push_timezone(hass, aioclient_mock): """Test setup with API push default data.""" - aioclient_mock.get( - "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) - aioclient_mock.get( - "http://127.0.0.1/homeassistant/info", json={ - 'result': 'ok', 'data': {'last_version': '10.0'}}) - aioclient_mock.post( - "http://127.0.0.1/supervisor/options", json={'result': 'ok'}) - - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}): + with patch.dict(os.environ, MOCK_ENVIRON): result = yield from async_setup_component(hass, 'hassio', { 'hassio': {}, 'homeassistant': { @@ -128,29 +168,21 @@ def test_setup_core_push_timezone(hass, aioclient_mock): }) assert result - assert aioclient_mock.call_count == 3 - assert aioclient_mock.mock_calls[1][2]['timezone'] == "testzone" + assert aioclient_mock.call_count == 4 + assert aioclient_mock.mock_calls[2][2]['timezone'] == "testzone" @asyncio.coroutine def test_setup_hassio_no_additional_data(hass, aioclient_mock): """Test setup with API push default data.""" - aioclient_mock.get( - "http://127.0.0.1/supervisor/ping", json={'result': 'ok'}) - aioclient_mock.get( - "http://127.0.0.1/homeassistant/info", json={ - 'result': 'ok', 'data': {'last_version': '10.0'}}) - aioclient_mock.get( - "http://127.0.0.1/homeassistant/info", json={'result': 'ok'}) - - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}), \ + with patch.dict(os.environ, MOCK_ENVIRON), \ patch.dict(os.environ, {'HASSIO_TOKEN': "123456"}): result = yield from async_setup_component(hass, 'hassio', { 'hassio': {}, }) assert result - assert aioclient_mock.call_count == 2 + assert aioclient_mock.call_count == 3 assert aioclient_mock.mock_calls[-1][3]['X-HASSIO-KEY'] == "123456" @@ -165,7 +197,7 @@ def test_fail_setup_without_environ_var(hass): @asyncio.coroutine def test_fail_setup_cannot_connect(hass): """Fail setup if cannot connect.""" - with patch.dict(os.environ, {'HASSIO': "127.0.0.1"}), \ + with patch.dict(os.environ, MOCK_ENVIRON), \ patch('homeassistant.components.hassio.HassIO.is_connected', Mock(return_value=mock_coro(None))): result = yield from async_setup_component(hass, 'hassio', {}) @@ -228,14 +260,14 @@ def test_service_calls(hassio_env, hass, aioclient_mock): 'hassio', 'addon_stdin', {'addon': 'test', 'input': 'test'}) yield from hass.async_block_till_done() - assert aioclient_mock.call_count == 4 + assert aioclient_mock.call_count == 5 assert aioclient_mock.mock_calls[-1][2] == 'test' yield from hass.services.async_call('hassio', 'host_shutdown', {}) yield from hass.services.async_call('hassio', 'host_reboot', {}) yield from hass.async_block_till_done() - assert aioclient_mock.call_count == 6 + assert aioclient_mock.call_count == 7 yield from hass.services.async_call('hassio', 'snapshot_full', {}) yield from hass.services.async_call('hassio', 'snapshot_partial', { @@ -245,7 +277,7 @@ def test_service_calls(hassio_env, hass, aioclient_mock): }) yield from hass.async_block_till_done() - assert aioclient_mock.call_count == 8 + assert aioclient_mock.call_count == 9 assert aioclient_mock.mock_calls[-1][2] == { 'addons': ['test'], 'folders': ['ssl'], 'password': "123456"} @@ -261,7 +293,7 @@ def test_service_calls(hassio_env, hass, aioclient_mock): }) yield from hass.async_block_till_done() - assert aioclient_mock.call_count == 10 + assert aioclient_mock.call_count == 11 assert aioclient_mock.mock_calls[-1][2] == { 'addons': ['test'], 'folders': ['ssl'], 'homeassistant': False, 'password': "123456" @@ -283,17 +315,17 @@ def test_service_calls_core(hassio_env, hass, aioclient_mock): yield from hass.services.async_call('homeassistant', 'stop') yield from hass.async_block_till_done() - assert aioclient_mock.call_count == 1 + assert aioclient_mock.call_count == 2 yield from hass.services.async_call('homeassistant', 'check_config') yield from hass.async_block_till_done() - assert aioclient_mock.call_count == 2 + assert aioclient_mock.call_count == 3 yield from hass.services.async_call('homeassistant', 'restart') yield from hass.async_block_till_done() - assert aioclient_mock.call_count == 4 + assert aioclient_mock.call_count == 5 @asyncio.coroutine diff --git a/tests/components/homekit/common.py b/tests/components/homekit/common.py new file mode 100644 index 00000000000000..915759f22d6c74 --- /dev/null +++ b/tests/components/homekit/common.py @@ -0,0 +1,8 @@ +"""Collection of fixtures and functions for the HomeKit tests.""" +from unittest.mock import patch + + +def patch_debounce(): + """Return patch for debounce method.""" + return patch('homeassistant.components.homekit.accessories.debounce', + lambda f: lambda *args, **kwargs: f(*args, **kwargs)) diff --git a/tests/components/homekit/conftest.py b/tests/components/homekit/conftest.py new file mode 100644 index 00000000000000..55e02de752608c --- /dev/null +++ b/tests/components/homekit/conftest.py @@ -0,0 +1,16 @@ +"""HomeKit session fixtures.""" +from unittest.mock import patch + +import pytest + +from pyhap.accessory_driver import AccessoryDriver + + +@pytest.fixture(scope='session') +def hk_driver(): + """Return a custom AccessoryDriver instance for HomeKit accessory init.""" + with patch('pyhap.accessory_driver.Zeroconf'), \ + patch('pyhap.accessory_driver.AccessoryEncoder'), \ + patch('pyhap.accessory_driver.HAPServer'), \ + patch('pyhap.accessory_driver.AccessoryDriver.publish'): + return AccessoryDriver(pincode=b'123-45-678', address='127.0.0.1') diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index faa982f62f3f3e..edb1c7175f85fc 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -3,162 +3,205 @@ This includes tests for all mock object types. """ from datetime import datetime, timedelta -import unittest -from unittest.mock import call, patch, Mock +from unittest.mock import patch, Mock + +import pytest from homeassistant.components.homekit.accessories import ( debounce, HomeAccessory, HomeBridge, HomeDriver) from homeassistant.components.homekit.const import ( - BRIDGE_MODEL, BRIDGE_NAME, BRIDGE_SERIAL_NUMBER, SERV_ACCESSORY_INFO, - CHAR_FIRMWARE_REVISION, CHAR_MANUFACTURER, CHAR_MODEL, CHAR_NAME, - CHAR_SERIAL_NUMBER, MANUFACTURER) -from homeassistant.const import __version__, ATTR_NOW, EVENT_TIME_CHANGED + BRIDGE_MODEL, BRIDGE_NAME, BRIDGE_SERIAL_NUMBER, CHAR_FIRMWARE_REVISION, + CHAR_MANUFACTURER, CHAR_MODEL, CHAR_NAME, CHAR_SERIAL_NUMBER, + MANUFACTURER, SERV_ACCESSORY_INFO) +from homeassistant.const import ( + __version__, ATTR_BATTERY_CHARGING, ATTR_BATTERY_LEVEL, ATTR_NOW, + EVENT_TIME_CHANGED) import homeassistant.util.dt as dt_util -from tests.common import get_test_home_assistant - - -def patch_debounce(): - """Return patch for debounce method.""" - return patch('homeassistant.components.homekit.accessories.debounce', - lambda f: lambda *args, **kwargs: f(*args, **kwargs)) - - -class TestAccessories(unittest.TestCase): - """Test pyhap adapter methods.""" - - def test_debounce(self): - """Test add_timeout decorator function.""" - def demo_func(*args): - nonlocal arguments, counter - counter += 1 - arguments = args - - arguments = None - counter = 0 - hass = get_test_home_assistant() - mock = Mock(hass=hass) - - debounce_demo = debounce(demo_func) - self.assertEqual(debounce_demo.__name__, 'demo_func') - now = datetime(2018, 1, 1, 20, 0, 0, tzinfo=dt_util.UTC) - - with patch('homeassistant.util.dt.utcnow', return_value=now): - debounce_demo(mock, 'value') - hass.bus.fire( - EVENT_TIME_CHANGED, {ATTR_NOW: now + timedelta(seconds=3)}) - hass.block_till_done() - assert counter == 1 - assert len(arguments) == 2 - - with patch('homeassistant.util.dt.utcnow', return_value=now): - debounce_demo(mock, 'value') - debounce_demo(mock, 'value') - - hass.bus.fire( - EVENT_TIME_CHANGED, {ATTR_NOW: now + timedelta(seconds=3)}) - hass.block_till_done() - assert counter == 2 - - hass.stop() - - def test_home_accessory(self): - """Test HomeAccessory class.""" - hass = get_test_home_assistant() - - acc = HomeAccessory(hass, 'Home Accessory', 'homekit.accessory', 2) - self.assertEqual(acc.hass, hass) - self.assertEqual(acc.display_name, 'Home Accessory') - self.assertEqual(acc.category, 1) # Category.OTHER - self.assertEqual(len(acc.services), 1) - serv = acc.services[0] # SERV_ACCESSORY_INFO - self.assertEqual(serv.display_name, SERV_ACCESSORY_INFO) - self.assertEqual( - serv.get_characteristic(CHAR_NAME).value, 'Home Accessory') - self.assertEqual( - serv.get_characteristic(CHAR_MANUFACTURER).value, MANUFACTURER) - self.assertEqual( - serv.get_characteristic(CHAR_MODEL).value, 'Homekit') - self.assertEqual(serv.get_characteristic(CHAR_SERIAL_NUMBER).value, - 'homekit.accessory') - - hass.states.set('homekit.accessory', 'on') - hass.block_till_done() - acc.run() - hass.states.set('homekit.accessory', 'off') - hass.block_till_done() - - acc = HomeAccessory('hass', 'test_name', 'test_model.demo', 2) - self.assertEqual(acc.display_name, 'test_name') - self.assertEqual(acc.aid, 2) - self.assertEqual(len(acc.services), 1) - serv = acc.services[0] # SERV_ACCESSORY_INFO - self.assertEqual( - serv.get_characteristic(CHAR_MODEL).value, 'Test Model') - - hass.stop() - - def test_home_bridge(self): - """Test HomeBridge class.""" - bridge = HomeBridge('hass') - self.assertEqual(bridge.hass, 'hass') - self.assertEqual(bridge.display_name, BRIDGE_NAME) - self.assertEqual(bridge.category, 2) # Category.BRIDGE - self.assertEqual(len(bridge.services), 1) - serv = bridge.services[0] # SERV_ACCESSORY_INFO - self.assertEqual(serv.display_name, SERV_ACCESSORY_INFO) - self.assertEqual( - serv.get_characteristic(CHAR_NAME).value, BRIDGE_NAME) - self.assertEqual( - serv.get_characteristic(CHAR_FIRMWARE_REVISION).value, __version__) - self.assertEqual( - serv.get_characteristic(CHAR_MANUFACTURER).value, MANUFACTURER) - self.assertEqual( - serv.get_characteristic(CHAR_MODEL).value, BRIDGE_MODEL) - self.assertEqual( - serv.get_characteristic(CHAR_SERIAL_NUMBER).value, - BRIDGE_SERIAL_NUMBER) - - bridge = HomeBridge('hass', 'test_name') - self.assertEqual(bridge.display_name, 'test_name') - self.assertEqual(len(bridge.services), 1) - serv = bridge.services[0] # SERV_ACCESSORY_INFO - - # setup_message - bridge.setup_message() - - # add_paired_client - with patch('pyhap.accessory.Accessory.add_paired_client') \ - as mock_add_paired_client, \ - patch('homeassistant.components.homekit.accessories.' - 'dismiss_setup_message') as mock_dissmiss_msg: - bridge.add_paired_client('client_uuid', 'client_public') - - self.assertEqual(mock_add_paired_client.call_args, - call('client_uuid', 'client_public')) - self.assertEqual(mock_dissmiss_msg.call_args, call('hass')) - - # remove_paired_client - with patch('pyhap.accessory.Accessory.remove_paired_client') \ - as mock_remove_paired_client, \ - patch('homeassistant.components.homekit.accessories.' - 'show_setup_message') as mock_show_msg: - bridge.remove_paired_client('client_uuid') - - self.assertEqual( - mock_remove_paired_client.call_args, call('client_uuid')) - self.assertEqual(mock_show_msg.call_args, call('hass', bridge)) - - def test_home_driver(self): - """Test HomeDriver class.""" - bridge = HomeBridge('hass') - ip_address = '127.0.0.1' - port = 51826 - path = '.homekit.state' - - with patch('pyhap.accessory_driver.AccessoryDriver.__init__') \ - as mock_driver: - HomeDriver(bridge, ip_address, port, path) - - self.assertEqual( - mock_driver.call_args, call(bridge, ip_address, port, path)) + +async def test_debounce(hass): + """Test add_timeout decorator function.""" + def demo_func(*args): + nonlocal arguments, counter + counter += 1 + arguments = args + + arguments = None + counter = 0 + mock = Mock(hass=hass, debounce={}) + + debounce_demo = debounce(demo_func) + assert debounce_demo.__name__ == 'demo_func' + now = datetime(2018, 1, 1, 20, 0, 0, tzinfo=dt_util.UTC) + + with patch('homeassistant.util.dt.utcnow', return_value=now): + await hass.async_add_job(debounce_demo, mock, 'value') + hass.bus.async_fire( + EVENT_TIME_CHANGED, {ATTR_NOW: now + timedelta(seconds=3)}) + await hass.async_block_till_done() + assert counter == 1 + assert len(arguments) == 2 + + with patch('homeassistant.util.dt.utcnow', return_value=now): + await hass.async_add_job(debounce_demo, mock, 'value') + await hass.async_add_job(debounce_demo, mock, 'value') + + hass.bus.async_fire( + EVENT_TIME_CHANGED, {ATTR_NOW: now + timedelta(seconds=3)}) + await hass.async_block_till_done() + assert counter == 2 + + +async def test_home_accessory(hass, hk_driver): + """Test HomeAccessory class.""" + entity_id = 'homekit.accessory' + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + + acc = HomeAccessory(hass, hk_driver, 'Home Accessory', entity_id, 2, None) + assert acc.hass == hass + assert acc.display_name == 'Home Accessory' + assert acc.aid == 2 + assert acc.category == 1 # Category.OTHER + assert len(acc.services) == 1 + serv = acc.services[0] # SERV_ACCESSORY_INFO + assert serv.display_name == SERV_ACCESSORY_INFO + assert serv.get_characteristic(CHAR_NAME).value == 'Home Accessory' + assert serv.get_characteristic(CHAR_MANUFACTURER).value == MANUFACTURER + assert serv.get_characteristic(CHAR_MODEL).value == 'Homekit' + assert serv.get_characteristic(CHAR_SERIAL_NUMBER).value == \ + 'homekit.accessory' + + hass.states.async_set(entity_id, 'on') + await hass.async_block_till_done() + with patch('homeassistant.components.homekit.accessories.' + 'HomeAccessory.update_state') as mock_update_state: + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + state = hass.states.get(entity_id) + mock_update_state.assert_called_with(state) + + hass.states.async_remove(entity_id) + await hass.async_block_till_done() + assert mock_update_state.call_count == 1 + + with pytest.raises(NotImplementedError): + acc.update_state('new_state') + + # Test model name from domain + entity_id = 'test_model.demo' + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = HomeAccessory(hass, hk_driver, 'test_name', entity_id, 2, None) + serv = acc.services[0] # SERV_ACCESSORY_INFO + assert serv.get_characteristic(CHAR_MODEL).value == 'Test Model' + + +async def test_battery_service(hass, hk_driver): + """Test battery service.""" + entity_id = 'homekit.accessory' + hass.states.async_set(entity_id, None, {ATTR_BATTERY_LEVEL: 50}) + await hass.async_block_till_done() + + acc = HomeAccessory(hass, hk_driver, 'Battery Service', entity_id, 2, None) + acc.update_state = lambda x: None + assert acc._char_battery.value == 0 + assert acc._char_low_battery.value == 0 + assert acc._char_charging.value == 2 + + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + assert acc._char_battery.value == 50 + assert acc._char_low_battery.value == 0 + assert acc._char_charging.value == 2 + + hass.states.async_set(entity_id, None, {ATTR_BATTERY_LEVEL: 15}) + await hass.async_block_till_done() + assert acc._char_battery.value == 15 + assert acc._char_low_battery.value == 1 + assert acc._char_charging.value == 2 + + # Test charging + hass.states.async_set(entity_id, None, { + ATTR_BATTERY_LEVEL: 10, ATTR_BATTERY_CHARGING: True}) + await hass.async_block_till_done() + + acc = HomeAccessory(hass, hk_driver, 'Battery Service', entity_id, 2, None) + acc.update_state = lambda x: None + assert acc._char_battery.value == 0 + assert acc._char_low_battery.value == 0 + assert acc._char_charging.value == 2 + + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + assert acc._char_battery.value == 10 + assert acc._char_low_battery.value == 1 + assert acc._char_charging.value == 1 + + hass.states.async_set(entity_id, None, { + ATTR_BATTERY_LEVEL: 100, ATTR_BATTERY_CHARGING: False}) + await hass.async_block_till_done() + assert acc._char_battery.value == 100 + assert acc._char_low_battery.value == 0 + assert acc._char_charging.value == 0 + + +def test_home_bridge(hk_driver): + """Test HomeBridge class.""" + bridge = HomeBridge('hass', hk_driver, BRIDGE_NAME) + assert bridge.hass == 'hass' + assert bridge.display_name == BRIDGE_NAME + assert bridge.category == 2 # Category.BRIDGE + assert len(bridge.services) == 1 + serv = bridge.services[0] # SERV_ACCESSORY_INFO + assert serv.display_name == SERV_ACCESSORY_INFO + assert serv.get_characteristic(CHAR_NAME).value == BRIDGE_NAME + assert serv.get_characteristic(CHAR_FIRMWARE_REVISION).value == __version__ + assert serv.get_characteristic(CHAR_MANUFACTURER).value == MANUFACTURER + assert serv.get_characteristic(CHAR_MODEL).value == BRIDGE_MODEL + assert serv.get_characteristic(CHAR_SERIAL_NUMBER).value == \ + BRIDGE_SERIAL_NUMBER + + bridge = HomeBridge('hass', hk_driver, 'test_name') + assert bridge.display_name == 'test_name' + assert len(bridge.services) == 1 + serv = bridge.services[0] # SERV_ACCESSORY_INFO + + # setup_message + bridge.setup_message() + + +def test_home_driver(): + """Test HomeDriver class.""" + ip_address = '127.0.0.1' + port = 51826 + path = '.homekit.state' + pin = b'123-45-678' + + with patch('pyhap.accessory_driver.AccessoryDriver.__init__') \ + as mock_driver: + driver = HomeDriver('hass', address=ip_address, port=port, + persist_file=path) + + mock_driver.assert_called_with(address=ip_address, port=port, + persist_file=path) + driver.state = Mock(pincode=pin) + + # pair + with patch('pyhap.accessory_driver.AccessoryDriver.pair') as mock_pair, \ + patch('homeassistant.components.homekit.accessories.' + 'dismiss_setup_message') as mock_dissmiss_msg: + driver.pair('client_uuid', 'client_public') + + mock_pair.assert_called_with('client_uuid', 'client_public') + mock_dissmiss_msg.assert_called_with('hass') + + # unpair + with patch('pyhap.accessory_driver.AccessoryDriver.unpair') \ + as mock_unpair, \ + patch('homeassistant.components.homekit.accessories.' + 'show_setup_message') as mock_show_msg: + driver.unpair('client_uuid') + + mock_unpair.assert_called_with('client_uuid') + mock_show_msg.assert_called_with('hass', pin) diff --git a/tests/components/homekit/test_get_accessories.py b/tests/components/homekit/test_get_accessories.py index cff52b2ff20c6e..92f8736d1fe753 100644 --- a/tests/components/homekit/test_get_accessories.py +++ b/tests/components/homekit/test_get_accessories.py @@ -1,213 +1,148 @@ """Package to test the get_accessory method.""" -import logging -import unittest from unittest.mock import patch, Mock +import pytest + from homeassistant.core import State -from homeassistant.components.cover import ( - SUPPORT_OPEN, SUPPORT_CLOSE) -from homeassistant.components.climate import ( - SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW) +import homeassistant.components.cover as cover +import homeassistant.components.climate as climate +import homeassistant.components.media_player as media_player from homeassistant.components.homekit import get_accessory, TYPES +from homeassistant.components.homekit.const import ( + CONF_FEATURE_LIST, FEATURE_ON_OFF, TYPE_OUTLET, TYPE_SWITCH) from homeassistant.const import ( - ATTR_CODE, ATTR_UNIT_OF_MEASUREMENT, ATTR_SUPPORTED_FEATURES, - TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_DEVICE_CLASS) - -_LOGGER = logging.getLogger(__name__) + ATTR_CODE, ATTR_DEVICE_CLASS, ATTR_SUPPORTED_FEATURES, + ATTR_UNIT_OF_MEASUREMENT, CONF_NAME, CONF_TYPE, TEMP_CELSIUS, + TEMP_FAHRENHEIT) -CONFIG = {} +def test_not_supported(caplog): + """Test if none is returned if entity isn't supported.""" + # not supported entity + assert get_accessory(None, None, State('demo.demo', 'on'), 2, {}) \ + is None -def test_get_accessory_invalid_aid(caplog): - """Test with unsupported component.""" - assert get_accessory(None, State('light.demo', 'on'), - None, config=None) is None + # invalid aid + assert get_accessory(None, None, State('light.demo', 'on'), None, None) \ + is None assert caplog.records[0].levelname == 'WARNING' assert 'invalid aid' in caplog.records[0].msg -def test_not_supported(): - """Test if none is returned if entity isn't supported.""" - assert get_accessory(None, State('demo.demo', 'on'), 2, config=None) \ - is None - - -class TestGetAccessories(unittest.TestCase): - """Methods to test the get_accessory method.""" - - def setUp(self): - """Setup Mock type.""" - self.mock_type = Mock() - - def tearDown(self): - """Test if mock type was called.""" - self.assertTrue(self.mock_type.called) - - def test_sensor_temperature(self): - """Test temperature sensor with device class temperature.""" - with patch.dict(TYPES, {'TemperatureSensor': self.mock_type}): - state = State('sensor.temperature', '23', - {ATTR_DEVICE_CLASS: 'temperature'}) - get_accessory(None, state, 2, {}) - - def test_sensor_temperature_celsius(self): - """Test temperature sensor with Celsius as unit.""" - with patch.dict(TYPES, {'TemperatureSensor': self.mock_type}): - state = State('sensor.temperature', '23', - {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) - get_accessory(None, state, 2, {}) - - def test_sensor_temperature_fahrenheit(self): - """Test temperature sensor with Fahrenheit as unit.""" - with patch.dict(TYPES, {'TemperatureSensor': self.mock_type}): - state = State('sensor.temperature', '74', - {ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT}) - get_accessory(None, state, 2, {}) - - def test_sensor_humidity(self): - """Test humidity sensor with device class humidity.""" - with patch.dict(TYPES, {'HumiditySensor': self.mock_type}): - state = State('sensor.humidity', '20', - {ATTR_DEVICE_CLASS: 'humidity', - ATTR_UNIT_OF_MEASUREMENT: '%'}) - get_accessory(None, state, 2, {}) - - def test_air_quality_sensor(self): - """Test air quality sensor with pm25 class.""" - with patch.dict(TYPES, {'AirQualitySensor': self.mock_type}): - state = State('sensor.air_quality', '40', - {ATTR_DEVICE_CLASS: 'pm25'}) - get_accessory(None, state, 2, {}) - - def test_air_quality_sensor_entity_id(self): - """Test air quality sensor with entity_id contains pm25.""" - with patch.dict(TYPES, {'AirQualitySensor': self.mock_type}): - state = State('sensor.air_quality_pm25', '40', {}) - get_accessory(None, state, 2, {}) - - def test_co2_sensor(self): - """Test co2 sensor with device class co2.""" - with patch.dict(TYPES, {'CarbonDioxideSensor': self.mock_type}): - state = State('sensor.airmeter', '500', - {ATTR_DEVICE_CLASS: 'co2'}) - get_accessory(None, state, 2, {}) - - def test_co2_sensor_entity_id(self): - """Test co2 sensor with entity_id contains co2.""" - with patch.dict(TYPES, {'CarbonDioxideSensor': self.mock_type}): - state = State('sensor.airmeter_co2', '500', {}) - get_accessory(None, state, 2, {}) - - def test_light_sensor(self): - """Test light sensor with device class illuminance.""" - with patch.dict(TYPES, {'LightSensor': self.mock_type}): - state = State('sensor.light', '900', - {ATTR_DEVICE_CLASS: 'illuminance'}) - get_accessory(None, state, 2, {}) - - def test_light_sensor_unit_lm(self): - """Test light sensor with lm as unit.""" - with patch.dict(TYPES, {'LightSensor': self.mock_type}): - state = State('sensor.light', '900', - {ATTR_UNIT_OF_MEASUREMENT: 'lm'}) - get_accessory(None, state, 2, {}) - - def test_light_sensor_unit_lx(self): - """Test light sensor with lx as unit.""" - with patch.dict(TYPES, {'LightSensor': self.mock_type}): - state = State('sensor.light', '900', - {ATTR_UNIT_OF_MEASUREMENT: 'lx'}) - get_accessory(None, state, 2, {}) - - def test_binary_sensor(self): - """Test binary sensor with opening class.""" - with patch.dict(TYPES, {'BinarySensor': self.mock_type}): - state = State('binary_sensor.opening', 'on', - {ATTR_DEVICE_CLASS: 'opening'}) - get_accessory(None, state, 2, {}) - - def test_device_tracker(self): - """Test binary sensor with opening class.""" - with patch.dict(TYPES, {'BinarySensor': self.mock_type}): - state = State('device_tracker.someone', 'not_home', {}) - get_accessory(None, state, 2, {}) - - def test_garage_door(self): - """Test cover with device_class: 'garage' and required features.""" - with patch.dict(TYPES, {'GarageDoorOpener': self.mock_type}): - state = State('cover.garage_door', 'open', { - ATTR_DEVICE_CLASS: 'garage', - ATTR_SUPPORTED_FEATURES: - SUPPORT_OPEN | SUPPORT_CLOSE}) - get_accessory(None, state, 2, {}) - - def test_cover_set_position(self): - """Test cover with support for set_cover_position.""" - with patch.dict(TYPES, {'WindowCovering': self.mock_type}): - state = State('cover.set_position', 'open', - {ATTR_SUPPORTED_FEATURES: 4}) - get_accessory(None, state, 2, {}) - - def test_cover_open_close(self): - """Test cover with support for open and close.""" - with patch.dict(TYPES, {'WindowCoveringBasic': self.mock_type}): - state = State('cover.open_window', 'open', - {ATTR_SUPPORTED_FEATURES: 3}) - get_accessory(None, state, 2, {}) - - def test_alarm_control_panel(self): - """Test alarm control panel.""" - config = {ATTR_CODE: '1234'} - with patch.dict(TYPES, {'SecuritySystem': self.mock_type}): - state = State('alarm_control_panel.test', 'armed') - get_accessory(None, state, 2, config) - - # pylint: disable=unsubscriptable-object - print(self.mock_type.call_args[1]) - self.assertEqual( - self.mock_type.call_args[1]['config'][ATTR_CODE], '1234') - - def test_climate(self): - """Test climate devices.""" - with patch.dict(TYPES, {'Thermostat': self.mock_type}): - state = State('climate.test', 'auto') - get_accessory(None, state, 2, {}) - - def test_light(self): - """Test light devices.""" - with patch.dict(TYPES, {'Light': self.mock_type}): - state = State('light.test', 'on') - get_accessory(None, state, 2, {}) - - def test_climate_support_auto(self): - """Test climate devices with support for auto mode.""" - with patch.dict(TYPES, {'Thermostat': self.mock_type}): - state = State('climate.test', 'auto', { - ATTR_SUPPORTED_FEATURES: - SUPPORT_TARGET_TEMPERATURE_LOW | - SUPPORT_TARGET_TEMPERATURE_HIGH}) - get_accessory(None, state, 2, {}) - - def test_switch(self): - """Test switch.""" - with patch.dict(TYPES, {'Switch': self.mock_type}): - state = State('switch.test', 'on') - get_accessory(None, state, 2, {}) - - def test_remote(self): - """Test remote.""" - with patch.dict(TYPES, {'Switch': self.mock_type}): - state = State('remote.test', 'on') - get_accessory(None, state, 2, {}) - - def test_input_boolean(self): - """Test input_boolean.""" - with patch.dict(TYPES, {'Switch': self.mock_type}): - state = State('input_boolean.test', 'on') - get_accessory(None, state, 2, {}) - - def test_lock(self): - """Test lock.""" - with patch.dict(TYPES, {'Lock': self.mock_type}): - state = State('lock.test', 'locked') - get_accessory(None, state, 2, {}) +def test_not_supported_media_player(): + """Test if mode isn't supported and if no supported modes.""" + # selected mode for entity not supported + config = {CONF_FEATURE_LIST: {FEATURE_ON_OFF: None}} + entity_state = State('media_player.demo', 'on') + assert get_accessory(None, None, entity_state, 2, config) is None + + # no supported modes for entity + entity_state = State('media_player.demo', 'on') + assert get_accessory(None, None, entity_state, 2, {}) is None + + +@pytest.mark.parametrize('config, name', [ + ({CONF_NAME: 'Customize Name'}, 'Customize Name'), +]) +def test_customize_options(config, name): + """Test with customized options.""" + mock_type = Mock() + with patch.dict(TYPES, {'Light': mock_type}): + entity_state = State('light.demo', 'on') + get_accessory(None, None, entity_state, 2, config) + mock_type.assert_called_with(None, None, name, + 'light.demo', 2, config) + + +@pytest.mark.parametrize('type_name, entity_id, state, attrs, config', [ + ('Fan', 'fan.test', 'on', {}, {}), + ('Light', 'light.test', 'on', {}, {}), + ('Lock', 'lock.test', 'locked', {}, {ATTR_CODE: '1234'}), + ('MediaPlayer', 'media_player.test', 'on', + {ATTR_SUPPORTED_FEATURES: media_player.SUPPORT_TURN_ON | + media_player.SUPPORT_TURN_OFF}, {CONF_FEATURE_LIST: + {FEATURE_ON_OFF: None}}), + ('SecuritySystem', 'alarm_control_panel.test', 'armed_away', {}, + {ATTR_CODE: '1234'}), + ('Thermostat', 'climate.test', 'auto', {}, {}), + ('Thermostat', 'climate.test', 'auto', + {ATTR_SUPPORTED_FEATURES: climate.SUPPORT_TARGET_TEMPERATURE_LOW | + climate.SUPPORT_TARGET_TEMPERATURE_HIGH}, {}), +]) +def test_types(type_name, entity_id, state, attrs, config): + """Test if types are associated correctly.""" + mock_type = Mock() + with patch.dict(TYPES, {type_name: mock_type}): + entity_state = State(entity_id, state, attrs) + get_accessory(None, None, entity_state, 2, config) + assert mock_type.called + + if config: + assert mock_type.call_args[0][-1] == config + + +@pytest.mark.parametrize('type_name, entity_id, state, attrs', [ + ('GarageDoorOpener', 'cover.garage_door', 'open', + {ATTR_DEVICE_CLASS: 'garage', + ATTR_SUPPORTED_FEATURES: cover.SUPPORT_OPEN | cover.SUPPORT_CLOSE}), + ('WindowCovering', 'cover.set_position', 'open', + {ATTR_SUPPORTED_FEATURES: 4}), + ('WindowCoveringBasic', 'cover.open_window', 'open', + {ATTR_SUPPORTED_FEATURES: 3}), +]) +def test_type_covers(type_name, entity_id, state, attrs): + """Test if cover types are associated correctly.""" + mock_type = Mock() + with patch.dict(TYPES, {type_name: mock_type}): + entity_state = State(entity_id, state, attrs) + get_accessory(None, None, entity_state, 2, {}) + assert mock_type.called + + +@pytest.mark.parametrize('type_name, entity_id, state, attrs', [ + ('BinarySensor', 'binary_sensor.opening', 'on', + {ATTR_DEVICE_CLASS: 'opening'}), + ('BinarySensor', 'device_tracker.someone', 'not_home', {}), + ('AirQualitySensor', 'sensor.air_quality_pm25', '40', {}), + ('AirQualitySensor', 'sensor.air_quality', '40', + {ATTR_DEVICE_CLASS: 'pm25'}), + ('CarbonDioxideSensor', 'sensor.airmeter_co2', '500', {}), + ('CarbonDioxideSensor', 'sensor.airmeter', '500', + {ATTR_DEVICE_CLASS: 'co2'}), + ('HumiditySensor', 'sensor.humidity', '20', + {ATTR_DEVICE_CLASS: 'humidity', ATTR_UNIT_OF_MEASUREMENT: '%'}), + ('LightSensor', 'sensor.light', '900', {ATTR_DEVICE_CLASS: 'illuminance'}), + ('LightSensor', 'sensor.light', '900', {ATTR_UNIT_OF_MEASUREMENT: 'lm'}), + ('LightSensor', 'sensor.light', '900', {ATTR_UNIT_OF_MEASUREMENT: 'lx'}), + ('TemperatureSensor', 'sensor.temperature', '23', + {ATTR_DEVICE_CLASS: 'temperature'}), + ('TemperatureSensor', 'sensor.temperature', '23', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}), + ('TemperatureSensor', 'sensor.temperature', '74', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT}), +]) +def test_type_sensors(type_name, entity_id, state, attrs): + """Test if sensor types are associated correctly.""" + mock_type = Mock() + with patch.dict(TYPES, {type_name: mock_type}): + entity_state = State(entity_id, state, attrs) + get_accessory(None, None, entity_state, 2, {}) + assert mock_type.called + + +@pytest.mark.parametrize('type_name, entity_id, state, attrs, config', [ + ('Outlet', 'switch.test', 'on', {}, {CONF_TYPE: TYPE_OUTLET}), + ('Switch', 'automation.test', 'on', {}, {}), + ('Switch', 'input_boolean.test', 'on', {}, {}), + ('Switch', 'remote.test', 'on', {}, {}), + ('Switch', 'script.test', 'on', {}, {}), + ('Switch', 'switch.test', 'on', {}, {}), + ('Switch', 'switch.test', 'on', {}, {CONF_TYPE: TYPE_SWITCH}), +]) +def test_type_switches(type_name, entity_id, state, attrs, config): + """Test if switch types are associated correctly.""" + mock_type = Mock() + with patch.dict(TYPES, {type_name: mock_type}): + entity_state = State(entity_id, state, attrs) + get_accessory(None, None, entity_state, 2, config) + assert mock_type.called diff --git a/tests/components/homekit/test_homekit.py b/tests/components/homekit/test_homekit.py index 082953038b52a7..f8afb4a49ab419 100644 --- a/tests/components/homekit/test_homekit.py +++ b/tests/components/homekit/test_homekit.py @@ -1,223 +1,219 @@ """Tests for the HomeKit component.""" -import unittest -from unittest.mock import call, patch, ANY, Mock +from unittest.mock import patch, ANY, Mock + +import pytest from homeassistant import setup -from homeassistant.core import State from homeassistant.components.homekit import ( - HomeKit, generate_aid, - STATUS_READY, STATUS_RUNNING, STATUS_STOPPED, STATUS_WAIT) + generate_aid, HomeKit, STATUS_READY, STATUS_RUNNING, + STATUS_STOPPED, STATUS_WAIT) from homeassistant.components.homekit.accessories import HomeBridge from homeassistant.components.homekit.const import ( - DOMAIN, HOMEKIT_FILE, CONF_AUTO_START, - DEFAULT_PORT, SERVICE_HOMEKIT_START) -from homeassistant.helpers.entityfilter import generate_filter + CONF_AUTO_START, BRIDGE_NAME, DEFAULT_PORT, DOMAIN, HOMEKIT_FILE, + SERVICE_HOMEKIT_START) from homeassistant.const import ( - CONF_IP_ADDRESS, CONF_PORT, + CONF_NAME, CONF_IP_ADDRESS, CONF_PORT, EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) +from homeassistant.core import State +from homeassistant.helpers.entityfilter import generate_filter -from tests.common import get_test_home_assistant -from tests.components.homekit.test_accessories import patch_debounce +from tests.components.homekit.common import patch_debounce IP_ADDRESS = '127.0.0.1' PATH_HOMEKIT = 'homeassistant.components.homekit' -class TestHomeKit(unittest.TestCase): - """Test setup of HomeKit component and HomeKit class.""" +@pytest.fixture(scope='module') +def debounce_patcher(): + """Patch debounce method.""" + patcher = patch_debounce() + yield patcher.start() + patcher.stop() - @classmethod - def setUpClass(cls): - """Setup debounce patcher.""" - cls.patcher = patch_debounce() - cls.patcher.start() - @classmethod - def tearDownClass(cls): - """Stop debounce patcher.""" - cls.patcher.stop() +def test_generate_aid(): + """Test generate aid method.""" + aid = generate_aid('demo.entity') + assert isinstance(aid, int) + assert aid >= 2 and aid <= 18446744073709551615 - def setUp(self): - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() + with patch(PATH_HOMEKIT + '.adler32') as mock_adler32: + mock_adler32.side_effect = [0, 1] + assert generate_aid('demo.entity') is None - def tearDown(self): - """Stop down everything that was started.""" - self.hass.stop() - def test_generate_aid(self): - """Test generate aid method.""" - aid = generate_aid('demo.entity') - self.assertIsInstance(aid, int) - self.assertTrue(aid >= 2 and aid <= 18446744073709551615) +async def test_setup_min(hass): + """Test async_setup with min config options.""" + with patch(PATH_HOMEKIT + '.HomeKit') as mock_homekit: + assert await setup.async_setup_component( + hass, DOMAIN, {DOMAIN: {}}) - with patch(PATH_HOMEKIT + '.adler32') as mock_adler32: - mock_adler32.side_effect = [0, 1] - self.assertIsNone(generate_aid('demo.entity')) + mock_homekit.assert_any_call(hass, BRIDGE_NAME, DEFAULT_PORT, None, ANY, + {}) + assert mock_homekit().setup.called is True - @patch(PATH_HOMEKIT + '.HomeKit') - def test_setup_min(self, mock_homekit): - """Test async_setup with min config options.""" - self.assertTrue(setup.setup_component( - self.hass, DOMAIN, {DOMAIN: {}})) + # Test auto start enabled + mock_homekit.reset_mock() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() - self.assertEqual(mock_homekit.mock_calls, [ - call(self.hass, DEFAULT_PORT, None, ANY, {}), - call().setup()]) + mock_homekit().start.assert_called_with(ANY) - # Test auto start enabled - mock_homekit.reset_mock() - self.hass.bus.fire(EVENT_HOMEASSISTANT_START) - self.hass.block_till_done() - self.assertEqual(mock_homekit.mock_calls, [call().start(ANY)]) +async def test_setup_auto_start_disabled(hass): + """Test async_setup with auto start disabled and test service calls.""" + config = {DOMAIN: {CONF_AUTO_START: False, CONF_NAME: 'Test Name', + CONF_PORT: 11111, CONF_IP_ADDRESS: '172.0.0.0'}} - @patch(PATH_HOMEKIT + '.HomeKit') - def test_setup_auto_start_disabled(self, mock_homekit): - """Test async_setup with auto start disabled and test service calls.""" + with patch(PATH_HOMEKIT + '.HomeKit') as mock_homekit: mock_homekit.return_value = homekit = Mock() + assert await setup.async_setup_component( + hass, DOMAIN, config) + + mock_homekit.assert_any_call(hass, 'Test Name', 11111, '172.0.0.0', ANY, + {}) + assert mock_homekit().setup.called is True + + # Test auto_start disabled + homekit.reset_mock() + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() + assert homekit.start.called is False + + # Test start call with driver is ready + homekit.reset_mock() + homekit.status = STATUS_READY + + await hass.services.async_call( + DOMAIN, SERVICE_HOMEKIT_START, blocking=True) + assert homekit.start.called is True + + # Test start call with driver started + homekit.reset_mock() + homekit.status = STATUS_STOPPED + + await hass.services.async_call( + DOMAIN, SERVICE_HOMEKIT_START, blocking=True) + assert homekit.start.called is False + + +async def test_homekit_setup(hass, hk_driver): + """Test setup of bridge and driver.""" + homekit = HomeKit(hass, BRIDGE_NAME, DEFAULT_PORT, None, {}, {}) + + with patch(PATH_HOMEKIT + '.accessories.HomeDriver', + return_value=hk_driver) as mock_driver, \ + patch('homeassistant.util.get_local_ip') as mock_ip: + mock_ip.return_value = IP_ADDRESS + await hass.async_add_job(homekit.setup) + + path = hass.config.path(HOMEKIT_FILE) + assert isinstance(homekit.bridge, HomeBridge) + mock_driver.assert_called_with( + hass, address=IP_ADDRESS, port=DEFAULT_PORT, persist_file=path) + + # Test if stop listener is setup + assert hass.bus.async_listeners().get(EVENT_HOMEASSISTANT_STOP) == 1 + + +async def test_homekit_setup_ip_address(hass, hk_driver): + """Test setup with given IP address.""" + homekit = HomeKit(hass, BRIDGE_NAME, DEFAULT_PORT, '172.0.0.0', {}, {}) + + with patch(PATH_HOMEKIT + '.accessories.HomeDriver', + return_value=hk_driver) as mock_driver: + await hass.async_add_job(homekit.setup) + mock_driver.assert_called_with( + hass, address='172.0.0.0', port=DEFAULT_PORT, persist_file=ANY) + + +async def test_homekit_add_accessory(): + """Add accessory if config exists and get_acc returns an accessory.""" + homekit = HomeKit('hass', None, None, None, lambda entity_id: True, {}) + homekit.driver = 'driver' + homekit.bridge = mock_bridge = Mock() + + with patch(PATH_HOMEKIT + '.get_accessory') as mock_get_acc: + + mock_get_acc.side_effect = [None, 'acc', None] + homekit.add_bridge_accessory(State('light.demo', 'on')) + mock_get_acc.assert_called_with('hass', 'driver', ANY, 363398124, {}) + assert not mock_bridge.add_accessory.called + + homekit.add_bridge_accessory(State('demo.test', 'on')) + mock_get_acc.assert_called_with('hass', 'driver', ANY, 294192020, {}) + assert mock_bridge.add_accessory.called + + homekit.add_bridge_accessory(State('demo.test_2', 'on')) + mock_get_acc.assert_called_with('hass', 'driver', ANY, 429982757, {}) + mock_bridge.add_accessory.assert_called_with('acc') + + +async def test_homekit_entity_filter(hass): + """Test the entity filter.""" + entity_filter = generate_filter(['cover'], ['demo.test'], [], []) + homekit = HomeKit(hass, None, None, None, entity_filter, {}) + + with patch(PATH_HOMEKIT + '.get_accessory') as mock_get_acc: + mock_get_acc.return_value = None + + homekit.add_bridge_accessory(State('cover.test', 'open')) + assert mock_get_acc.called is True + mock_get_acc.reset_mock() + + homekit.add_bridge_accessory(State('demo.test', 'on')) + assert mock_get_acc.called is True + mock_get_acc.reset_mock() + + homekit.add_bridge_accessory(State('light.demo', 'light')) + assert mock_get_acc.called is False + + +async def test_homekit_start(hass, hk_driver, debounce_patcher): + """Test HomeKit start method.""" + pin = b'123-45-678' + homekit = HomeKit(hass, None, None, None, {}, {'cover.demo': {}}) + homekit.bridge = 'bridge' + homekit.driver = hk_driver + + hass.states.async_set('light.demo', 'on') + state = hass.states.async_all()[0] + + with patch(PATH_HOMEKIT + '.HomeKit.add_bridge_accessory') as \ + mock_add_acc, \ + patch(PATH_HOMEKIT + '.show_setup_message') as mock_setup_msg, \ + patch('pyhap.accessory_driver.AccessoryDriver.add_accessory') as \ + hk_driver_add_acc, \ + patch('pyhap.accessory_driver.AccessoryDriver.start') as \ + hk_driver_start: + await hass.async_add_job(homekit.start) - config = {DOMAIN: {CONF_AUTO_START: False, CONF_PORT: 11111, - CONF_IP_ADDRESS: '172.0.0.0'}} - self.assertTrue(setup.setup_component( - self.hass, DOMAIN, config)) - self.hass.block_till_done() - - self.assertEqual(mock_homekit.mock_calls, [ - call(self.hass, 11111, '172.0.0.0', ANY, {}), - call().setup()]) - - # Test auto_start disabled - homekit.reset_mock() - self.hass.bus.fire(EVENT_HOMEASSISTANT_START) - self.hass.block_till_done() - self.assertEqual(homekit.mock_calls, []) - - # Test start call with driver is ready - homekit.reset_mock() - homekit.status = STATUS_READY - - self.hass.services.call('homekit', 'start') - self.assertEqual(homekit.mock_calls, [call.start()]) - - # Test start call with driver started - homekit.reset_mock() - homekit.status = STATUS_STOPPED - - self.hass.services.call(DOMAIN, SERVICE_HOMEKIT_START) - self.assertEqual(homekit.mock_calls, []) - - def test_homekit_setup(self): - """Test setup of bridge and driver.""" - homekit = HomeKit(self.hass, DEFAULT_PORT, None, {}, {}) - - with patch(PATH_HOMEKIT + '.accessories.HomeDriver') as mock_driver, \ - patch('homeassistant.util.get_local_ip') as mock_ip: - mock_ip.return_value = IP_ADDRESS - homekit.setup() - - path = self.hass.config.path(HOMEKIT_FILE) - self.assertTrue(isinstance(homekit.bridge, HomeBridge)) - self.assertEqual(mock_driver.mock_calls, [ - call(homekit.bridge, DEFAULT_PORT, IP_ADDRESS, path)]) - - # Test if stop listener is setup - self.assertEqual( - self.hass.bus.listeners.get(EVENT_HOMEASSISTANT_STOP), 1) - - def test_homekit_setup_ip_address(self): - """Test setup with given IP address.""" - homekit = HomeKit(self.hass, DEFAULT_PORT, '172.0.0.0', {}, {}) - - with patch(PATH_HOMEKIT + '.accessories.HomeDriver') as mock_driver: - homekit.setup() - mock_driver.assert_called_with(ANY, DEFAULT_PORT, '172.0.0.0', ANY) - - def test_homekit_add_accessory(self): - """Add accessory if config exists and get_acc returns an accessory.""" - homekit = HomeKit(self.hass, None, None, lambda entity_id: True, {}) - homekit.bridge = HomeBridge(self.hass) - - with patch(PATH_HOMEKIT + '.accessories.HomeBridge.add_accessory') \ - as mock_add_acc, \ - patch(PATH_HOMEKIT + '.get_accessory') as mock_get_acc: - mock_get_acc.side_effect = [None, 'acc', None] - homekit.add_bridge_accessory(State('light.demo', 'on')) - self.assertEqual(mock_get_acc.call_args, - call(self.hass, ANY, 363398124, {})) - self.assertFalse(mock_add_acc.called) - homekit.add_bridge_accessory(State('demo.test', 'on')) - self.assertEqual(mock_get_acc.call_args, - call(self.hass, ANY, 294192020, {})) - self.assertTrue(mock_add_acc.called) - homekit.add_bridge_accessory(State('demo.test_2', 'on')) - self.assertEqual(mock_get_acc.call_args, - call(self.hass, ANY, 429982757, {})) - self.assertEqual(mock_add_acc.mock_calls, [call('acc')]) - - def test_homekit_entity_filter(self): - """Test the entity filter.""" - entity_filter = generate_filter(['cover'], ['demo.test'], [], []) - homekit = HomeKit(self.hass, None, None, entity_filter, {}) - - with patch(PATH_HOMEKIT + '.get_accessory') as mock_get_acc: - mock_get_acc.return_value = None - - homekit.add_bridge_accessory(State('cover.test', 'open')) - self.assertTrue(mock_get_acc.called) - mock_get_acc.reset_mock() - - homekit.add_bridge_accessory(State('demo.test', 'on')) - self.assertTrue(mock_get_acc.called) - mock_get_acc.reset_mock() - - homekit.add_bridge_accessory(State('light.demo', 'light')) - self.assertFalse(mock_get_acc.called) - - @patch(PATH_HOMEKIT + '.show_setup_message') - @patch(PATH_HOMEKIT + '.HomeKit.add_bridge_accessory') - def test_homekit_start(self, mock_add_bridge_acc, mock_show_setup_msg): - """Test HomeKit start method.""" - homekit = HomeKit(self.hass, None, None, {}, {'cover.demo': {}}) - homekit.bridge = HomeBridge(self.hass) - homekit.driver = Mock() - - self.hass.states.set('light.demo', 'on') - state = self.hass.states.all()[0] - - homekit.start() - self.hass.block_till_done() - - self.assertEqual(mock_add_bridge_acc.mock_calls, [call(state)]) - self.assertEqual(mock_show_setup_msg.mock_calls, [ - call(self.hass, homekit.bridge)]) - self.assertEqual(homekit.driver.mock_calls, [call.start()]) - self.assertEqual(homekit.status, STATUS_RUNNING) - - # Test start() if already started - homekit.driver.reset_mock() - homekit.start() - self.hass.block_till_done() - self.assertEqual(homekit.driver.mock_calls, []) - - def test_homekit_stop(self): - """Test HomeKit stop method.""" - homekit = HomeKit(self.hass, None, None, None, None) - homekit.driver = Mock() - - self.assertEqual(homekit.status, STATUS_READY) - homekit.stop() - self.hass.block_till_done() - homekit.status = STATUS_WAIT - homekit.stop() - self.hass.block_till_done() - homekit.status = STATUS_STOPPED - homekit.stop() - self.hass.block_till_done() - self.assertFalse(homekit.driver.stop.called) - - # Test if driver is started - homekit.status = STATUS_RUNNING - homekit.stop() - self.hass.block_till_done() - self.assertTrue(homekit.driver.stop.called) + mock_add_acc.assert_called_with(state) + mock_setup_msg.assert_called_with(hass, pin) + hk_driver_add_acc.assert_called_with('bridge') + assert hk_driver_start.called + assert homekit.status == STATUS_RUNNING + + # Test start() if already started + hk_driver_start.reset_mock() + await hass.async_add_job(homekit.start) + assert not hk_driver_start.called + + +async def test_homekit_stop(hass): + """Test HomeKit stop method.""" + homekit = HomeKit(hass, None, None, None, None, None) + homekit.driver = Mock() + + assert homekit.status == STATUS_READY + await hass.async_add_job(homekit.stop) + homekit.status = STATUS_WAIT + await hass.async_add_job(homekit.stop) + homekit.status = STATUS_STOPPED + await hass.async_add_job(homekit.stop) + assert homekit.driver.stop.called is False + + # Test if driver is started + homekit.status = STATUS_RUNNING + await hass.async_add_job(homekit.stop) + assert homekit.driver.stop.called is True diff --git a/tests/components/homekit/test_type_covers.py b/tests/components/homekit/test_type_covers.py index 313d58e78fde94..04ed5df570225d 100644 --- a/tests/components/homekit/test_type_covers.py +++ b/tests/components/homekit/test_type_covers.py @@ -1,265 +1,235 @@ """Test different accessory types: Covers.""" -import unittest +from collections import namedtuple + +import pytest -from homeassistant.core import callback from homeassistant.components.cover import ( - ATTR_POSITION, ATTR_CURRENT_POSITION, SUPPORT_STOP) + ATTR_CURRENT_POSITION, ATTR_POSITION, DOMAIN, SUPPORT_STOP) from homeassistant.const import ( - STATE_CLOSED, STATE_UNAVAILABLE, STATE_UNKNOWN, STATE_OPEN, - ATTR_SERVICE, ATTR_SERVICE_DATA, EVENT_CALL_SERVICE, - ATTR_SUPPORTED_FEATURES) - -from tests.common import get_test_home_assistant -from tests.components.homekit.test_accessories import patch_debounce - - -class TestHomekitCovers(unittest.TestCase): - """Test class for all accessory types regarding covers.""" - - @classmethod - def setUpClass(cls): - """Setup Light class import and debounce patcher.""" - cls.patcher = patch_debounce() - cls.patcher.start() - _import = __import__('homeassistant.components.homekit.type_covers', - fromlist=['GarageDoorOpener', 'WindowCovering,', - 'WindowCoveringBasic']) - cls.garage_cls = _import.GarageDoorOpener - cls.window_cls = _import.WindowCovering - cls.window_basic_cls = _import.WindowCoveringBasic - - @classmethod - def tearDownClass(cls): - """Stop debounce patcher.""" - cls.patcher.stop() - - def setUp(self): - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.events = [] - - @callback - def record_event(event): - """Track called event.""" - self.events.append(event) - - self.hass.bus.listen(EVENT_CALL_SERVICE, record_event) - - def tearDown(self): - """Stop down everything that was started.""" - self.hass.stop() - - def test_garage_door_open_close(self): - """Test if accessory and HA are updated accordingly.""" - garage_door = 'cover.garage_door' - - acc = self.garage_cls(self.hass, 'Cover', garage_door, 2, config=None) - acc.run() - - self.assertEqual(acc.aid, 2) - self.assertEqual(acc.category, 4) # GarageDoorOpener - - self.assertEqual(acc.char_current_state.value, 0) - self.assertEqual(acc.char_target_state.value, 0) - - self.hass.states.set(garage_door, STATE_CLOSED) - self.hass.block_till_done() - - self.assertEqual(acc.char_current_state.value, 1) - self.assertEqual(acc.char_target_state.value, 1) - - self.hass.states.set(garage_door, STATE_OPEN) - self.hass.block_till_done() - - self.assertEqual(acc.char_current_state.value, 0) - self.assertEqual(acc.char_target_state.value, 0) - - self.hass.states.set(garage_door, STATE_UNAVAILABLE) - self.hass.block_till_done() - - self.assertEqual(acc.char_current_state.value, 0) - self.assertEqual(acc.char_target_state.value, 0) - - self.hass.states.set(garage_door, STATE_UNKNOWN) - self.hass.block_till_done() - - self.assertEqual(acc.char_current_state.value, 0) - self.assertEqual(acc.char_target_state.value, 0) - - # Set closed from HomeKit - acc.char_target_state.client_update_value(1) - self.hass.block_till_done() - - self.assertEqual(acc.char_current_state.value, 2) - self.assertEqual(acc.char_target_state.value, 1) - self.assertEqual( - self.events[0].data[ATTR_SERVICE], 'close_cover') - - self.hass.states.set(garage_door, STATE_CLOSED) - self.hass.block_till_done() - - # Set open from HomeKit - acc.char_target_state.client_update_value(0) - self.hass.block_till_done() - - self.assertEqual(acc.char_current_state.value, 3) - self.assertEqual(acc.char_target_state.value, 0) - self.assertEqual( - self.events[1].data[ATTR_SERVICE], 'open_cover') - - def test_window_set_cover_position(self): - """Test if accessory and HA are updated accordingly.""" - window_cover = 'cover.window' - - acc = self.window_cls(self.hass, 'Cover', window_cover, 2, config=None) - acc.run() - - self.assertEqual(acc.aid, 2) - self.assertEqual(acc.category, 14) # WindowCovering - - self.assertEqual(acc.char_current_position.value, 0) - self.assertEqual(acc.char_target_position.value, 0) - - self.hass.states.set(window_cover, STATE_UNKNOWN, - {ATTR_CURRENT_POSITION: None}) - self.hass.block_till_done() - - self.assertEqual(acc.char_current_position.value, 0) - self.assertEqual(acc.char_target_position.value, 0) - - self.hass.states.set(window_cover, STATE_OPEN, - {ATTR_CURRENT_POSITION: 50}) - self.hass.block_till_done() - - self.assertEqual(acc.char_current_position.value, 50) - self.assertEqual(acc.char_target_position.value, 50) - - # Set from HomeKit - acc.char_target_position.client_update_value(25) - self.hass.block_till_done() - self.assertEqual( - self.events[0].data[ATTR_SERVICE], 'set_cover_position') - self.assertEqual( - self.events[0].data[ATTR_SERVICE_DATA][ATTR_POSITION], 25) - - self.assertEqual(acc.char_current_position.value, 50) - self.assertEqual(acc.char_target_position.value, 25) - - # Set from HomeKit - acc.char_target_position.client_update_value(75) - self.hass.block_till_done() - self.assertEqual( - self.events[1].data[ATTR_SERVICE], 'set_cover_position') - self.assertEqual( - self.events[1].data[ATTR_SERVICE_DATA][ATTR_POSITION], 75) - - self.assertEqual(acc.char_current_position.value, 50) - self.assertEqual(acc.char_target_position.value, 75) - - def test_window_open_close(self): - """Test if accessory and HA are updated accordingly.""" - window_cover = 'cover.window' - - self.hass.states.set(window_cover, STATE_UNKNOWN, - {ATTR_SUPPORTED_FEATURES: 0}) - acc = self.window_basic_cls(self.hass, 'Cover', window_cover, 2, - config=None) - acc.run() - - self.assertEqual(acc.aid, 2) - self.assertEqual(acc.category, 14) # WindowCovering - - self.assertEqual(acc.char_current_position.value, 0) - self.assertEqual(acc.char_target_position.value, 0) - self.assertEqual(acc.char_position_state.value, 2) - - self.hass.states.set(window_cover, STATE_UNKNOWN) - self.hass.block_till_done() - - self.assertEqual(acc.char_current_position.value, 0) - self.assertEqual(acc.char_target_position.value, 0) - self.assertEqual(acc.char_position_state.value, 2) - - self.hass.states.set(window_cover, STATE_OPEN) - self.hass.block_till_done() - - self.assertEqual(acc.char_current_position.value, 100) - self.assertEqual(acc.char_target_position.value, 100) - self.assertEqual(acc.char_position_state.value, 2) - - self.hass.states.set(window_cover, STATE_CLOSED) - self.hass.block_till_done() - - self.assertEqual(acc.char_current_position.value, 0) - self.assertEqual(acc.char_target_position.value, 0) - self.assertEqual(acc.char_position_state.value, 2) - - # Set from HomeKit - acc.char_target_position.client_update_value(25) - self.hass.block_till_done() - self.assertEqual( - self.events[0].data[ATTR_SERVICE], 'close_cover') - - self.assertEqual(acc.char_current_position.value, 0) - self.assertEqual(acc.char_target_position.value, 0) - self.assertEqual(acc.char_position_state.value, 2) - - # Set from HomeKit - acc.char_target_position.client_update_value(90) - self.hass.block_till_done() - self.assertEqual( - self.events[1].data[ATTR_SERVICE], 'open_cover') - - self.assertEqual(acc.char_current_position.value, 100) - self.assertEqual(acc.char_target_position.value, 100) - self.assertEqual(acc.char_position_state.value, 2) - - # Set from HomeKit - acc.char_target_position.client_update_value(55) - self.hass.block_till_done() - self.assertEqual( - self.events[2].data[ATTR_SERVICE], 'open_cover') - - self.assertEqual(acc.char_current_position.value, 100) - self.assertEqual(acc.char_target_position.value, 100) - self.assertEqual(acc.char_position_state.value, 2) - - def test_window_open_close_stop(self): - """Test if accessory and HA are updated accordingly.""" - window_cover = 'cover.window' - - self.hass.states.set(window_cover, STATE_UNKNOWN, - {ATTR_SUPPORTED_FEATURES: SUPPORT_STOP}) - acc = self.window_basic_cls(self.hass, 'Cover', window_cover, 2, - config=None) - acc.run() - - # Set from HomeKit - acc.char_target_position.client_update_value(25) - self.hass.block_till_done() - self.assertEqual( - self.events[0].data[ATTR_SERVICE], 'close_cover') - - self.assertEqual(acc.char_current_position.value, 0) - self.assertEqual(acc.char_target_position.value, 0) - self.assertEqual(acc.char_position_state.value, 2) - - # Set from HomeKit - acc.char_target_position.client_update_value(90) - self.hass.block_till_done() - self.assertEqual( - self.events[1].data[ATTR_SERVICE], 'open_cover') - - self.assertEqual(acc.char_current_position.value, 100) - self.assertEqual(acc.char_target_position.value, 100) - self.assertEqual(acc.char_position_state.value, 2) - - # Set from HomeKit - acc.char_target_position.client_update_value(55) - self.hass.block_till_done() - self.assertEqual( - self.events[2].data[ATTR_SERVICE], 'stop_cover') - - self.assertEqual(acc.char_current_position.value, 50) - self.assertEqual(acc.char_target_position.value, 50) - self.assertEqual(acc.char_position_state.value, 2) + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, + STATE_CLOSED, STATE_OPEN, STATE_UNAVAILABLE, STATE_UNKNOWN) + +from tests.common import async_mock_service +from tests.components.homekit.common import patch_debounce + + +@pytest.fixture(scope='module') +def cls(): + """Patch debounce decorator during import of type_covers.""" + patcher = patch_debounce() + patcher.start() + _import = __import__('homeassistant.components.homekit.type_covers', + fromlist=['GarageDoorOpener', 'WindowCovering', + 'WindowCoveringBasic']) + patcher_tuple = namedtuple('Cls', ['window', 'window_basic', 'garage']) + yield patcher_tuple(window=_import.WindowCovering, + window_basic=_import.WindowCoveringBasic, + garage=_import.GarageDoorOpener) + patcher.stop() + + +async def test_garage_door_open_close(hass, hk_driver, cls): + """Test if accessory and HA are updated accordingly.""" + entity_id = 'cover.garage_door' + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = cls.garage(hass, hk_driver, 'Garage Door', entity_id, 2, None) + await hass.async_add_job(acc.run) + + assert acc.aid == 2 + assert acc.category == 4 # GarageDoorOpener + + assert acc.char_current_state.value == 0 + assert acc.char_target_state.value == 0 + + hass.states.async_set(entity_id, STATE_CLOSED) + await hass.async_block_till_done() + assert acc.char_current_state.value == 1 + assert acc.char_target_state.value == 1 + + hass.states.async_set(entity_id, STATE_OPEN) + await hass.async_block_till_done() + assert acc.char_current_state.value == 0 + assert acc.char_target_state.value == 0 + + hass.states.async_set(entity_id, STATE_UNAVAILABLE) + await hass.async_block_till_done() + assert acc.char_current_state.value == 0 + assert acc.char_target_state.value == 0 + + hass.states.async_set(entity_id, STATE_UNKNOWN) + await hass.async_block_till_done() + assert acc.char_current_state.value == 0 + assert acc.char_target_state.value == 0 + + # Set from HomeKit + call_close_cover = async_mock_service(hass, DOMAIN, 'close_cover') + call_open_cover = async_mock_service(hass, DOMAIN, 'open_cover') + + await hass.async_add_job(acc.char_target_state.client_update_value, 1) + await hass.async_block_till_done() + assert call_close_cover + assert call_close_cover[0].data[ATTR_ENTITY_ID] == entity_id + assert acc.char_current_state.value == 2 + assert acc.char_target_state.value == 1 + + hass.states.async_set(entity_id, STATE_CLOSED) + await hass.async_block_till_done() + + await hass.async_add_job(acc.char_target_state.client_update_value, 0) + await hass.async_block_till_done() + assert call_open_cover + assert call_open_cover[0].data[ATTR_ENTITY_ID] == entity_id + assert acc.char_current_state.value == 3 + assert acc.char_target_state.value == 0 + + +async def test_window_set_cover_position(hass, hk_driver, cls): + """Test if accessory and HA are updated accordingly.""" + entity_id = 'cover.window' + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = cls.window(hass, hk_driver, 'Cover', entity_id, 2, None) + await hass.async_add_job(acc.run) + + assert acc.aid == 2 + assert acc.category == 14 # WindowCovering + + assert acc.char_current_position.value == 0 + assert acc.char_target_position.value == 0 + + hass.states.async_set(entity_id, STATE_UNKNOWN, + {ATTR_CURRENT_POSITION: None}) + await hass.async_block_till_done() + assert acc.char_current_position.value == 0 + assert acc.char_target_position.value == 0 + + hass.states.async_set(entity_id, STATE_OPEN, + {ATTR_CURRENT_POSITION: 50}) + await hass.async_block_till_done() + assert acc.char_current_position.value == 50 + assert acc.char_target_position.value == 50 + + # Set from HomeKit + call_set_cover_position = async_mock_service(hass, DOMAIN, + 'set_cover_position') + + await hass.async_add_job(acc.char_target_position.client_update_value, 25) + await hass.async_block_till_done() + assert call_set_cover_position[0] + assert call_set_cover_position[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_cover_position[0].data[ATTR_POSITION] == 25 + assert acc.char_current_position.value == 50 + assert acc.char_target_position.value == 25 + + await hass.async_add_job(acc.char_target_position.client_update_value, 75) + await hass.async_block_till_done() + assert call_set_cover_position[1] + assert call_set_cover_position[1].data[ATTR_ENTITY_ID] == entity_id + assert call_set_cover_position[1].data[ATTR_POSITION] == 75 + assert acc.char_current_position.value == 50 + assert acc.char_target_position.value == 75 + + +async def test_window_open_close(hass, hk_driver, cls): + """Test if accessory and HA are updated accordingly.""" + entity_id = 'cover.window' + + hass.states.async_set(entity_id, STATE_UNKNOWN, + {ATTR_SUPPORTED_FEATURES: 0}) + acc = cls.window_basic(hass, hk_driver, 'Cover', entity_id, 2, None) + await hass.async_add_job(acc.run) + + assert acc.aid == 2 + assert acc.category == 14 # WindowCovering + + assert acc.char_current_position.value == 0 + assert acc.char_target_position.value == 0 + assert acc.char_position_state.value == 2 + + hass.states.async_set(entity_id, STATE_UNKNOWN) + await hass.async_block_till_done() + assert acc.char_current_position.value == 0 + assert acc.char_target_position.value == 0 + assert acc.char_position_state.value == 2 + + hass.states.async_set(entity_id, STATE_OPEN) + await hass.async_block_till_done() + assert acc.char_current_position.value == 100 + assert acc.char_target_position.value == 100 + assert acc.char_position_state.value == 2 + + hass.states.async_set(entity_id, STATE_CLOSED) + await hass.async_block_till_done() + assert acc.char_current_position.value == 0 + assert acc.char_target_position.value == 0 + assert acc.char_position_state.value == 2 + + # Set from HomeKit + call_close_cover = async_mock_service(hass, DOMAIN, 'close_cover') + call_open_cover = async_mock_service(hass, DOMAIN, 'open_cover') + + await hass.async_add_job(acc.char_target_position.client_update_value, 25) + await hass.async_block_till_done() + assert call_close_cover + assert call_close_cover[0].data[ATTR_ENTITY_ID] == entity_id + assert acc.char_current_position.value == 0 + assert acc.char_target_position.value == 0 + assert acc.char_position_state.value == 2 + + await hass.async_add_job(acc.char_target_position.client_update_value, 90) + await hass.async_block_till_done() + assert call_open_cover[0] + assert call_open_cover[0].data[ATTR_ENTITY_ID] == entity_id + assert acc.char_current_position.value == 100 + assert acc.char_target_position.value == 100 + assert acc.char_position_state.value == 2 + + await hass.async_add_job(acc.char_target_position.client_update_value, 55) + await hass.async_block_till_done() + assert call_open_cover[1] + assert call_open_cover[1].data[ATTR_ENTITY_ID] == entity_id + assert acc.char_current_position.value == 100 + assert acc.char_target_position.value == 100 + assert acc.char_position_state.value == 2 + + +async def test_window_open_close_stop(hass, hk_driver, cls): + """Test if accessory and HA are updated accordingly.""" + entity_id = 'cover.window' + + hass.states.async_set(entity_id, STATE_UNKNOWN, + {ATTR_SUPPORTED_FEATURES: SUPPORT_STOP}) + acc = cls.window_basic(hass, hk_driver, 'Cover', entity_id, 2, None) + await hass.async_add_job(acc.run) + + # Set from HomeKit + call_close_cover = async_mock_service(hass, DOMAIN, 'close_cover') + call_open_cover = async_mock_service(hass, DOMAIN, 'open_cover') + call_stop_cover = async_mock_service(hass, DOMAIN, 'stop_cover') + + await hass.async_add_job(acc.char_target_position.client_update_value, 25) + await hass.async_block_till_done() + assert call_close_cover + assert call_close_cover[0].data[ATTR_ENTITY_ID] == entity_id + assert acc.char_current_position.value == 0 + assert acc.char_target_position.value == 0 + assert acc.char_position_state.value == 2 + + await hass.async_add_job(acc.char_target_position.client_update_value, 90) + await hass.async_block_till_done() + assert call_open_cover + assert call_open_cover[0].data[ATTR_ENTITY_ID] == entity_id + assert acc.char_current_position.value == 100 + assert acc.char_target_position.value == 100 + assert acc.char_position_state.value == 2 + + await hass.async_add_job(acc.char_target_position.client_update_value, 55) + await hass.async_block_till_done() + assert call_stop_cover + assert call_stop_cover[0].data[ATTR_ENTITY_ID] == entity_id + assert acc.char_current_position.value == 50 + assert acc.char_target_position.value == 50 + assert acc.char_position_state.value == 2 diff --git a/tests/components/homekit/test_type_fans.py b/tests/components/homekit/test_type_fans.py new file mode 100644 index 00000000000000..87a481ff06fd12 --- /dev/null +++ b/tests/components/homekit/test_type_fans.py @@ -0,0 +1,144 @@ +"""Test different accessory types: Fans.""" +from collections import namedtuple + +import pytest + +from homeassistant.components.fan import ( + ATTR_DIRECTION, ATTR_OSCILLATING, DIRECTION_FORWARD, DIRECTION_REVERSE, + DOMAIN, SUPPORT_DIRECTION, SUPPORT_OSCILLATE) +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, STATE_ON, STATE_OFF, + STATE_UNKNOWN) + +from tests.common import async_mock_service +from tests.components.homekit.common import patch_debounce + + +@pytest.fixture(scope='module') +def cls(): + """Patch debounce decorator during import of type_fans.""" + patcher = patch_debounce() + patcher.start() + _import = __import__('homeassistant.components.homekit.type_fans', + fromlist=['Fan']) + patcher_tuple = namedtuple('Cls', ['fan']) + yield patcher_tuple(fan=_import.Fan) + patcher.stop() + + +async def test_fan_basic(hass, hk_driver, cls): + """Test fan with char state.""" + entity_id = 'fan.demo' + + hass.states.async_set(entity_id, STATE_ON, {ATTR_SUPPORTED_FEATURES: 0}) + await hass.async_block_till_done() + acc = cls.fan(hass, hk_driver, 'Fan', entity_id, 2, None) + + assert acc.aid == 2 + assert acc.category == 3 # Fan + assert acc.char_active.value == 0 + + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + assert acc.char_active.value == 1 + + hass.states.async_set(entity_id, STATE_OFF, {ATTR_SUPPORTED_FEATURES: 0}) + await hass.async_block_till_done() + assert acc.char_active.value == 0 + + hass.states.async_set(entity_id, STATE_UNKNOWN) + await hass.async_block_till_done() + assert acc.char_active.value == 0 + + hass.states.async_remove(entity_id) + await hass.async_block_till_done() + assert acc.char_active.value == 0 + + # Set from HomeKit + call_turn_on = async_mock_service(hass, DOMAIN, 'turn_on') + call_turn_off = async_mock_service(hass, DOMAIN, 'turn_off') + + await hass.async_add_job(acc.char_active.client_update_value, 1) + await hass.async_block_till_done() + assert call_turn_on + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + + hass.states.async_set(entity_id, STATE_ON) + await hass.async_block_till_done() + + await hass.async_add_job(acc.char_active.client_update_value, 0) + await hass.async_block_till_done() + assert call_turn_off + assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + + +async def test_fan_direction(hass, hk_driver, cls): + """Test fan with direction.""" + entity_id = 'fan.demo' + + hass.states.async_set(entity_id, STATE_ON, { + ATTR_SUPPORTED_FEATURES: SUPPORT_DIRECTION, + ATTR_DIRECTION: DIRECTION_FORWARD}) + await hass.async_block_till_done() + acc = cls.fan(hass, hk_driver, 'Fan', entity_id, 2, None) + + assert acc.char_direction.value == 0 + + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + assert acc.char_direction.value == 0 + + hass.states.async_set(entity_id, STATE_ON, + {ATTR_DIRECTION: DIRECTION_REVERSE}) + await hass.async_block_till_done() + assert acc.char_direction.value == 1 + + # Set from HomeKit + call_set_direction = async_mock_service(hass, DOMAIN, 'set_direction') + + await hass.async_add_job(acc.char_direction.client_update_value, 0) + await hass.async_block_till_done() + assert call_set_direction[0] + assert call_set_direction[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_direction[0].data[ATTR_DIRECTION] == DIRECTION_FORWARD + + await hass.async_add_job(acc.char_direction.client_update_value, 1) + await hass.async_block_till_done() + assert call_set_direction[1] + assert call_set_direction[1].data[ATTR_ENTITY_ID] == entity_id + assert call_set_direction[1].data[ATTR_DIRECTION] == DIRECTION_REVERSE + + +async def test_fan_oscillate(hass, hk_driver, cls): + """Test fan with oscillate.""" + entity_id = 'fan.demo' + + hass.states.async_set(entity_id, STATE_ON, { + ATTR_SUPPORTED_FEATURES: SUPPORT_OSCILLATE, ATTR_OSCILLATING: False}) + await hass.async_block_till_done() + acc = cls.fan(hass, hk_driver, 'Fan', entity_id, 2, None) + + assert acc.char_swing.value == 0 + + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + assert acc.char_swing.value == 0 + + hass.states.async_set(entity_id, STATE_ON, {ATTR_OSCILLATING: True}) + await hass.async_block_till_done() + assert acc.char_swing.value == 1 + + # Set from HomeKit + call_oscillate = async_mock_service(hass, DOMAIN, 'oscillate') + + await hass.async_add_job(acc.char_swing.client_update_value, 0) + await hass.async_block_till_done() + assert call_oscillate[0] + assert call_oscillate[0].data[ATTR_ENTITY_ID] == entity_id + assert call_oscillate[0].data[ATTR_OSCILLATING] is False + + await hass.async_add_job(acc.char_swing.client_update_value, 1) + await hass.async_block_till_done() + assert call_oscillate[1] + assert call_oscillate[1].data[ATTR_ENTITY_ID] == entity_id + assert call_oscillate[1].data[ATTR_OSCILLATING] is True diff --git a/tests/components/homekit/test_type_lights.py b/tests/components/homekit/test_type_lights.py index 10bf469c08df8f..aab6274f484f42 100644 --- a/tests/components/homekit/test_type_lights.py +++ b/tests/components/homekit/test_type_lights.py @@ -1,188 +1,172 @@ """Test different accessory types: Lights.""" -import unittest +from collections import namedtuple + +import pytest -from homeassistant.core import callback from homeassistant.components.light import ( - DOMAIN, ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP, - ATTR_HS_COLOR, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_COLOR) + ATTR_BRIGHTNESS, ATTR_BRIGHTNESS_PCT, ATTR_COLOR_TEMP, ATTR_HS_COLOR, + DOMAIN, SUPPORT_BRIGHTNESS, SUPPORT_COLOR_TEMP, SUPPORT_COLOR) from homeassistant.const import ( - ATTR_DOMAIN, ATTR_ENTITY_ID, ATTR_SERVICE, ATTR_SERVICE_DATA, - ATTR_SUPPORTED_FEATURES, EVENT_CALL_SERVICE, SERVICE_TURN_ON, - SERVICE_TURN_OFF, STATE_ON, STATE_OFF, STATE_UNKNOWN) - -from tests.common import get_test_home_assistant -from tests.components.homekit.test_accessories import patch_debounce - - -class TestHomekitLights(unittest.TestCase): - """Test class for all accessory types regarding lights.""" - - @classmethod - def setUpClass(cls): - """Setup Light class import and debounce patcher.""" - cls.patcher = patch_debounce() - cls.patcher.start() - _import = __import__('homeassistant.components.homekit.type_lights', - fromlist=['Light']) - cls.light_cls = _import.Light - - @classmethod - def tearDownClass(cls): - """Stop debounce patcher.""" - cls.patcher.stop() - - def setUp(self): - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.events = [] - - @callback - def record_event(event): - """Track called event.""" - self.events.append(event) - - self.hass.bus.listen(EVENT_CALL_SERVICE, record_event) - - def tearDown(self): - """Stop down everything that was started.""" - self.hass.stop() - - def test_light_basic(self): - """Test light with char state.""" - entity_id = 'light.demo' - - self.hass.states.set(entity_id, STATE_ON, - {ATTR_SUPPORTED_FEATURES: 0}) - self.hass.block_till_done() - acc = self.light_cls(self.hass, 'Light', entity_id, 2, config=None) - self.assertEqual(acc.aid, 2) - self.assertEqual(acc.category, 5) # Lightbulb - self.assertEqual(acc.char_on.value, 0) - - acc.run() - self.hass.block_till_done() - self.assertEqual(acc.char_on.value, 1) - - self.hass.states.set(entity_id, STATE_OFF, - {ATTR_SUPPORTED_FEATURES: 0}) - self.hass.block_till_done() - self.assertEqual(acc.char_on.value, 0) - - self.hass.states.set(entity_id, STATE_UNKNOWN) - self.hass.block_till_done() - self.assertEqual(acc.char_on.value, 0) - - # Set from HomeKit - acc.char_on.client_update_value(1) - self.hass.block_till_done() - self.assertEqual(self.events[0].data[ATTR_DOMAIN], DOMAIN) - self.assertEqual(self.events[0].data[ATTR_SERVICE], SERVICE_TURN_ON) - - self.hass.states.set(entity_id, STATE_ON) - self.hass.block_till_done() - - acc.char_on.client_update_value(0) - self.hass.block_till_done() - self.assertEqual(self.events[1].data[ATTR_DOMAIN], DOMAIN) - self.assertEqual(self.events[1].data[ATTR_SERVICE], SERVICE_TURN_OFF) - - self.hass.states.set(entity_id, STATE_OFF) - self.hass.block_till_done() - - # Remove entity - self.hass.states.remove(entity_id) - self.hass.block_till_done() - - def test_light_brightness(self): - """Test light with brightness.""" - entity_id = 'light.demo' - - self.hass.states.set(entity_id, STATE_ON, { - ATTR_SUPPORTED_FEATURES: SUPPORT_BRIGHTNESS, ATTR_BRIGHTNESS: 255}) - self.hass.block_till_done() - acc = self.light_cls(self.hass, 'Light', entity_id, 2, config=None) - self.assertEqual(acc.char_brightness.value, 0) - - acc.run() - self.hass.block_till_done() - self.assertEqual(acc.char_brightness.value, 100) - - self.hass.states.set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 102}) - self.hass.block_till_done() - self.assertEqual(acc.char_brightness.value, 40) - - # Set from HomeKit - acc.char_brightness.client_update_value(20) - acc.char_on.client_update_value(1) - self.hass.block_till_done() - self.assertEqual(self.events[0].data[ATTR_DOMAIN], DOMAIN) - self.assertEqual(self.events[0].data[ATTR_SERVICE], SERVICE_TURN_ON) - self.assertEqual( - self.events[0].data[ATTR_SERVICE_DATA], { - ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS_PCT: 20}) - - acc.char_on.client_update_value(1) - acc.char_brightness.client_update_value(40) - self.hass.block_till_done() - self.assertEqual(self.events[1].data[ATTR_DOMAIN], DOMAIN) - self.assertEqual(self.events[1].data[ATTR_SERVICE], SERVICE_TURN_ON) - self.assertEqual( - self.events[1].data[ATTR_SERVICE_DATA], { - ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS_PCT: 40}) - - acc.char_on.client_update_value(1) - acc.char_brightness.client_update_value(0) - self.hass.block_till_done() - self.assertEqual(self.events[2].data[ATTR_DOMAIN], DOMAIN) - self.assertEqual(self.events[2].data[ATTR_SERVICE], SERVICE_TURN_OFF) - - def test_light_color_temperature(self): - """Test light with color temperature.""" - entity_id = 'light.demo' - - self.hass.states.set(entity_id, STATE_ON, { - ATTR_SUPPORTED_FEATURES: SUPPORT_COLOR_TEMP, - ATTR_COLOR_TEMP: 190}) - self.hass.block_till_done() - acc = self.light_cls(self.hass, 'Light', entity_id, 2, config=None) - self.assertEqual(acc.char_color_temperature.value, 153) - - acc.run() - self.hass.block_till_done() - self.assertEqual(acc.char_color_temperature.value, 190) - - # Set from HomeKit - acc.char_color_temperature.client_update_value(250) - self.hass.block_till_done() - self.assertEqual(self.events[0].data[ATTR_DOMAIN], DOMAIN) - self.assertEqual(self.events[0].data[ATTR_SERVICE], SERVICE_TURN_ON) - self.assertEqual( - self.events[0].data[ATTR_SERVICE_DATA], { - ATTR_ENTITY_ID: entity_id, ATTR_COLOR_TEMP: 250}) - - def test_light_rgb_color(self): - """Test light with rgb_color.""" - entity_id = 'light.demo' - - self.hass.states.set(entity_id, STATE_ON, { - ATTR_SUPPORTED_FEATURES: SUPPORT_COLOR, - ATTR_HS_COLOR: (260, 90)}) - self.hass.block_till_done() - acc = self.light_cls(self.hass, 'Light', entity_id, 2, config=None) - self.assertEqual(acc.char_hue.value, 0) - self.assertEqual(acc.char_saturation.value, 75) - - acc.run() - self.hass.block_till_done() - self.assertEqual(acc.char_hue.value, 260) - self.assertEqual(acc.char_saturation.value, 90) - - # Set from HomeKit - acc.char_hue.client_update_value(145) - acc.char_saturation.client_update_value(75) - self.hass.block_till_done() - self.assertEqual(self.events[0].data[ATTR_DOMAIN], DOMAIN) - self.assertEqual(self.events[0].data[ATTR_SERVICE], SERVICE_TURN_ON) - self.assertEqual( - self.events[0].data[ATTR_SERVICE_DATA], { - ATTR_ENTITY_ID: entity_id, ATTR_HS_COLOR: (145, 75)}) + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, + STATE_ON, STATE_OFF, STATE_UNKNOWN) + +from tests.common import async_mock_service +from tests.components.homekit.common import patch_debounce + + +@pytest.fixture(scope='module') +def cls(): + """Patch debounce decorator during import of type_lights.""" + patcher = patch_debounce() + patcher.start() + _import = __import__('homeassistant.components.homekit.type_lights', + fromlist=['Light']) + patcher_tuple = namedtuple('Cls', ['light']) + yield patcher_tuple(light=_import.Light) + patcher.stop() + + +async def test_light_basic(hass, hk_driver, cls): + """Test light with char state.""" + entity_id = 'light.demo' + + hass.states.async_set(entity_id, STATE_ON, {ATTR_SUPPORTED_FEATURES: 0}) + await hass.async_block_till_done() + acc = cls.light(hass, hk_driver, 'Light', entity_id, 2, None) + + assert acc.aid == 2 + assert acc.category == 5 # Lightbulb + assert acc.char_on.value == 0 + + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + assert acc.char_on.value == 1 + + hass.states.async_set(entity_id, STATE_OFF, {ATTR_SUPPORTED_FEATURES: 0}) + await hass.async_block_till_done() + assert acc.char_on.value == 0 + + hass.states.async_set(entity_id, STATE_UNKNOWN) + await hass.async_block_till_done() + assert acc.char_on.value == 0 + + hass.states.async_remove(entity_id) + await hass.async_block_till_done() + assert acc.char_on.value == 0 + + # Set from HomeKit + call_turn_on = async_mock_service(hass, DOMAIN, 'turn_on') + call_turn_off = async_mock_service(hass, DOMAIN, 'turn_off') + + await hass.async_add_job(acc.char_on.client_update_value, 1) + await hass.async_block_till_done() + assert call_turn_on + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + + hass.states.async_set(entity_id, STATE_ON) + await hass.async_block_till_done() + + await hass.async_add_job(acc.char_on.client_update_value, 0) + await hass.async_block_till_done() + assert call_turn_off + assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + + +async def test_light_brightness(hass, hk_driver, cls): + """Test light with brightness.""" + entity_id = 'light.demo' + + hass.states.async_set(entity_id, STATE_ON, { + ATTR_SUPPORTED_FEATURES: SUPPORT_BRIGHTNESS, ATTR_BRIGHTNESS: 255}) + await hass.async_block_till_done() + acc = cls.light(hass, hk_driver, 'Light', entity_id, 2, None) + + assert acc.char_brightness.value == 0 + + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + assert acc.char_brightness.value == 100 + + hass.states.async_set(entity_id, STATE_ON, {ATTR_BRIGHTNESS: 102}) + await hass.async_block_till_done() + assert acc.char_brightness.value == 40 + + # Set from HomeKit + call_turn_on = async_mock_service(hass, DOMAIN, 'turn_on') + call_turn_off = async_mock_service(hass, DOMAIN, 'turn_off') + + await hass.async_add_job(acc.char_brightness.client_update_value, 20) + await hass.async_add_job(acc.char_on.client_update_value, 1) + await hass.async_block_till_done() + assert call_turn_on[0] + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert call_turn_on[0].data[ATTR_BRIGHTNESS_PCT] == 20 + + await hass.async_add_job(acc.char_on.client_update_value, 1) + await hass.async_add_job(acc.char_brightness.client_update_value, 40) + await hass.async_block_till_done() + assert call_turn_on[1] + assert call_turn_on[1].data[ATTR_ENTITY_ID] == entity_id + assert call_turn_on[1].data[ATTR_BRIGHTNESS_PCT] == 40 + + await hass.async_add_job(acc.char_on.client_update_value, 1) + await hass.async_add_job(acc.char_brightness.client_update_value, 0) + await hass.async_block_till_done() + assert call_turn_off + assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + + +async def test_light_color_temperature(hass, hk_driver, cls): + """Test light with color temperature.""" + entity_id = 'light.demo' + + hass.states.async_set(entity_id, STATE_ON, { + ATTR_SUPPORTED_FEATURES: SUPPORT_COLOR_TEMP, + ATTR_COLOR_TEMP: 190}) + await hass.async_block_till_done() + acc = cls.light(hass, hk_driver, 'Light', entity_id, 2, None) + + assert acc.char_color_temperature.value == 153 + + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + assert acc.char_color_temperature.value == 190 + + # Set from HomeKit + call_turn_on = async_mock_service(hass, DOMAIN, 'turn_on') + + await hass.async_add_job( + acc.char_color_temperature.client_update_value, 250) + await hass.async_block_till_done() + assert call_turn_on + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert call_turn_on[0].data[ATTR_COLOR_TEMP] == 250 + + +async def test_light_rgb_color(hass, hk_driver, cls): + """Test light with rgb_color.""" + entity_id = 'light.demo' + + hass.states.async_set(entity_id, STATE_ON, { + ATTR_SUPPORTED_FEATURES: SUPPORT_COLOR, + ATTR_HS_COLOR: (260, 90)}) + await hass.async_block_till_done() + acc = cls.light(hass, hk_driver, 'Light', entity_id, 2, None) + + assert acc.char_hue.value == 0 + assert acc.char_saturation.value == 75 + + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + assert acc.char_hue.value == 260 + assert acc.char_saturation.value == 90 + + # Set from HomeKit + call_turn_on = async_mock_service(hass, DOMAIN, 'turn_on') + + await hass.async_add_job(acc.char_hue.client_update_value, 145) + await hass.async_add_job(acc.char_saturation.client_update_value, 75) + await hass.async_block_till_done() + assert call_turn_on + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert call_turn_on[0].data[ATTR_HS_COLOR] == (145, 75) diff --git a/tests/components/homekit/test_type_locks.py b/tests/components/homekit/test_type_locks.py index b205311606011a..8f18a591019389 100644 --- a/tests/components/homekit/test_type_locks.py +++ b/tests/components/homekit/test_type_locks.py @@ -1,77 +1,85 @@ """Test different accessory types: Locks.""" -import unittest +import pytest -from homeassistant.core import callback from homeassistant.components.homekit.type_locks import Lock +from homeassistant.components.lock import DOMAIN from homeassistant.const import ( - STATE_UNKNOWN, STATE_UNLOCKED, STATE_LOCKED, - ATTR_SERVICE, EVENT_CALL_SERVICE) - -from tests.common import get_test_home_assistant - - -class TestHomekitSensors(unittest.TestCase): - """Test class for all accessory types regarding covers.""" - - def setUp(self): - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.events = [] - - @callback - def record_event(event): - """Track called event.""" - self.events.append(event) - - self.hass.bus.listen(EVENT_CALL_SERVICE, record_event) - - def tearDown(self): - """Stop down everything that was started.""" - self.hass.stop() - - def test_lock_unlock(self): - """Test if accessory and HA are updated accordingly.""" - kitchen_lock = 'lock.kitchen_door' - - acc = Lock(self.hass, 'Lock', kitchen_lock, 2, config=None) - acc.run() - - self.assertEqual(acc.aid, 2) - self.assertEqual(acc.category, 6) # DoorLock - - self.assertEqual(acc.char_current_state.value, 3) - self.assertEqual(acc.char_target_state.value, 1) - - self.hass.states.set(kitchen_lock, STATE_LOCKED) - self.hass.block_till_done() - - self.assertEqual(acc.char_current_state.value, 1) - self.assertEqual(acc.char_target_state.value, 1) - - self.hass.states.set(kitchen_lock, STATE_UNLOCKED) - self.hass.block_till_done() - - self.assertEqual(acc.char_current_state.value, 0) - self.assertEqual(acc.char_target_state.value, 0) - - self.hass.states.set(kitchen_lock, STATE_UNKNOWN) - self.hass.block_till_done() - - self.assertEqual(acc.char_current_state.value, 3) - self.assertEqual(acc.char_target_state.value, 0) - - # Set from HomeKit - acc.char_target_state.client_update_value(1) - self.hass.block_till_done() - self.assertEqual( - self.events[0].data[ATTR_SERVICE], 'lock') - self.assertEqual(acc.char_target_state.value, 1) - - acc.char_target_state.client_update_value(0) - self.hass.block_till_done() - self.assertEqual( - self.events[1].data[ATTR_SERVICE], 'unlock') - self.assertEqual(acc.char_target_state.value, 0) - - self.hass.states.remove(kitchen_lock) - self.hass.block_till_done() + ATTR_CODE, ATTR_ENTITY_ID, STATE_LOCKED, STATE_UNKNOWN, STATE_UNLOCKED) + +from tests.common import async_mock_service + + +async def test_lock_unlock(hass, hk_driver): + """Test if accessory and HA are updated accordingly.""" + code = '1234' + config = {ATTR_CODE: code} + entity_id = 'lock.kitchen_door' + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = Lock(hass, hk_driver, 'Lock', entity_id, 2, config) + await hass.async_add_job(acc.run) + + assert acc.aid == 2 + assert acc.category == 6 # DoorLock + + assert acc.char_current_state.value == 3 + assert acc.char_target_state.value == 1 + + hass.states.async_set(entity_id, STATE_LOCKED) + await hass.async_block_till_done() + assert acc.char_current_state.value == 1 + assert acc.char_target_state.value == 1 + + hass.states.async_set(entity_id, STATE_UNLOCKED) + await hass.async_block_till_done() + assert acc.char_current_state.value == 0 + assert acc.char_target_state.value == 0 + + hass.states.async_set(entity_id, STATE_UNKNOWN) + await hass.async_block_till_done() + assert acc.char_current_state.value == 3 + assert acc.char_target_state.value == 0 + + hass.states.async_remove(entity_id) + await hass.async_block_till_done() + assert acc.char_current_state.value == 3 + assert acc.char_target_state.value == 0 + + # Set from HomeKit + call_lock = async_mock_service(hass, DOMAIN, 'lock') + call_unlock = async_mock_service(hass, DOMAIN, 'unlock') + + await hass.async_add_job(acc.char_target_state.client_update_value, 1) + await hass.async_block_till_done() + assert call_lock + assert call_lock[0].data[ATTR_ENTITY_ID] == entity_id + assert call_lock[0].data[ATTR_CODE] == code + assert acc.char_target_state.value == 1 + + await hass.async_add_job(acc.char_target_state.client_update_value, 0) + await hass.async_block_till_done() + assert call_unlock + assert call_unlock[0].data[ATTR_ENTITY_ID] == entity_id + assert call_unlock[0].data[ATTR_CODE] == code + assert acc.char_target_state.value == 0 + + +@pytest.mark.parametrize('config', [{}, {ATTR_CODE: None}]) +async def test_no_code(hass, hk_driver, config): + """Test accessory if lock doesn't require a code.""" + entity_id = 'lock.kitchen_door' + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = Lock(hass, hk_driver, 'Lock', entity_id, 2, config) + + # Set from HomeKit + call_lock = async_mock_service(hass, DOMAIN, 'lock') + + await hass.async_add_job(acc.char_target_state.client_update_value, 1) + await hass.async_block_till_done() + assert call_lock + assert call_lock[0].data[ATTR_ENTITY_ID] == entity_id + assert ATTR_CODE not in call_lock[0].data + assert acc.char_target_state.value == 1 diff --git a/tests/components/homekit/test_type_media_players.py b/tests/components/homekit/test_type_media_players.py new file mode 100644 index 00000000000000..681cbba7252172 --- /dev/null +++ b/tests/components/homekit/test_type_media_players.py @@ -0,0 +1,115 @@ +"""Test different accessory types: Media Players.""" + +from homeassistant.components.homekit.const import ( + CONF_FEATURE_LIST, FEATURE_ON_OFF, FEATURE_PLAY_PAUSE, FEATURE_PLAY_STOP, + FEATURE_TOGGLE_MUTE) +from homeassistant.components.homekit.type_media_players import MediaPlayer +from homeassistant.components.media_player import ( + ATTR_MEDIA_VOLUME_MUTED, DOMAIN) +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, STATE_IDLE, STATE_OFF, STATE_ON, + STATE_PAUSED, STATE_PLAYING) + +from tests.common import async_mock_service + + +async def test_media_player_set_state(hass, hk_driver): + """Test if accessory and HA are updated accordingly.""" + config = {CONF_FEATURE_LIST: { + FEATURE_ON_OFF: None, FEATURE_PLAY_PAUSE: None, + FEATURE_PLAY_STOP: None, FEATURE_TOGGLE_MUTE: None}} + entity_id = 'media_player.test' + + hass.states.async_set(entity_id, None, {ATTR_SUPPORTED_FEATURES: 20873, + ATTR_MEDIA_VOLUME_MUTED: False}) + await hass.async_block_till_done() + acc = MediaPlayer(hass, hk_driver, 'MediaPlayer', entity_id, 2, config) + await hass.async_add_job(acc.run) + + assert acc.aid == 2 + assert acc.category == 8 # Switch + + assert acc.chars[FEATURE_ON_OFF].value == 0 + assert acc.chars[FEATURE_PLAY_PAUSE].value == 0 + assert acc.chars[FEATURE_PLAY_STOP].value == 0 + assert acc.chars[FEATURE_TOGGLE_MUTE].value == 0 + + hass.states.async_set(entity_id, STATE_ON, {ATTR_MEDIA_VOLUME_MUTED: True}) + await hass.async_block_till_done() + assert acc.chars[FEATURE_ON_OFF].value == 1 + assert acc.chars[FEATURE_TOGGLE_MUTE].value == 1 + + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + assert acc.chars[FEATURE_ON_OFF].value == 0 + + hass.states.async_set(entity_id, STATE_PLAYING) + await hass.async_block_till_done() + assert acc.chars[FEATURE_PLAY_PAUSE].value == 1 + assert acc.chars[FEATURE_PLAY_STOP].value == 1 + + hass.states.async_set(entity_id, STATE_PAUSED) + await hass.async_block_till_done() + assert acc.chars[FEATURE_PLAY_PAUSE].value == 0 + + hass.states.async_set(entity_id, STATE_IDLE) + await hass.async_block_till_done() + assert acc.chars[FEATURE_PLAY_STOP].value == 0 + + # Set from HomeKit + call_turn_on = async_mock_service(hass, DOMAIN, 'turn_on') + call_turn_off = async_mock_service(hass, DOMAIN, 'turn_off') + call_media_play = async_mock_service(hass, DOMAIN, 'media_play') + call_media_pause = async_mock_service(hass, DOMAIN, 'media_pause') + call_media_stop = async_mock_service(hass, DOMAIN, 'media_stop') + call_toggle_mute = async_mock_service(hass, DOMAIN, 'volume_mute') + + await hass.async_add_job(acc.chars[FEATURE_ON_OFF] + .client_update_value, True) + await hass.async_block_till_done() + assert call_turn_on + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + + await hass.async_add_job(acc.chars[FEATURE_ON_OFF] + .client_update_value, False) + await hass.async_block_till_done() + assert call_turn_off + assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + + await hass.async_add_job(acc.chars[FEATURE_PLAY_PAUSE] + .client_update_value, True) + await hass.async_block_till_done() + assert call_media_play + assert call_media_play[0].data[ATTR_ENTITY_ID] == entity_id + + await hass.async_add_job(acc.chars[FEATURE_PLAY_PAUSE] + .client_update_value, False) + await hass.async_block_till_done() + assert call_media_pause + assert call_media_pause[0].data[ATTR_ENTITY_ID] == entity_id + + await hass.async_add_job(acc.chars[FEATURE_PLAY_STOP] + .client_update_value, True) + await hass.async_block_till_done() + assert call_media_play + assert call_media_play[1].data[ATTR_ENTITY_ID] == entity_id + + await hass.async_add_job(acc.chars[FEATURE_PLAY_STOP] + .client_update_value, False) + await hass.async_block_till_done() + assert call_media_stop + assert call_media_stop[0].data[ATTR_ENTITY_ID] == entity_id + + await hass.async_add_job(acc.chars[FEATURE_TOGGLE_MUTE] + .client_update_value, True) + await hass.async_block_till_done() + assert call_toggle_mute + assert call_toggle_mute[0].data[ATTR_ENTITY_ID] == entity_id + assert call_toggle_mute[0].data[ATTR_MEDIA_VOLUME_MUTED] is True + + await hass.async_add_job(acc.chars[FEATURE_TOGGLE_MUTE] + .client_update_value, False) + await hass.async_block_till_done() + assert call_toggle_mute + assert call_toggle_mute[1].data[ATTR_ENTITY_ID] == entity_id + assert call_toggle_mute[1].data[ATTR_MEDIA_VOLUME_MUTED] is False diff --git a/tests/components/homekit/test_type_security_systems.py b/tests/components/homekit/test_type_security_systems.py index baa461af77200a..3ddce0f36eb4c7 100644 --- a/tests/components/homekit/test_type_security_systems.py +++ b/tests/components/homekit/test_type_security_systems.py @@ -1,134 +1,116 @@ """Test different accessory types: Security Systems.""" -import unittest +import pytest -from homeassistant.core import callback -from homeassistant.components.homekit.type_security_systems import ( - SecuritySystem) +from homeassistant.components.alarm_control_panel import DOMAIN +from homeassistant.components.homekit.type_security_systems import \ + SecuritySystem from homeassistant.const import ( - ATTR_CODE, ATTR_SERVICE, ATTR_SERVICE_DATA, EVENT_CALL_SERVICE, - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, + ATTR_CODE, ATTR_ENTITY_ID, STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_ARMED_NIGHT, STATE_ALARM_DISARMED, STATE_ALARM_TRIGGERED, STATE_UNKNOWN) -from tests.common import get_test_home_assistant - - -class TestHomekitSecuritySystems(unittest.TestCase): - """Test class for all accessory types regarding security systems.""" - - def setUp(self): - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.events = [] - - @callback - def record_event(event): - """Track called event.""" - self.events.append(event) - - self.hass.bus.listen(EVENT_CALL_SERVICE, record_event) - - def tearDown(self): - """Stop down everything that was started.""" - self.hass.stop() - - def test_switch_set_state(self): - """Test if accessory and HA are updated accordingly.""" - acp = 'alarm_control_panel.test' - - acc = SecuritySystem(self.hass, 'SecuritySystem', acp, - 2, config={ATTR_CODE: '1234'}) - acc.run() - - self.assertEqual(acc.aid, 2) - self.assertEqual(acc.category, 11) # AlarmSystem - - self.assertEqual(acc.char_current_state.value, 3) - self.assertEqual(acc.char_target_state.value, 3) - - self.hass.states.set(acp, STATE_ALARM_ARMED_AWAY) - self.hass.block_till_done() - self.assertEqual(acc.char_target_state.value, 1) - self.assertEqual(acc.char_current_state.value, 1) - - self.hass.states.set(acp, STATE_ALARM_ARMED_HOME) - self.hass.block_till_done() - self.assertEqual(acc.char_target_state.value, 0) - self.assertEqual(acc.char_current_state.value, 0) - - self.hass.states.set(acp, STATE_ALARM_ARMED_NIGHT) - self.hass.block_till_done() - self.assertEqual(acc.char_target_state.value, 2) - self.assertEqual(acc.char_current_state.value, 2) - - self.hass.states.set(acp, STATE_ALARM_DISARMED) - self.hass.block_till_done() - self.assertEqual(acc.char_target_state.value, 3) - self.assertEqual(acc.char_current_state.value, 3) - - self.hass.states.set(acp, STATE_ALARM_TRIGGERED) - self.hass.block_till_done() - self.assertEqual(acc.char_target_state.value, 3) - self.assertEqual(acc.char_current_state.value, 4) - - self.hass.states.set(acp, STATE_UNKNOWN) - self.hass.block_till_done() - self.assertEqual(acc.char_target_state.value, 3) - self.assertEqual(acc.char_current_state.value, 4) - - # Set from HomeKit - acc.char_target_state.client_update_value(0) - self.hass.block_till_done() - self.assertEqual( - self.events[0].data[ATTR_SERVICE], 'alarm_arm_home') - self.assertEqual( - self.events[0].data[ATTR_SERVICE_DATA][ATTR_CODE], '1234') - self.assertEqual(acc.char_target_state.value, 0) - - acc.char_target_state.client_update_value(1) - self.hass.block_till_done() - self.assertEqual( - self.events[1].data[ATTR_SERVICE], 'alarm_arm_away') - self.assertEqual( - self.events[0].data[ATTR_SERVICE_DATA][ATTR_CODE], '1234') - self.assertEqual(acc.char_target_state.value, 1) - - acc.char_target_state.client_update_value(2) - self.hass.block_till_done() - self.assertEqual( - self.events[2].data[ATTR_SERVICE], 'alarm_arm_night') - self.assertEqual( - self.events[0].data[ATTR_SERVICE_DATA][ATTR_CODE], '1234') - self.assertEqual(acc.char_target_state.value, 2) - - acc.char_target_state.client_update_value(3) - self.hass.block_till_done() - self.assertEqual( - self.events[3].data[ATTR_SERVICE], 'alarm_disarm') - self.assertEqual( - self.events[0].data[ATTR_SERVICE_DATA][ATTR_CODE], '1234') - self.assertEqual(acc.char_target_state.value, 3) - - def test_no_alarm_code(self): - """Test accessory if security_system doesn't require a alarm_code.""" - acp = 'alarm_control_panel.test' - - acc = SecuritySystem(self.hass, 'SecuritySystem', acp, - 2, config={ATTR_CODE: None}) - # Set from HomeKit - acc.char_target_state.client_update_value(0) - self.hass.block_till_done() - self.assertEqual( - self.events[0].data[ATTR_SERVICE], 'alarm_arm_home') - self.assertNotIn(ATTR_CODE, self.events[0].data[ATTR_SERVICE_DATA]) - self.assertEqual(acc.char_target_state.value, 0) - - acc = SecuritySystem(self.hass, 'SecuritySystem', acp, - 2, config={}) - # Set from HomeKit - acc.char_target_state.client_update_value(0) - self.hass.block_till_done() - self.assertEqual( - self.events[0].data[ATTR_SERVICE], 'alarm_arm_home') - self.assertNotIn(ATTR_CODE, self.events[0].data[ATTR_SERVICE_DATA]) - self.assertEqual(acc.char_target_state.value, 0) +from tests.common import async_mock_service + + +async def test_switch_set_state(hass, hk_driver): + """Test if accessory and HA are updated accordingly.""" + code = '1234' + config = {ATTR_CODE: code} + entity_id = 'alarm_control_panel.test' + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = SecuritySystem(hass, hk_driver, 'SecuritySystem', + entity_id, 2, config) + await hass.async_add_job(acc.run) + + assert acc.aid == 2 + assert acc.category == 11 # AlarmSystem + + assert acc.char_current_state.value == 3 + assert acc.char_target_state.value == 3 + + hass.states.async_set(entity_id, STATE_ALARM_ARMED_AWAY) + await hass.async_block_till_done() + assert acc.char_target_state.value == 1 + assert acc.char_current_state.value == 1 + + hass.states.async_set(entity_id, STATE_ALARM_ARMED_HOME) + await hass.async_block_till_done() + assert acc.char_target_state.value == 0 + assert acc.char_current_state.value == 0 + + hass.states.async_set(entity_id, STATE_ALARM_ARMED_NIGHT) + await hass.async_block_till_done() + assert acc.char_target_state.value == 2 + assert acc.char_current_state.value == 2 + + hass.states.async_set(entity_id, STATE_ALARM_DISARMED) + await hass.async_block_till_done() + assert acc.char_target_state.value == 3 + assert acc.char_current_state.value == 3 + + hass.states.async_set(entity_id, STATE_ALARM_TRIGGERED) + await hass.async_block_till_done() + assert acc.char_target_state.value == 3 + assert acc.char_current_state.value == 4 + + hass.states.async_set(entity_id, STATE_UNKNOWN) + await hass.async_block_till_done() + assert acc.char_target_state.value == 3 + assert acc.char_current_state.value == 4 + + # Set from HomeKit + call_arm_home = async_mock_service(hass, DOMAIN, 'alarm_arm_home') + call_arm_away = async_mock_service(hass, DOMAIN, 'alarm_arm_away') + call_arm_night = async_mock_service(hass, DOMAIN, 'alarm_arm_night') + call_disarm = async_mock_service(hass, DOMAIN, 'alarm_disarm') + + await hass.async_add_job(acc.char_target_state.client_update_value, 0) + await hass.async_block_till_done() + assert call_arm_home + assert call_arm_home[0].data[ATTR_ENTITY_ID] == entity_id + assert call_arm_home[0].data[ATTR_CODE] == code + assert acc.char_target_state.value == 0 + + await hass.async_add_job(acc.char_target_state.client_update_value, 1) + await hass.async_block_till_done() + assert call_arm_away + assert call_arm_away[0].data[ATTR_ENTITY_ID] == entity_id + assert call_arm_away[0].data[ATTR_CODE] == code + assert acc.char_target_state.value == 1 + + await hass.async_add_job(acc.char_target_state.client_update_value, 2) + await hass.async_block_till_done() + assert call_arm_night + assert call_arm_night[0].data[ATTR_ENTITY_ID] == entity_id + assert call_arm_night[0].data[ATTR_CODE] == code + assert acc.char_target_state.value == 2 + + await hass.async_add_job(acc.char_target_state.client_update_value, 3) + await hass.async_block_till_done() + assert call_disarm + assert call_disarm[0].data[ATTR_ENTITY_ID] == entity_id + assert call_disarm[0].data[ATTR_CODE] == code + assert acc.char_target_state.value == 3 + + +@pytest.mark.parametrize('config', [{}, {ATTR_CODE: None}]) +async def test_no_alarm_code(hass, hk_driver, config): + """Test accessory if security_system doesn't require an alarm_code.""" + entity_id = 'alarm_control_panel.test' + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = SecuritySystem(hass, hk_driver, 'SecuritySystem', + entity_id, 2, config) + + # Set from HomeKit + call_arm_home = async_mock_service(hass, DOMAIN, 'alarm_arm_home') + + await hass.async_add_job(acc.char_target_state.client_update_value, 0) + await hass.async_block_till_done() + assert call_arm_home + assert call_arm_home[0].data[ATTR_ENTITY_ID] == entity_id + assert ATTR_CODE not in call_arm_home[0].data + assert acc.char_target_state.value == 0 diff --git a/tests/components/homekit/test_type_sensors.py b/tests/components/homekit/test_type_sensors.py index 77bfc0c890129b..901a8e768563c7 100644 --- a/tests/components/homekit/test_type_sensors.py +++ b/tests/components/homekit/test_type_sensors.py @@ -1,209 +1,208 @@ """Test different accessory types: Sensors.""" -import unittest - from homeassistant.components.homekit.const import PROP_CELSIUS from homeassistant.components.homekit.type_sensors import ( - TemperatureSensor, HumiditySensor, AirQualitySensor, CarbonDioxideSensor, - LightSensor, BinarySensor, BINARY_SENSOR_SERVICE_MAP) + AirQualitySensor, BinarySensor, CarbonDioxideSensor, HumiditySensor, + LightSensor, TemperatureSensor, BINARY_SENSOR_SERVICE_MAP) from homeassistant.const import ( - ATTR_UNIT_OF_MEASUREMENT, ATTR_DEVICE_CLASS, STATE_UNKNOWN, STATE_ON, - STATE_OFF, STATE_HOME, STATE_NOT_HOME, TEMP_CELSIUS, TEMP_FAHRENHEIT) + ATTR_DEVICE_CLASS, ATTR_UNIT_OF_MEASUREMENT, STATE_HOME, STATE_NOT_HOME, + STATE_OFF, STATE_ON, STATE_UNKNOWN, TEMP_CELSIUS, TEMP_FAHRENHEIT) + + +async def test_temperature(hass, hk_driver): + """Test if accessory is updated after state change.""" + entity_id = 'sensor.temperature' + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = TemperatureSensor(hass, hk_driver, 'Temperature', entity_id, 2, None) + await hass.async_add_job(acc.run) + + assert acc.aid == 2 + assert acc.category == 10 # Sensor + + assert acc.char_temp.value == 0.0 + for key, value in PROP_CELSIUS.items(): + assert acc.char_temp.properties[key] == value + + hass.states.async_set(entity_id, STATE_UNKNOWN, + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_temp.value == 0.0 + + hass.states.async_set(entity_id, '20', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_temp.value == 20 + + hass.states.async_set(entity_id, '75.2', + {ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT}) + await hass.async_block_till_done() + assert acc.char_temp.value == 24 + + +async def test_humidity(hass, hk_driver): + """Test if accessory is updated after state change.""" + entity_id = 'sensor.humidity' + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = HumiditySensor(hass, hk_driver, 'Humidity', entity_id, 2, None) + await hass.async_add_job(acc.run) + + assert acc.aid == 2 + assert acc.category == 10 # Sensor + + assert acc.char_humidity.value == 0 -from tests.common import get_test_home_assistant + hass.states.async_set(entity_id, STATE_UNKNOWN) + await hass.async_block_till_done() + assert acc.char_humidity.value == 0 + hass.states.async_set(entity_id, '20') + await hass.async_block_till_done() + assert acc.char_humidity.value == 20 -class TestHomekitSensors(unittest.TestCase): - """Test class for all accessory types regarding sensors.""" - def setUp(self): - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() +async def test_air_quality(hass, hk_driver): + """Test if accessory is updated after state change.""" + entity_id = 'sensor.air_quality' - def tearDown(self): - """Stop down everything that was started.""" - self.hass.stop() + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = AirQualitySensor(hass, hk_driver, 'Air Quality', entity_id, 2, None) + await hass.async_add_job(acc.run) - def test_temperature(self): - """Test if accessory is updated after state change.""" - entity_id = 'sensor.temperature' + assert acc.aid == 2 + assert acc.category == 10 # Sensor - acc = TemperatureSensor(self.hass, 'Temperature', entity_id, - 2, config=None) - acc.run() + assert acc.char_density.value == 0 + assert acc.char_quality.value == 0 - self.assertEqual(acc.aid, 2) - self.assertEqual(acc.category, 10) # Sensor + hass.states.async_set(entity_id, STATE_UNKNOWN) + await hass.async_block_till_done() + assert acc.char_density.value == 0 + assert acc.char_quality.value == 0 - self.assertEqual(acc.char_temp.value, 0.0) - for key, value in PROP_CELSIUS.items(): - self.assertEqual(acc.char_temp.properties[key], value) + hass.states.async_set(entity_id, '34') + await hass.async_block_till_done() + assert acc.char_density.value == 34 + assert acc.char_quality.value == 1 - self.hass.states.set(entity_id, STATE_UNKNOWN, - {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) - self.hass.block_till_done() - self.assertEqual(acc.char_temp.value, 0.0) + hass.states.async_set(entity_id, '200') + await hass.async_block_till_done() + assert acc.char_density.value == 200 + assert acc.char_quality.value == 5 - self.hass.states.set(entity_id, '20', - {ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) - self.hass.block_till_done() - self.assertEqual(acc.char_temp.value, 20) - self.hass.states.set(entity_id, '75.2', - {ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT}) - self.hass.block_till_done() - self.assertEqual(acc.char_temp.value, 24) +async def test_co2(hass, hk_driver): + """Test if accessory is updated after state change.""" + entity_id = 'sensor.co2' - def test_humidity(self): - """Test if accessory is updated after state change.""" - entity_id = 'sensor.humidity' + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = CarbonDioxideSensor(hass, hk_driver, 'CO2', entity_id, 2, None) + await hass.async_add_job(acc.run) - acc = HumiditySensor(self.hass, 'Humidity', entity_id, 2, config=None) - acc.run() + assert acc.aid == 2 + assert acc.category == 10 # Sensor - self.assertEqual(acc.aid, 2) - self.assertEqual(acc.category, 10) # Sensor + assert acc.char_co2.value == 0 + assert acc.char_peak.value == 0 + assert acc.char_detected.value == 0 - self.assertEqual(acc.char_humidity.value, 0) + hass.states.async_set(entity_id, STATE_UNKNOWN) + await hass.async_block_till_done() + assert acc.char_co2.value == 0 + assert acc.char_peak.value == 0 + assert acc.char_detected.value == 0 - self.hass.states.set(entity_id, STATE_UNKNOWN) - self.hass.block_till_done() - self.assertEqual(acc.char_humidity.value, 0) + hass.states.async_set(entity_id, '1100') + await hass.async_block_till_done() + assert acc.char_co2.value == 1100 + assert acc.char_peak.value == 1100 + assert acc.char_detected.value == 1 - self.hass.states.set(entity_id, '20') - self.hass.block_till_done() - self.assertEqual(acc.char_humidity.value, 20) + hass.states.async_set(entity_id, '800') + await hass.async_block_till_done() + assert acc.char_co2.value == 800 + assert acc.char_peak.value == 1100 + assert acc.char_detected.value == 0 - def test_air_quality(self): - """Test if accessory is updated after state change.""" - entity_id = 'sensor.air_quality' - acc = AirQualitySensor(self.hass, 'Air Quality', entity_id, - 2, config=None) - acc.run() +async def test_light(hass, hk_driver): + """Test if accessory is updated after state change.""" + entity_id = 'sensor.light' - self.assertEqual(acc.aid, 2) - self.assertEqual(acc.category, 10) # Sensor + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = LightSensor(hass, hk_driver, 'Light', entity_id, 2, None) + await hass.async_add_job(acc.run) - self.assertEqual(acc.char_density.value, 0) - self.assertEqual(acc.char_quality.value, 0) + assert acc.aid == 2 + assert acc.category == 10 # Sensor - self.hass.states.set(entity_id, STATE_UNKNOWN) - self.hass.block_till_done() - self.assertEqual(acc.char_density.value, 0) - self.assertEqual(acc.char_quality.value, 0) + assert acc.char_light.value == 0.0001 - self.hass.states.set(entity_id, '34') - self.hass.block_till_done() - self.assertEqual(acc.char_density.value, 34) - self.assertEqual(acc.char_quality.value, 1) + hass.states.async_set(entity_id, STATE_UNKNOWN) + await hass.async_block_till_done() + assert acc.char_light.value == 0.0001 - self.hass.states.set(entity_id, '200') - self.hass.block_till_done() - self.assertEqual(acc.char_density.value, 200) - self.assertEqual(acc.char_quality.value, 5) + hass.states.async_set(entity_id, '300') + await hass.async_block_till_done() + assert acc.char_light.value == 300 - def test_co2(self): - """Test if accessory is updated after state change.""" - entity_id = 'sensor.co2' - acc = CarbonDioxideSensor(self.hass, 'CO2', entity_id, 2, config=None) - acc.run() +async def test_binary(hass, hk_driver): + """Test if accessory is updated after state change.""" + entity_id = 'binary_sensor.opening' - self.assertEqual(acc.aid, 2) - self.assertEqual(acc.category, 10) # Sensor + hass.states.async_set(entity_id, STATE_UNKNOWN, + {ATTR_DEVICE_CLASS: 'opening'}) + await hass.async_block_till_done() - self.assertEqual(acc.char_co2.value, 0) - self.assertEqual(acc.char_peak.value, 0) - self.assertEqual(acc.char_detected.value, 0) - - self.hass.states.set(entity_id, STATE_UNKNOWN) - self.hass.block_till_done() - self.assertEqual(acc.char_co2.value, 0) - self.assertEqual(acc.char_peak.value, 0) - self.assertEqual(acc.char_detected.value, 0) + acc = BinarySensor(hass, hk_driver, 'Window Opening', entity_id, 2, None) + await hass.async_add_job(acc.run) - self.hass.states.set(entity_id, '1100') - self.hass.block_till_done() - self.assertEqual(acc.char_co2.value, 1100) - self.assertEqual(acc.char_peak.value, 1100) - self.assertEqual(acc.char_detected.value, 1) - - self.hass.states.set(entity_id, '800') - self.hass.block_till_done() - self.assertEqual(acc.char_co2.value, 800) - self.assertEqual(acc.char_peak.value, 1100) - self.assertEqual(acc.char_detected.value, 0) + assert acc.aid == 2 + assert acc.category == 10 # Sensor - def test_light(self): - """Test if accessory is updated after state change.""" - entity_id = 'sensor.light' + assert acc.char_detected.value == 0 - acc = LightSensor(self.hass, 'Light', entity_id, 2, config=None) - acc.run() + hass.states.async_set(entity_id, STATE_ON, + {ATTR_DEVICE_CLASS: 'opening'}) + await hass.async_block_till_done() + assert acc.char_detected.value == 1 - self.assertEqual(acc.aid, 2) - self.assertEqual(acc.category, 10) # Sensor - - self.assertEqual(acc.char_light.value, 0.0001) + hass.states.async_set(entity_id, STATE_OFF, + {ATTR_DEVICE_CLASS: 'opening'}) + await hass.async_block_till_done() + assert acc.char_detected.value == 0 - self.hass.states.set(entity_id, STATE_UNKNOWN) - self.hass.block_till_done() - self.assertEqual(acc.char_light.value, 0.0001) + hass.states.async_set(entity_id, STATE_HOME, + {ATTR_DEVICE_CLASS: 'opening'}) + await hass.async_block_till_done() + assert acc.char_detected.value == 1 - self.hass.states.set(entity_id, '300') - self.hass.block_till_done() - self.assertEqual(acc.char_light.value, 300) + hass.states.async_set(entity_id, STATE_NOT_HOME, + {ATTR_DEVICE_CLASS: 'opening'}) + await hass.async_block_till_done() + assert acc.char_detected.value == 0 - def test_binary(self): - """Test if accessory is updated after state change.""" - entity_id = 'binary_sensor.opening' + hass.states.async_remove(entity_id) + await hass.async_block_till_done() + assert acc.char_detected.value == 0 - self.hass.states.set(entity_id, STATE_UNKNOWN, - {ATTR_DEVICE_CLASS: "opening"}) - self.hass.block_till_done() - acc = BinarySensor(self.hass, 'Window Opening', entity_id, - 2, config=None) - acc.run() - - self.assertEqual(acc.aid, 2) - self.assertEqual(acc.category, 10) # Sensor +async def test_binary_device_classes(hass, hk_driver): + """Test if services and characteristics are assigned correctly.""" + entity_id = 'binary_sensor.demo' - self.assertEqual(acc.char_detected.value, 0) - - self.hass.states.set(entity_id, STATE_ON, - {ATTR_DEVICE_CLASS: "opening"}) - self.hass.block_till_done() - self.assertEqual(acc.char_detected.value, 1) - - self.hass.states.set(entity_id, STATE_OFF, - {ATTR_DEVICE_CLASS: "opening"}) - self.hass.block_till_done() - self.assertEqual(acc.char_detected.value, 0) + for device_class, (service, char) in BINARY_SENSOR_SERVICE_MAP.items(): + hass.states.async_set(entity_id, STATE_OFF, + {ATTR_DEVICE_CLASS: device_class}) + await hass.async_block_till_done() - self.hass.states.set(entity_id, STATE_HOME, - {ATTR_DEVICE_CLASS: "opening"}) - self.hass.block_till_done() - self.assertEqual(acc.char_detected.value, 1) - - self.hass.states.set(entity_id, STATE_NOT_HOME, - {ATTR_DEVICE_CLASS: "opening"}) - self.hass.block_till_done() - self.assertEqual(acc.char_detected.value, 0) - - self.hass.states.remove(entity_id) - self.hass.block_till_done() - - def test_binary_device_classes(self): - """Test if services and characteristics are assigned correctly.""" - entity_id = 'binary_sensor.demo' - - for device_class, (service, char) in BINARY_SENSOR_SERVICE_MAP.items(): - self.hass.states.set(entity_id, STATE_OFF, - {ATTR_DEVICE_CLASS: device_class}) - self.hass.block_till_done() - - acc = BinarySensor(self.hass, 'Binary Sensor', entity_id, - 2, config=None) - self.assertEqual(acc.get_service(service).display_name, service) - self.assertEqual(acc.char_detected.display_name, char) + acc = BinarySensor(hass, hk_driver, 'Binary Sensor', + entity_id, 2, None) + assert acc.get_service(service).display_name == service + assert acc.char_detected.display_name == char diff --git a/tests/components/homekit/test_type_switches.py b/tests/components/homekit/test_type_switches.py index 65b107e24cd8b2..c2b80226508d79 100644 --- a/tests/components/homekit/test_type_switches.py +++ b/tests/components/homekit/test_type_switches.py @@ -1,104 +1,92 @@ """Test different accessory types: Switches.""" -import unittest - -from homeassistant.core import callback, split_entity_id -from homeassistant.components.homekit.type_switches import Switch -from homeassistant.const import ( - ATTR_DOMAIN, ATTR_SERVICE, EVENT_CALL_SERVICE, - SERVICE_TURN_ON, SERVICE_TURN_OFF, STATE_ON, STATE_OFF) - -from tests.common import get_test_home_assistant - - -class TestHomekitSwitches(unittest.TestCase): - """Test class for all accessory types regarding switches.""" - - def setUp(self): - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.events = [] - - @callback - def record_event(event): - """Track called event.""" - self.events.append(event) - - self.hass.bus.listen(EVENT_CALL_SERVICE, record_event) - - def tearDown(self): - """Stop down everything that was started.""" - self.hass.stop() - - def test_switch_set_state(self): - """Test if accessory and HA are updated accordingly.""" - entity_id = 'switch.test' - domain = split_entity_id(entity_id)[0] - - acc = Switch(self.hass, 'Switch', entity_id, 2, config=None) - acc.run() - - self.assertEqual(acc.aid, 2) - self.assertEqual(acc.category, 8) # Switch - - self.assertEqual(acc.char_on.value, False) - - self.hass.states.set(entity_id, STATE_ON) - self.hass.block_till_done() - self.assertEqual(acc.char_on.value, True) - - self.hass.states.set(entity_id, STATE_OFF) - self.hass.block_till_done() - self.assertEqual(acc.char_on.value, False) - - # Set from HomeKit - acc.char_on.client_update_value(True) - self.hass.block_till_done() - self.assertEqual( - self.events[0].data[ATTR_DOMAIN], domain) - self.assertEqual( - self.events[0].data[ATTR_SERVICE], SERVICE_TURN_ON) - - acc.char_on.client_update_value(False) - self.hass.block_till_done() - self.assertEqual( - self.events[1].data[ATTR_DOMAIN], domain) - self.assertEqual( - self.events[1].data[ATTR_SERVICE], SERVICE_TURN_OFF) - - def test_remote_set_state(self): - """Test service call for remote as domain.""" - entity_id = 'remote.test' - domain = split_entity_id(entity_id)[0] - - acc = Switch(self.hass, 'Switch', entity_id, 2, config=None) - acc.run() - - self.assertEqual(acc.char_on.value, False) - - # Set from HomeKit - acc.char_on.client_update_value(True) - self.hass.block_till_done() - self.assertEqual( - self.events[0].data[ATTR_DOMAIN], domain) - self.assertEqual( - self.events[0].data[ATTR_SERVICE], SERVICE_TURN_ON) - self.assertEqual(acc.char_on.value, True) - - def test_input_boolean_set_state(self): - """Test service call for remote as domain.""" - entity_id = 'input_boolean.test' - domain = split_entity_id(entity_id)[0] - - acc = Switch(self.hass, 'Switch', entity_id, 2, config=None) - acc.run() - - self.assertEqual(acc.char_on.value, False) - - # Set from HomeKit - acc.char_on.client_update_value(True) - self.hass.block_till_done() - self.assertEqual( - self.events[0].data[ATTR_DOMAIN], domain) - self.assertEqual( - self.events[0].data[ATTR_SERVICE], SERVICE_TURN_ON) - self.assertEqual(acc.char_on.value, True) +import pytest + +from homeassistant.components.homekit.type_switches import Outlet, Switch +from homeassistant.const import ATTR_ENTITY_ID, STATE_OFF, STATE_ON +from homeassistant.core import split_entity_id + +from tests.common import async_mock_service + + +async def test_outlet_set_state(hass, hk_driver): + """Test if Outlet accessory and HA are updated accordingly.""" + entity_id = 'switch.outlet_test' + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = Outlet(hass, hk_driver, 'Outlet', entity_id, 2, None) + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + + assert acc.aid == 2 + assert acc.category == 7 # Outlet + + assert acc.char_on.value is False + assert acc.char_outlet_in_use.value is True + + hass.states.async_set(entity_id, STATE_ON) + await hass.async_block_till_done() + assert acc.char_on.value is True + + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + assert acc.char_on.value is False + + # Set from HomeKit + call_turn_on = async_mock_service(hass, 'switch', 'turn_on') + call_turn_off = async_mock_service(hass, 'switch', 'turn_off') + + await hass.async_add_job(acc.char_on.client_update_value, True) + await hass.async_block_till_done() + assert call_turn_on + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + + await hass.async_add_job(acc.char_on.client_update_value, False) + await hass.async_block_till_done() + assert call_turn_off + assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + + +@pytest.mark.parametrize('entity_id', [ + 'automation.test', + 'input_boolean.test', + 'remote.test', + 'script.test', + 'switch.test', +]) +async def test_switch_set_state(hass, hk_driver, entity_id): + """Test if accessory and HA are updated accordingly.""" + domain = split_entity_id(entity_id)[0] + + hass.states.async_set(entity_id, None) + await hass.async_block_till_done() + acc = Switch(hass, hk_driver, 'Switch', entity_id, 2, None) + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + + assert acc.aid == 2 + assert acc.category == 8 # Switch + + assert acc.char_on.value is False + + hass.states.async_set(entity_id, STATE_ON) + await hass.async_block_till_done() + assert acc.char_on.value is True + + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + assert acc.char_on.value is False + + # Set from HomeKit + call_turn_on = async_mock_service(hass, domain, 'turn_on') + call_turn_off = async_mock_service(hass, domain, 'turn_off') + + await hass.async_add_job(acc.char_on.client_update_value, True) + await hass.async_block_till_done() + assert call_turn_on + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + + await hass.async_add_job(acc.char_on.client_update_value, False) + await hass.async_block_till_done() + assert call_turn_off + assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id diff --git a/tests/components/homekit/test_type_thermostats.py b/tests/components/homekit/test_type_thermostats.py index fe2a7f6cd02787..45c340e58c4c27 100644 --- a/tests/components/homekit/test_type_thermostats.py +++ b/tests/components/homekit/test_type_thermostats.py @@ -1,364 +1,389 @@ """Test different accessory types: Thermostats.""" -import unittest +from collections import namedtuple +from unittest.mock import patch + +import pytest -from homeassistant.core import callback from homeassistant.components.climate import ( - ATTR_CURRENT_TEMPERATURE, ATTR_TEMPERATURE, + ATTR_CURRENT_TEMPERATURE, ATTR_MAX_TEMP, ATTR_MIN_TEMP, ATTR_TEMPERATURE, ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_HIGH, ATTR_OPERATION_MODE, - ATTR_OPERATION_LIST, STATE_COOL, STATE_HEAT, STATE_AUTO) + ATTR_OPERATION_LIST, DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, DOMAIN, + STATE_AUTO, STATE_COOL, STATE_HEAT) +from homeassistant.components.homekit.const import ( + PROP_MAX_VALUE, PROP_MIN_VALUE) from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_SERVICE, ATTR_SERVICE_DATA, ATTR_SUPPORTED_FEATURES, - ATTR_UNIT_OF_MEASUREMENT, EVENT_CALL_SERVICE, - STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT) - -from tests.common import get_test_home_assistant -from tests.components.homekit.test_accessories import patch_debounce - - -class TestHomekitThermostats(unittest.TestCase): - """Test class for all accessory types regarding thermostats.""" - - @classmethod - def setUpClass(cls): - """Setup Thermostat class import and debounce patcher.""" - cls.patcher = patch_debounce() - cls.patcher.start() - _import = __import__( - 'homeassistant.components.homekit.type_thermostats', - fromlist=['Thermostat']) - cls.thermostat_cls = _import.Thermostat - - @classmethod - def tearDownClass(cls): - """Stop debounce patcher.""" - cls.patcher.stop() - - def setUp(self): - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.events = [] - - @callback - def record_event(event): - """Track called event.""" - self.events.append(event) - - self.hass.bus.listen(EVENT_CALL_SERVICE, record_event) - - def tearDown(self): - """Stop down everything that was started.""" - self.hass.stop() - - def test_default_thermostat(self): - """Test if accessory and HA are updated accordingly.""" - climate = 'climate.test' - - self.hass.states.set(climate, STATE_OFF, {ATTR_SUPPORTED_FEATURES: 0}) - self.hass.block_till_done() - acc = self.thermostat_cls(self.hass, 'Climate', climate, - 2, config=None) - acc.run() - - self.assertEqual(acc.aid, 2) - self.assertEqual(acc.category, 9) # Thermostat - - self.assertEqual(acc.char_current_heat_cool.value, 0) - self.assertEqual(acc.char_target_heat_cool.value, 0) - self.assertEqual(acc.char_current_temp.value, 21.0) - self.assertEqual(acc.char_target_temp.value, 21.0) - self.assertEqual(acc.char_display_units.value, 0) - self.assertEqual(acc.char_cooling_thresh_temp, None) - self.assertEqual(acc.char_heating_thresh_temp, None) - - self.hass.states.set(climate, STATE_HEAT, - {ATTR_OPERATION_MODE: STATE_HEAT, - ATTR_TEMPERATURE: 22.0, - ATTR_CURRENT_TEMPERATURE: 18.0, - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) - self.hass.block_till_done() - self.assertEqual(acc.char_target_temp.value, 22.0) - self.assertEqual(acc.char_current_heat_cool.value, 1) - self.assertEqual(acc.char_target_heat_cool.value, 1) - self.assertEqual(acc.char_current_temp.value, 18.0) - self.assertEqual(acc.char_display_units.value, 0) - - self.hass.states.set(climate, STATE_HEAT, - {ATTR_OPERATION_MODE: STATE_HEAT, - ATTR_TEMPERATURE: 22.0, - ATTR_CURRENT_TEMPERATURE: 23.0, - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) - self.hass.block_till_done() - self.assertEqual(acc.char_target_temp.value, 22.0) - self.assertEqual(acc.char_current_heat_cool.value, 0) - self.assertEqual(acc.char_target_heat_cool.value, 1) - self.assertEqual(acc.char_current_temp.value, 23.0) - self.assertEqual(acc.char_display_units.value, 0) - - self.hass.states.set(climate, STATE_COOL, - {ATTR_OPERATION_MODE: STATE_COOL, - ATTR_TEMPERATURE: 20.0, - ATTR_CURRENT_TEMPERATURE: 25.0, - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) - self.hass.block_till_done() - self.assertEqual(acc.char_target_temp.value, 20.0) - self.assertEqual(acc.char_current_heat_cool.value, 2) - self.assertEqual(acc.char_target_heat_cool.value, 2) - self.assertEqual(acc.char_current_temp.value, 25.0) - self.assertEqual(acc.char_display_units.value, 0) - - self.hass.states.set(climate, STATE_COOL, - {ATTR_OPERATION_MODE: STATE_COOL, - ATTR_TEMPERATURE: 20.0, - ATTR_CURRENT_TEMPERATURE: 19.0, - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) - self.hass.block_till_done() - self.assertEqual(acc.char_target_temp.value, 20.0) - self.assertEqual(acc.char_current_heat_cool.value, 0) - self.assertEqual(acc.char_target_heat_cool.value, 2) - self.assertEqual(acc.char_current_temp.value, 19.0) - self.assertEqual(acc.char_display_units.value, 0) - - self.hass.states.set(climate, STATE_OFF, - {ATTR_OPERATION_MODE: STATE_OFF, - ATTR_TEMPERATURE: 22.0, - ATTR_CURRENT_TEMPERATURE: 18.0, - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) - self.hass.block_till_done() - self.assertEqual(acc.char_target_temp.value, 22.0) - self.assertEqual(acc.char_current_heat_cool.value, 0) - self.assertEqual(acc.char_target_heat_cool.value, 0) - self.assertEqual(acc.char_current_temp.value, 18.0) - self.assertEqual(acc.char_display_units.value, 0) - - self.hass.states.set(climate, STATE_AUTO, - {ATTR_OPERATION_MODE: STATE_AUTO, - ATTR_OPERATION_LIST: [STATE_HEAT, STATE_COOL], - ATTR_TEMPERATURE: 22.0, - ATTR_CURRENT_TEMPERATURE: 18.0, - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) - self.hass.block_till_done() - self.assertEqual(acc.char_target_temp.value, 22.0) - self.assertEqual(acc.char_current_heat_cool.value, 1) - self.assertEqual(acc.char_target_heat_cool.value, 3) - self.assertEqual(acc.char_current_temp.value, 18.0) - self.assertEqual(acc.char_display_units.value, 0) - - self.hass.states.set(climate, STATE_AUTO, - {ATTR_OPERATION_MODE: STATE_AUTO, - ATTR_OPERATION_LIST: [STATE_HEAT, STATE_COOL], - ATTR_TEMPERATURE: 22.0, - ATTR_CURRENT_TEMPERATURE: 25.0, - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) - self.hass.block_till_done() - self.assertEqual(acc.char_target_temp.value, 22.0) - self.assertEqual(acc.char_current_heat_cool.value, 2) - self.assertEqual(acc.char_target_heat_cool.value, 3) - self.assertEqual(acc.char_current_temp.value, 25.0) - self.assertEqual(acc.char_display_units.value, 0) - - self.hass.states.set(climate, STATE_AUTO, - {ATTR_OPERATION_MODE: STATE_AUTO, - ATTR_OPERATION_LIST: [STATE_HEAT, STATE_COOL], - ATTR_TEMPERATURE: 22.0, - ATTR_CURRENT_TEMPERATURE: 22.0, - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) - self.hass.block_till_done() - self.assertEqual(acc.char_target_temp.value, 22.0) - self.assertEqual(acc.char_current_heat_cool.value, 0) - self.assertEqual(acc.char_target_heat_cool.value, 3) - self.assertEqual(acc.char_current_temp.value, 22.0) - self.assertEqual(acc.char_display_units.value, 0) - - # Set from HomeKit - acc.char_target_temp.client_update_value(19.0) - self.hass.block_till_done() - self.assertEqual( - self.events[0].data[ATTR_SERVICE], 'set_temperature') - self.assertEqual( - self.events[0].data[ATTR_SERVICE_DATA][ATTR_TEMPERATURE], 19.0) - self.assertEqual(acc.char_target_temp.value, 19.0) - - acc.char_target_heat_cool.client_update_value(1) - self.hass.block_till_done() - self.assertEqual( - self.events[1].data[ATTR_SERVICE], 'set_operation_mode') - self.assertEqual( - self.events[1].data[ATTR_SERVICE_DATA][ATTR_OPERATION_MODE], - STATE_HEAT) - self.assertEqual(acc.char_target_heat_cool.value, 1) - - def test_auto_thermostat(self): - """Test if accessory and HA are updated accordingly.""" - climate = 'climate.test' - - # support_auto = True - self.hass.states.set(climate, STATE_OFF, {ATTR_SUPPORTED_FEATURES: 6}) - self.hass.block_till_done() - acc = self.thermostat_cls(self.hass, 'Climate', climate, - 2, config=None) - acc.run() - - self.assertEqual(acc.char_cooling_thresh_temp.value, 23.0) - self.assertEqual(acc.char_heating_thresh_temp.value, 19.0) - - self.hass.states.set(climate, STATE_AUTO, - {ATTR_OPERATION_MODE: STATE_AUTO, - ATTR_TARGET_TEMP_HIGH: 22.0, - ATTR_TARGET_TEMP_LOW: 20.0, - ATTR_CURRENT_TEMPERATURE: 18.0, - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) - self.hass.block_till_done() - self.assertEqual(acc.char_heating_thresh_temp.value, 20.0) - self.assertEqual(acc.char_cooling_thresh_temp.value, 22.0) - self.assertEqual(acc.char_current_heat_cool.value, 1) - self.assertEqual(acc.char_target_heat_cool.value, 3) - self.assertEqual(acc.char_current_temp.value, 18.0) - self.assertEqual(acc.char_display_units.value, 0) - - self.hass.states.set(climate, STATE_AUTO, - {ATTR_OPERATION_MODE: STATE_AUTO, - ATTR_TARGET_TEMP_HIGH: 23.0, - ATTR_TARGET_TEMP_LOW: 19.0, - ATTR_CURRENT_TEMPERATURE: 24.0, - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) - self.hass.block_till_done() - self.assertEqual(acc.char_heating_thresh_temp.value, 19.0) - self.assertEqual(acc.char_cooling_thresh_temp.value, 23.0) - self.assertEqual(acc.char_current_heat_cool.value, 2) - self.assertEqual(acc.char_target_heat_cool.value, 3) - self.assertEqual(acc.char_current_temp.value, 24.0) - self.assertEqual(acc.char_display_units.value, 0) - - self.hass.states.set(climate, STATE_AUTO, - {ATTR_OPERATION_MODE: STATE_AUTO, - ATTR_TARGET_TEMP_HIGH: 23.0, - ATTR_TARGET_TEMP_LOW: 19.0, - ATTR_CURRENT_TEMPERATURE: 21.0, - ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) - self.hass.block_till_done() - self.assertEqual(acc.char_heating_thresh_temp.value, 19.0) - self.assertEqual(acc.char_cooling_thresh_temp.value, 23.0) - self.assertEqual(acc.char_current_heat_cool.value, 0) - self.assertEqual(acc.char_target_heat_cool.value, 3) - self.assertEqual(acc.char_current_temp.value, 21.0) - self.assertEqual(acc.char_display_units.value, 0) - - # Set from HomeKit - acc.char_heating_thresh_temp.client_update_value(20.0) - self.hass.block_till_done() - self.assertEqual( - self.events[0].data[ATTR_SERVICE], 'set_temperature') - self.assertEqual( - self.events[0].data[ATTR_SERVICE_DATA][ATTR_TARGET_TEMP_LOW], 20.0) - self.assertEqual(acc.char_heating_thresh_temp.value, 20.0) - - acc.char_cooling_thresh_temp.client_update_value(25.0) - self.hass.block_till_done() - self.assertEqual( - self.events[1].data[ATTR_SERVICE], 'set_temperature') - self.assertEqual( - self.events[1].data[ATTR_SERVICE_DATA][ATTR_TARGET_TEMP_HIGH], - 25.0) - self.assertEqual(acc.char_cooling_thresh_temp.value, 25.0) - - def test_power_state(self): - """Test if accessory and HA are updated accordingly.""" - climate = 'climate.test' - - # SUPPORT_ON_OFF = True - self.hass.states.set(climate, STATE_HEAT, - {ATTR_SUPPORTED_FEATURES: 4096, - ATTR_OPERATION_MODE: STATE_HEAT, - ATTR_TEMPERATURE: 23.0, - ATTR_CURRENT_TEMPERATURE: 18.0}) - self.hass.block_till_done() - acc = self.thermostat_cls(self.hass, 'Climate', climate, - 2, config=None) - acc.run() - self.assertTrue(acc.support_power_state) - - self.assertEqual(acc.char_current_heat_cool.value, 1) - self.assertEqual(acc.char_target_heat_cool.value, 1) - - self.hass.states.set(climate, STATE_OFF, - {ATTR_OPERATION_MODE: STATE_HEAT, - ATTR_TEMPERATURE: 23.0, - ATTR_CURRENT_TEMPERATURE: 18.0}) - self.hass.block_till_done() - self.assertEqual(acc.char_current_heat_cool.value, 0) - self.assertEqual(acc.char_target_heat_cool.value, 0) - - self.hass.states.set(climate, STATE_OFF, - {ATTR_OPERATION_MODE: STATE_OFF, - ATTR_TEMPERATURE: 23.0, - ATTR_CURRENT_TEMPERATURE: 18.0}) - self.hass.block_till_done() - self.assertEqual(acc.char_current_heat_cool.value, 0) - self.assertEqual(acc.char_target_heat_cool.value, 0) - - # Set from HomeKit - acc.char_target_heat_cool.client_update_value(1) - self.hass.block_till_done() - self.assertEqual( - self.events[0].data[ATTR_SERVICE], 'turn_on') - self.assertEqual( - self.events[0].data[ATTR_SERVICE_DATA][ATTR_ENTITY_ID], - climate) - self.assertEqual( - self.events[1].data[ATTR_SERVICE], 'set_operation_mode') - self.assertEqual( - self.events[1].data[ATTR_SERVICE_DATA][ATTR_OPERATION_MODE], - STATE_HEAT) - self.assertEqual(acc.char_target_heat_cool.value, 1) - - acc.char_target_heat_cool.client_update_value(0) - self.hass.block_till_done() - self.assertEqual( - self.events[2].data[ATTR_SERVICE], 'turn_off') - self.assertEqual( - self.events[2].data[ATTR_SERVICE_DATA][ATTR_ENTITY_ID], - climate) - self.assertEqual(acc.char_target_heat_cool.value, 0) - - def test_thermostat_fahrenheit(self): - """Test if accessory and HA are updated accordingly.""" - climate = 'climate.test' - - # support_auto = True - self.hass.states.set(climate, STATE_OFF, {ATTR_SUPPORTED_FEATURES: 6}) - self.hass.block_till_done() - acc = self.thermostat_cls(self.hass, 'Climate', climate, - 2, config=None) - acc.run() - - self.hass.states.set(climate, STATE_AUTO, - {ATTR_OPERATION_MODE: STATE_AUTO, - ATTR_TARGET_TEMP_HIGH: 75.2, - ATTR_TARGET_TEMP_LOW: 68, - ATTR_TEMPERATURE: 71.6, - ATTR_CURRENT_TEMPERATURE: 73.4, - ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT}) - self.hass.block_till_done() - self.assertEqual(acc.char_heating_thresh_temp.value, 20.0) - self.assertEqual(acc.char_cooling_thresh_temp.value, 24.0) - self.assertEqual(acc.char_current_temp.value, 23.0) - self.assertEqual(acc.char_target_temp.value, 22.0) - self.assertEqual(acc.char_display_units.value, 1) - - # Set from HomeKit - acc.char_cooling_thresh_temp.client_update_value(23) - self.hass.block_till_done() - service_data = self.events[-1].data[ATTR_SERVICE_DATA] - self.assertEqual(service_data[ATTR_TARGET_TEMP_HIGH], 73.4) - self.assertEqual(service_data[ATTR_TARGET_TEMP_LOW], 68) - - acc.char_heating_thresh_temp.client_update_value(22) - self.hass.block_till_done() - service_data = self.events[-1].data[ATTR_SERVICE_DATA] - self.assertEqual(service_data[ATTR_TARGET_TEMP_HIGH], 73.4) - self.assertEqual(service_data[ATTR_TARGET_TEMP_LOW], 71.6) - - acc.char_target_temp.client_update_value(24.0) - self.hass.block_till_done() - service_data = self.events[-1].data[ATTR_SERVICE_DATA] - self.assertEqual(service_data[ATTR_TEMPERATURE], 75.2) + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, + CONF_TEMPERATURE_UNIT, STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT) + +from tests.common import async_mock_service +from tests.components.homekit.common import patch_debounce + + +@pytest.fixture(scope='module') +def cls(): + """Patch debounce decorator during import of type_thermostats.""" + patcher = patch_debounce() + patcher.start() + _import = __import__('homeassistant.components.homekit.type_thermostats', + fromlist=['Thermostat']) + patcher_tuple = namedtuple('Cls', ['thermostat']) + yield patcher_tuple(thermostat=_import.Thermostat) + patcher.stop() + + +async def test_default_thermostat(hass, hk_driver, cls): + """Test if accessory and HA are updated accordingly.""" + entity_id = 'climate.test' + + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + acc = cls.thermostat(hass, hk_driver, 'Climate', entity_id, 2, None) + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + + assert acc.aid == 2 + assert acc.category == 9 # Thermostat + + assert acc.char_current_heat_cool.value == 0 + assert acc.char_target_heat_cool.value == 0 + assert acc.char_current_temp.value == 21.0 + assert acc.char_target_temp.value == 21.0 + assert acc.char_display_units.value == 0 + assert acc.char_cooling_thresh_temp is None + assert acc.char_heating_thresh_temp is None + + assert acc.char_target_temp.properties[PROP_MAX_VALUE] == DEFAULT_MAX_TEMP + assert acc.char_target_temp.properties[PROP_MIN_VALUE] == DEFAULT_MIN_TEMP + + hass.states.async_set(entity_id, STATE_HEAT, + {ATTR_OPERATION_MODE: STATE_HEAT, + ATTR_TEMPERATURE: 22.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_target_temp.value == 22.0 + assert acc.char_current_heat_cool.value == 1 + assert acc.char_target_heat_cool.value == 1 + assert acc.char_current_temp.value == 18.0 + assert acc.char_display_units.value == 0 + + hass.states.async_set(entity_id, STATE_HEAT, + {ATTR_OPERATION_MODE: STATE_HEAT, + ATTR_TEMPERATURE: 22.0, + ATTR_CURRENT_TEMPERATURE: 23.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_target_temp.value == 22.0 + assert acc.char_current_heat_cool.value == 0 + assert acc.char_target_heat_cool.value == 1 + assert acc.char_current_temp.value == 23.0 + assert acc.char_display_units.value == 0 + + hass.states.async_set(entity_id, STATE_COOL, + {ATTR_OPERATION_MODE: STATE_COOL, + ATTR_TEMPERATURE: 20.0, + ATTR_CURRENT_TEMPERATURE: 25.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_target_temp.value == 20.0 + assert acc.char_current_heat_cool.value == 2 + assert acc.char_target_heat_cool.value == 2 + assert acc.char_current_temp.value == 25.0 + assert acc.char_display_units.value == 0 + + hass.states.async_set(entity_id, STATE_COOL, + {ATTR_OPERATION_MODE: STATE_COOL, + ATTR_TEMPERATURE: 20.0, + ATTR_CURRENT_TEMPERATURE: 19.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_target_temp.value == 20.0 + assert acc.char_current_heat_cool.value == 0 + assert acc.char_target_heat_cool.value == 2 + assert acc.char_current_temp.value == 19.0 + assert acc.char_display_units.value == 0 + + hass.states.async_set(entity_id, STATE_OFF, + {ATTR_OPERATION_MODE: STATE_OFF, + ATTR_TEMPERATURE: 22.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_target_temp.value == 22.0 + assert acc.char_current_heat_cool.value == 0 + assert acc.char_target_heat_cool.value == 0 + assert acc.char_current_temp.value == 18.0 + assert acc.char_display_units.value == 0 + + hass.states.async_set(entity_id, STATE_AUTO, + {ATTR_OPERATION_MODE: STATE_AUTO, + ATTR_OPERATION_LIST: [STATE_HEAT, STATE_COOL], + ATTR_TEMPERATURE: 22.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_target_temp.value == 22.0 + assert acc.char_current_heat_cool.value == 1 + assert acc.char_target_heat_cool.value == 3 + assert acc.char_current_temp.value == 18.0 + assert acc.char_display_units.value == 0 + + hass.states.async_set(entity_id, STATE_AUTO, + {ATTR_OPERATION_MODE: STATE_AUTO, + ATTR_OPERATION_LIST: [STATE_HEAT, STATE_COOL], + ATTR_TEMPERATURE: 22.0, + ATTR_CURRENT_TEMPERATURE: 25.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_target_temp.value == 22.0 + assert acc.char_current_heat_cool.value == 2 + assert acc.char_target_heat_cool.value == 3 + assert acc.char_current_temp.value == 25.0 + assert acc.char_display_units.value == 0 + + hass.states.async_set(entity_id, STATE_AUTO, + {ATTR_OPERATION_MODE: STATE_AUTO, + ATTR_OPERATION_LIST: [STATE_HEAT, STATE_COOL], + ATTR_TEMPERATURE: 22.0, + ATTR_CURRENT_TEMPERATURE: 22.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_target_temp.value == 22.0 + assert acc.char_current_heat_cool.value == 0 + assert acc.char_target_heat_cool.value == 3 + assert acc.char_current_temp.value == 22.0 + assert acc.char_display_units.value == 0 + + # Set from HomeKit + call_set_temperature = async_mock_service(hass, DOMAIN, 'set_temperature') + call_set_operation_mode = async_mock_service(hass, DOMAIN, + 'set_operation_mode') + + await hass.async_add_job(acc.char_target_temp.client_update_value, 19.0) + await hass.async_block_till_done() + assert call_set_temperature + assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_temperature[0].data[ATTR_TEMPERATURE] == 19.0 + assert acc.char_target_temp.value == 19.0 + + await hass.async_add_job(acc.char_target_heat_cool.client_update_value, 1) + await hass.async_block_till_done() + assert call_set_operation_mode + assert call_set_operation_mode[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_operation_mode[0].data[ATTR_OPERATION_MODE] == STATE_HEAT + assert acc.char_target_heat_cool.value == 1 + + +async def test_auto_thermostat(hass, hk_driver, cls): + """Test if accessory and HA are updated accordingly.""" + entity_id = 'climate.test' + + # support_auto = True + hass.states.async_set(entity_id, STATE_OFF, {ATTR_SUPPORTED_FEATURES: 6}) + await hass.async_block_till_done() + acc = cls.thermostat(hass, hk_driver, 'Climate', entity_id, 2, None) + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + + assert acc.char_cooling_thresh_temp.value == 23.0 + assert acc.char_heating_thresh_temp.value == 19.0 + + assert acc.char_cooling_thresh_temp.properties[PROP_MAX_VALUE] \ + == DEFAULT_MAX_TEMP + assert acc.char_cooling_thresh_temp.properties[PROP_MIN_VALUE] \ + == DEFAULT_MIN_TEMP + assert acc.char_heating_thresh_temp.properties[PROP_MAX_VALUE] \ + == DEFAULT_MAX_TEMP + assert acc.char_heating_thresh_temp.properties[PROP_MIN_VALUE] \ + == DEFAULT_MIN_TEMP + + hass.states.async_set(entity_id, STATE_AUTO, + {ATTR_OPERATION_MODE: STATE_AUTO, + ATTR_TARGET_TEMP_HIGH: 22.0, + ATTR_TARGET_TEMP_LOW: 20.0, + ATTR_CURRENT_TEMPERATURE: 18.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_heating_thresh_temp.value == 20.0 + assert acc.char_cooling_thresh_temp.value == 22.0 + assert acc.char_current_heat_cool.value == 1 + assert acc.char_target_heat_cool.value == 3 + assert acc.char_current_temp.value == 18.0 + assert acc.char_display_units.value == 0 + + hass.states.async_set(entity_id, STATE_AUTO, + {ATTR_OPERATION_MODE: STATE_AUTO, + ATTR_TARGET_TEMP_HIGH: 23.0, + ATTR_TARGET_TEMP_LOW: 19.0, + ATTR_CURRENT_TEMPERATURE: 24.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_heating_thresh_temp.value == 19.0 + assert acc.char_cooling_thresh_temp.value == 23.0 + assert acc.char_current_heat_cool.value == 2 + assert acc.char_target_heat_cool.value == 3 + assert acc.char_current_temp.value == 24.0 + assert acc.char_display_units.value == 0 + + hass.states.async_set(entity_id, STATE_AUTO, + {ATTR_OPERATION_MODE: STATE_AUTO, + ATTR_TARGET_TEMP_HIGH: 23.0, + ATTR_TARGET_TEMP_LOW: 19.0, + ATTR_CURRENT_TEMPERATURE: 21.0, + ATTR_UNIT_OF_MEASUREMENT: TEMP_CELSIUS}) + await hass.async_block_till_done() + assert acc.char_heating_thresh_temp.value == 19.0 + assert acc.char_cooling_thresh_temp.value == 23.0 + assert acc.char_current_heat_cool.value == 0 + assert acc.char_target_heat_cool.value == 3 + assert acc.char_current_temp.value == 21.0 + assert acc.char_display_units.value == 0 + + # Set from HomeKit + call_set_temperature = async_mock_service(hass, DOMAIN, 'set_temperature') + + await hass.async_add_job( + acc.char_heating_thresh_temp.client_update_value, 20.0) + await hass.async_block_till_done() + assert call_set_temperature[0] + assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == 20.0 + assert acc.char_heating_thresh_temp.value == 20.0 + + await hass.async_add_job( + acc.char_cooling_thresh_temp.client_update_value, 25.0) + await hass.async_block_till_done() + assert call_set_temperature[1] + assert call_set_temperature[1].data[ATTR_ENTITY_ID] == entity_id + assert call_set_temperature[1].data[ATTR_TARGET_TEMP_HIGH] == 25.0 + assert acc.char_cooling_thresh_temp.value == 25.0 + + +async def test_power_state(hass, hk_driver, cls): + """Test if accessory and HA are updated accordingly.""" + entity_id = 'climate.test' + + # SUPPORT_ON_OFF = True + hass.states.async_set(entity_id, STATE_HEAT, + {ATTR_SUPPORTED_FEATURES: 4096, + ATTR_OPERATION_MODE: STATE_HEAT, + ATTR_TEMPERATURE: 23.0, + ATTR_CURRENT_TEMPERATURE: 18.0}) + await hass.async_block_till_done() + acc = cls.thermostat(hass, hk_driver, 'Climate', entity_id, 2, None) + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + assert acc.support_power_state is True + + assert acc.char_current_heat_cool.value == 1 + assert acc.char_target_heat_cool.value == 1 + + hass.states.async_set(entity_id, STATE_OFF, + {ATTR_OPERATION_MODE: STATE_HEAT, + ATTR_TEMPERATURE: 23.0, + ATTR_CURRENT_TEMPERATURE: 18.0}) + await hass.async_block_till_done() + assert acc.char_current_heat_cool.value == 0 + assert acc.char_target_heat_cool.value == 0 + + hass.states.async_set(entity_id, STATE_OFF, + {ATTR_OPERATION_MODE: STATE_OFF, + ATTR_TEMPERATURE: 23.0, + ATTR_CURRENT_TEMPERATURE: 18.0}) + await hass.async_block_till_done() + assert acc.char_current_heat_cool.value == 0 + assert acc.char_target_heat_cool.value == 0 + + # Set from HomeKit + call_turn_on = async_mock_service(hass, DOMAIN, 'turn_on') + call_turn_off = async_mock_service(hass, DOMAIN, 'turn_off') + call_set_operation_mode = async_mock_service(hass, DOMAIN, + 'set_operation_mode') + + await hass.async_add_job(acc.char_target_heat_cool.client_update_value, 1) + await hass.async_block_till_done() + assert call_turn_on + assert call_turn_on[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_operation_mode + assert call_set_operation_mode[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_operation_mode[0].data[ATTR_OPERATION_MODE] == STATE_HEAT + assert acc.char_target_heat_cool.value == 1 + + await hass.async_add_job(acc.char_target_heat_cool.client_update_value, 0) + await hass.async_block_till_done() + assert call_turn_off + assert call_turn_off[0].data[ATTR_ENTITY_ID] == entity_id + assert acc.char_target_heat_cool.value == 0 + + +async def test_thermostat_fahrenheit(hass, hk_driver, cls): + """Test if accessory and HA are updated accordingly.""" + entity_id = 'climate.test' + + # support_auto = True + hass.states.async_set(entity_id, STATE_OFF, {ATTR_SUPPORTED_FEATURES: 6}) + await hass.async_block_till_done() + with patch.object(hass.config.units, CONF_TEMPERATURE_UNIT, + new=TEMP_FAHRENHEIT): + acc = cls.thermostat(hass, hk_driver, 'Climate', entity_id, 2, None) + await hass.async_add_job(acc.run) + await hass.async_block_till_done() + + hass.states.async_set(entity_id, STATE_AUTO, + {ATTR_OPERATION_MODE: STATE_AUTO, + ATTR_TARGET_TEMP_HIGH: 75.2, + ATTR_TARGET_TEMP_LOW: 68, + ATTR_TEMPERATURE: 71.6, + ATTR_CURRENT_TEMPERATURE: 73.4, + ATTR_UNIT_OF_MEASUREMENT: TEMP_FAHRENHEIT}) + await hass.async_block_till_done() + assert acc.char_heating_thresh_temp.value == 20.0 + assert acc.char_cooling_thresh_temp.value == 24.0 + assert acc.char_current_temp.value == 23.0 + assert acc.char_target_temp.value == 22.0 + assert acc.char_display_units.value == 1 + + # Set from HomeKit + call_set_temperature = async_mock_service(hass, DOMAIN, 'set_temperature') + + await hass.async_add_job( + acc.char_cooling_thresh_temp.client_update_value, 23) + await hass.async_block_till_done() + assert call_set_temperature[0] + assert call_set_temperature[0].data[ATTR_ENTITY_ID] == entity_id + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_HIGH] == 73.4 + assert call_set_temperature[0].data[ATTR_TARGET_TEMP_LOW] == 68 + + await hass.async_add_job( + acc.char_heating_thresh_temp.client_update_value, 22) + await hass.async_block_till_done() + assert call_set_temperature[1] + assert call_set_temperature[1].data[ATTR_ENTITY_ID] == entity_id + assert call_set_temperature[1].data[ATTR_TARGET_TEMP_HIGH] == 73.4 + assert call_set_temperature[1].data[ATTR_TARGET_TEMP_LOW] == 71.6 + + await hass.async_add_job(acc.char_target_temp.client_update_value, 24.0) + await hass.async_block_till_done() + assert call_set_temperature[2] + assert call_set_temperature[2].data[ATTR_ENTITY_ID] == entity_id + assert call_set_temperature[2].data[ATTR_TEMPERATURE] == 75.2 + + +async def test_get_temperature_range(hass, hk_driver, cls): + """Test if temperature range is evaluated correctly.""" + entity_id = 'climate.test' + + hass.states.async_set(entity_id, STATE_OFF) + await hass.async_block_till_done() + acc = cls.thermostat(hass, hk_driver, 'Climate', entity_id, 2, None) + + hass.states.async_set(entity_id, STATE_OFF, + {ATTR_MIN_TEMP: 20, ATTR_MAX_TEMP: 25}) + await hass.async_block_till_done() + assert acc.get_temperature_range() == (20, 25) + + acc._unit = TEMP_FAHRENHEIT + hass.states.async_set(entity_id, STATE_OFF, + {ATTR_MIN_TEMP: 60, ATTR_MAX_TEMP: 70}) + await hass.async_block_till_done() + assert acc.get_temperature_range() == (15.6, 21.1) diff --git a/tests/components/homekit/test_util.py b/tests/components/homekit/test_util.py index 4a9521384bd553..9be92b817be19d 100644 --- a/tests/components/homekit/test_util.py +++ b/tests/components/homekit/test_util.py @@ -1,41 +1,79 @@ """Test HomeKit util module.""" -import unittest - -import voluptuous as vol import pytest +import voluptuous as vol -from homeassistant.core import callback -from homeassistant.components.homekit.accessories import HomeBridge -from homeassistant.components.homekit.const import HOMEKIT_NOTIFY_ID +from homeassistant.components.homekit.const import ( + CONF_FEATURE, CONF_FEATURE_LIST, HOMEKIT_NOTIFY_ID, FEATURE_ON_OFF, + FEATURE_PLAY_PAUSE, TYPE_OUTLET) from homeassistant.components.homekit.util import ( - show_setup_message, dismiss_setup_message, convert_to_float, - temperature_to_homekit, temperature_to_states, ATTR_CODE, - density_to_air_quality) + convert_to_float, density_to_air_quality, dismiss_setup_message, + show_setup_message, temperature_to_homekit, temperature_to_states, + validate_media_player_features) from homeassistant.components.homekit.util import validate_entity_config \ as vec from homeassistant.components.persistent_notification import ( - SERVICE_CREATE, SERVICE_DISMISS, ATTR_NOTIFICATION_ID) + ATTR_MESSAGE, ATTR_NOTIFICATION_ID, DOMAIN) from homeassistant.const import ( - EVENT_CALL_SERVICE, ATTR_DOMAIN, ATTR_SERVICE, ATTR_SERVICE_DATA, - TEMP_CELSIUS, TEMP_FAHRENHEIT, STATE_UNKNOWN) + ATTR_CODE, ATTR_SUPPORTED_FEATURES, CONF_NAME, CONF_TYPE, STATE_UNKNOWN, + TEMP_CELSIUS, TEMP_FAHRENHEIT) +from homeassistant.core import State -from tests.common import get_test_home_assistant +from tests.common import async_mock_service def test_validate_entity_config(): """Test validate entities.""" configs = [{'invalid_entity_id': {}}, {'demo.test': 1}, {'demo.test': 'test'}, {'demo.test': [1, 2]}, - {'demo.test': None}] + {'demo.test': None}, {'demo.test': {CONF_NAME: None}}, + {'media_player.test': {CONF_FEATURE_LIST: [ + {CONF_FEATURE: 'invalid_feature'}]}}, + {'media_player.test': {CONF_FEATURE_LIST: [ + {CONF_FEATURE: FEATURE_ON_OFF}, + {CONF_FEATURE: FEATURE_ON_OFF}]}}, + {'switch.test': {CONF_TYPE: 'invalid_type'}}] for conf in configs: with pytest.raises(vol.Invalid): vec(conf) assert vec({}) == {} + assert vec({'demo.test': {CONF_NAME: 'Name'}}) == \ + {'demo.test': {CONF_NAME: 'Name'}} + + assert vec({'alarm_control_panel.demo': {}}) == \ + {'alarm_control_panel.demo': {ATTR_CODE: None}} assert vec({'alarm_control_panel.demo': {ATTR_CODE: '1234'}}) == \ {'alarm_control_panel.demo': {ATTR_CODE: '1234'}} + assert vec({'lock.demo': {}}) == {'lock.demo': {ATTR_CODE: None}} + assert vec({'lock.demo': {ATTR_CODE: '1234'}}) == \ + {'lock.demo': {ATTR_CODE: '1234'}} + + assert vec({'media_player.demo': {}}) == \ + {'media_player.demo': {CONF_FEATURE_LIST: {}}} + config = {CONF_FEATURE_LIST: [{CONF_FEATURE: FEATURE_ON_OFF}, + {CONF_FEATURE: FEATURE_PLAY_PAUSE}]} + assert vec({'media_player.demo': config}) == \ + {'media_player.demo': {CONF_FEATURE_LIST: + {FEATURE_ON_OFF: {}, FEATURE_PLAY_PAUSE: {}}}} + assert vec({'switch.demo': {CONF_TYPE: TYPE_OUTLET}}) == \ + {'switch.demo': {CONF_TYPE: TYPE_OUTLET}} + + +def test_validate_media_player_features(): + """Test validate modes for media players.""" + config = {} + attrs = {ATTR_SUPPORTED_FEATURES: 20873} + entity_state = State('media_player.demo', 'on', attrs) + assert validate_media_player_features(entity_state, config) is True + + config = {FEATURE_ON_OFF: None} + assert validate_media_player_features(entity_state, config) is True + + entity_state = State('media_player.demo', 'on') + assert validate_media_player_features(entity_state, config) is False + def test_convert_to_float(): """Test convert_to_float method.""" @@ -68,51 +106,28 @@ def test_density_to_air_quality(): assert density_to_air_quality(300) == 5 -class TestUtil(unittest.TestCase): - """Test all HomeKit util methods.""" - - def setUp(self): - """Setup things to be run when tests are started.""" - self.hass = get_test_home_assistant() - self.events = [] - - @callback - def record_event(event): - """Track called event.""" - self.events.append(event) - - self.hass.bus.listen(EVENT_CALL_SERVICE, record_event) - - def tearDown(self): - """Stop down everything that was started.""" - self.hass.stop() - - def test_show_setup_msg(self): - """Test show setup message as persistence notification.""" - bridge = HomeBridge(self.hass) - - show_setup_message(self.hass, bridge) - self.hass.block_till_done() - - data = self.events[0].data - self.assertEqual( - data.get(ATTR_DOMAIN, None), 'persistent_notification') - self.assertEqual(data.get(ATTR_SERVICE, None), SERVICE_CREATE) - self.assertNotEqual(data.get(ATTR_SERVICE_DATA, None), None) - self.assertEqual( - data[ATTR_SERVICE_DATA].get(ATTR_NOTIFICATION_ID, None), - HOMEKIT_NOTIFY_ID) - - def test_dismiss_setup_msg(self): - """Test dismiss setup message.""" - dismiss_setup_message(self.hass) - self.hass.block_till_done() - - data = self.events[0].data - self.assertEqual( - data.get(ATTR_DOMAIN, None), 'persistent_notification') - self.assertEqual(data.get(ATTR_SERVICE, None), SERVICE_DISMISS) - self.assertNotEqual(data.get(ATTR_SERVICE_DATA, None), None) - self.assertEqual( - data[ATTR_SERVICE_DATA].get(ATTR_NOTIFICATION_ID, None), - HOMEKIT_NOTIFY_ID) +async def test_show_setup_msg(hass): + """Test show setup message as persistence notification.""" + pincode = b'123-45-678' + + call_create_notification = async_mock_service(hass, DOMAIN, 'create') + + await hass.async_add_job(show_setup_message, hass, pincode) + await hass.async_block_till_done() + + assert call_create_notification + assert call_create_notification[0].data[ATTR_NOTIFICATION_ID] == \ + HOMEKIT_NOTIFY_ID + assert pincode.decode() in call_create_notification[0].data[ATTR_MESSAGE] + + +async def test_dismiss_setup_msg(hass): + """Test dismiss setup message.""" + call_dismiss_notification = async_mock_service(hass, DOMAIN, 'dismiss') + + await hass.async_add_job(dismiss_setup_message, hass) + await hass.async_block_till_done() + + assert call_dismiss_notification + assert call_dismiss_notification[0].data[ATTR_NOTIFICATION_ID] == \ + HOMEKIT_NOTIFY_ID diff --git a/tests/components/homematicip_cloud/__init__.py b/tests/components/homematicip_cloud/__init__.py new file mode 100644 index 00000000000000..1d89bd73183c91 --- /dev/null +++ b/tests/components/homematicip_cloud/__init__.py @@ -0,0 +1 @@ +"""Tests for the HomematicIP Cloud component.""" diff --git a/tests/components/homematicip_cloud/test_config_flow.py b/tests/components/homematicip_cloud/test_config_flow.py new file mode 100644 index 00000000000000..1c2e54a1a5dfb8 --- /dev/null +++ b/tests/components/homematicip_cloud/test_config_flow.py @@ -0,0 +1,150 @@ +"""Tests for HomematicIP Cloud config flow.""" +from unittest.mock import patch + +from homeassistant.components.homematicip_cloud import hap as hmipc +from homeassistant.components.homematicip_cloud import config_flow, const + +from tests.common import MockConfigEntry, mock_coro + + +async def test_flow_works(hass): + """Test config flow works.""" + config = { + const.HMIPC_HAPID: 'ABC123', + const.HMIPC_PIN: '123', + const.HMIPC_NAME: 'hmip', + } + flow = config_flow.HomematicipCloudFlowHandler() + flow.hass = hass + + hap = hmipc.HomematicipAuth(hass, config) + with patch.object(hap, 'get_auth', return_value=mock_coro()), \ + patch.object(hmipc.HomematicipAuth, 'async_checkbutton', + return_value=mock_coro(True)), \ + patch.object(hmipc.HomematicipAuth, 'async_register', + return_value=mock_coro(True)): + hap.authtoken = 'ABC' + result = await flow.async_step_init(user_input=config) + + assert hap.authtoken == 'ABC' + assert result['type'] == 'create_entry' + + +async def test_flow_init_connection_error(hass): + """Test config flow with accesspoint connection error.""" + config = { + const.HMIPC_HAPID: 'ABC123', + const.HMIPC_PIN: '123', + const.HMIPC_NAME: 'hmip', + } + flow = config_flow.HomematicipCloudFlowHandler() + flow.hass = hass + + with patch.object(hmipc.HomematicipAuth, 'async_setup', + return_value=mock_coro(False)): + result = await flow.async_step_init(user_input=config) + assert result['type'] == 'form' + + +async def test_flow_link_connection_error(hass): + """Test config flow client registration connection error.""" + config = { + const.HMIPC_HAPID: 'ABC123', + const.HMIPC_PIN: '123', + const.HMIPC_NAME: 'hmip', + } + flow = config_flow.HomematicipCloudFlowHandler() + flow.hass = hass + + with patch.object(hmipc.HomematicipAuth, 'async_setup', + return_value=mock_coro(True)), \ + patch.object(hmipc.HomematicipAuth, 'async_checkbutton', + return_value=mock_coro(True)), \ + patch.object(hmipc.HomematicipAuth, 'async_register', + return_value=mock_coro(False)): + result = await flow.async_step_init(user_input=config) + assert result['type'] == 'abort' + + +async def test_flow_link_press_button(hass): + """Test config flow ask for pressing the blue button.""" + config = { + const.HMIPC_HAPID: 'ABC123', + const.HMIPC_PIN: '123', + const.HMIPC_NAME: 'hmip', + } + flow = config_flow.HomematicipCloudFlowHandler() + flow.hass = hass + + with patch.object(hmipc.HomematicipAuth, 'async_setup', + return_value=mock_coro(True)), \ + patch.object(hmipc.HomematicipAuth, 'async_checkbutton', + return_value=mock_coro(False)): + result = await flow.async_step_init(user_input=config) + assert result['type'] == 'form' + assert result['errors'] == {'base': 'press_the_button'} + + +async def test_init_flow_show_form(hass): + """Test config flow shows up with a form.""" + flow = config_flow.HomematicipCloudFlowHandler() + flow.hass = hass + + result = await flow.async_step_init(user_input=None) + assert result['type'] == 'form' + + +async def test_init_already_configured(hass): + """Test accesspoint is already configured.""" + MockConfigEntry(domain=const.DOMAIN, data={ + const.HMIPC_HAPID: 'ABC123', + }).add_to_hass(hass) + config = { + const.HMIPC_HAPID: 'ABC123', + const.HMIPC_PIN: '123', + const.HMIPC_NAME: 'hmip', + } + + flow = config_flow.HomematicipCloudFlowHandler() + flow.hass = hass + + result = await flow.async_step_init(user_input=config) + assert result['type'] == 'abort' + + +async def test_import_config(hass): + """Test importing a host with an existing config file.""" + flow = config_flow.HomematicipCloudFlowHandler() + flow.hass = hass + + result = await flow.async_step_import({ + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip' + }) + + assert result['type'] == 'create_entry' + assert result['title'] == 'ABC123' + assert result['data'] == { + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip' + } + + +async def test_import_existing_config(hass): + """Test abort of an existing accesspoint from config.""" + flow = config_flow.HomematicipCloudFlowHandler() + flow.hass = hass + + MockConfigEntry(domain=const.DOMAIN, data={ + hmipc.HMIPC_HAPID: 'ABC123', + }).add_to_hass(hass) + + result = await flow.async_step_import({ + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip' + }) + + assert result['type'] == 'abort' diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py new file mode 100644 index 00000000000000..476bed368d7967 --- /dev/null +++ b/tests/components/homematicip_cloud/test_hap.py @@ -0,0 +1,115 @@ +"""Test HomematicIP Cloud accesspoint.""" +from unittest.mock import Mock, patch + +from homeassistant.components.homematicip_cloud import hap as hmipc +from homeassistant.components.homematicip_cloud import const, errors +from tests.common import mock_coro + + +async def test_auth_setup(hass): + """Test auth setup for client registration.""" + config = { + const.HMIPC_HAPID: 'ABC123', + const.HMIPC_PIN: '123', + const.HMIPC_NAME: 'hmip', + } + hap = hmipc.HomematicipAuth(hass, config) + with patch.object(hap, 'get_auth', return_value=mock_coro()): + assert await hap.async_setup() is True + + +async def test_auth_setup_connection_error(hass): + """Test auth setup connection error behaviour.""" + config = { + const.HMIPC_HAPID: 'ABC123', + const.HMIPC_PIN: '123', + const.HMIPC_NAME: 'hmip', + } + hap = hmipc.HomematicipAuth(hass, config) + with patch.object(hap, 'get_auth', + side_effect=errors.HmipcConnectionError): + assert await hap.async_setup() is False + + +async def test_auth_auth_check_and_register(hass): + """Test auth client registration.""" + config = { + const.HMIPC_HAPID: 'ABC123', + const.HMIPC_PIN: '123', + const.HMIPC_NAME: 'hmip', + } + hap = hmipc.HomematicipAuth(hass, config) + hap.auth = Mock() + with patch.object(hap.auth, 'isRequestAcknowledged', + return_value=mock_coro()), \ + patch.object(hap.auth, 'requestAuthToken', + return_value=mock_coro('ABC')), \ + patch.object(hap.auth, 'confirmAuthToken', + return_value=mock_coro()): + assert await hap.async_checkbutton() is True + assert await hap.async_register() == 'ABC' + + +async def test_hap_setup_works(aioclient_mock): + """Test a successful setup of a accesspoint.""" + hass = Mock() + entry = Mock() + home = Mock() + entry.data = { + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip', + } + hap = hmipc.HomematicipHAP(hass, entry) + with patch.object(hap, 'get_hap', return_value=mock_coro(home)): + assert await hap.async_setup() is True + + assert hap.home is home + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 6 + assert hass.config_entries.async_forward_entry_setup.mock_calls[0][1] == \ + (entry, 'alarm_control_panel') + assert hass.config_entries.async_forward_entry_setup.mock_calls[1][1] == \ + (entry, 'binary_sensor') + + +async def test_hap_setup_connection_error(): + """Test a failed accesspoint setup.""" + hass = Mock() + entry = Mock() + entry.data = { + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip', + } + hap = hmipc.HomematicipHAP(hass, entry) + with patch.object(hap, 'get_hap', + side_effect=errors.HmipcConnectionError): + assert await hap.async_setup() is False + + assert len(hass.async_add_job.mock_calls) == 0 + assert len(hass.config_entries.flow.async_init.mock_calls) == 0 + + +async def test_hap_reset_unloads_entry_if_setup(): + """Test calling reset while the entry has been setup.""" + hass = Mock() + entry = Mock() + home = Mock() + entry.data = { + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip', + } + hap = hmipc.HomematicipHAP(hass, entry) + with patch.object(hap, 'get_hap', return_value=mock_coro(home)): + assert await hap.async_setup() is True + + assert hap.home is home + assert len(hass.services.async_register.mock_calls) == 0 + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 6 + + hass.config_entries.async_forward_entry_unload.return_value = \ + mock_coro(True) + await hap.async_reset() + + assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 6 diff --git a/tests/components/homematicip_cloud/test_init.py b/tests/components/homematicip_cloud/test_init.py new file mode 100644 index 00000000000000..185372272471bd --- /dev/null +++ b/tests/components/homematicip_cloud/test_init.py @@ -0,0 +1,103 @@ +"""Test HomematicIP Cloud setup process.""" + +from unittest.mock import patch + +from homeassistant.setup import async_setup_component +from homeassistant.components import homematicip_cloud as hmipc + +from tests.common import mock_coro, MockConfigEntry + + +async def test_config_with_accesspoint_passed_to_config_entry(hass): + """Test that config for a accesspoint are loaded via config entry.""" + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(hmipc, 'configured_haps', return_value=[]): + assert await async_setup_component(hass, hmipc.DOMAIN, { + hmipc.DOMAIN: { + hmipc.CONF_ACCESSPOINT: 'ABC123', + hmipc.CONF_AUTHTOKEN: '123', + hmipc.CONF_NAME: 'name', + } + }) is True + + # Flow started for the access point + assert len(mock_config_entries.flow.mock_calls) == 2 + + +async def test_config_already_registered_not_passed_to_config_entry(hass): + """Test that an already registered accesspoint does not get imported.""" + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(hmipc, 'configured_haps', return_value=['ABC123']): + assert await async_setup_component(hass, hmipc.DOMAIN, { + hmipc.DOMAIN: { + hmipc.CONF_ACCESSPOINT: 'ABC123', + hmipc.CONF_AUTHTOKEN: '123', + hmipc.CONF_NAME: 'name', + } + }) is True + + # No flow started + assert len(mock_config_entries.flow.mock_calls) == 0 + + +async def test_setup_entry_successful(hass): + """Test setup entry is successful.""" + entry = MockConfigEntry(domain=hmipc.DOMAIN, data={ + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip', + }) + entry.add_to_hass(hass) + with patch.object(hmipc, 'HomematicipHAP') as mock_hap: + mock_hap.return_value.async_setup.return_value = mock_coro(True) + assert await async_setup_component(hass, hmipc.DOMAIN, { + hmipc.DOMAIN: { + hmipc.CONF_ACCESSPOINT: 'ABC123', + hmipc.CONF_AUTHTOKEN: '123', + hmipc.CONF_NAME: 'hmip', + } + }) is True + + assert len(mock_hap.mock_calls) == 2 + + +async def test_setup_defined_accesspoint(hass): + """Test we initiate config entry for the accesspoint.""" + with patch.object(hass, 'config_entries') as mock_config_entries, \ + patch.object(hmipc, 'configured_haps', return_value=[]): + mock_config_entries.flow.async_init.return_value = mock_coro() + assert await async_setup_component(hass, hmipc.DOMAIN, { + hmipc.DOMAIN: { + hmipc.CONF_ACCESSPOINT: 'ABC123', + hmipc.CONF_AUTHTOKEN: '123', + hmipc.CONF_NAME: 'hmip', + } + }) is True + + assert len(mock_config_entries.flow.mock_calls) == 1 + assert mock_config_entries.flow.mock_calls[0][2]['data'] == { + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip', + } + + +async def test_unload_entry(hass): + """Test being able to unload an entry.""" + entry = MockConfigEntry(domain=hmipc.DOMAIN, data={ + hmipc.HMIPC_HAPID: 'ABC123', + hmipc.HMIPC_AUTHTOKEN: '123', + hmipc.HMIPC_NAME: 'hmip', + }) + entry.add_to_hass(hass) + + with patch.object(hmipc, 'HomematicipHAP') as mock_hap: + mock_hap.return_value.async_setup.return_value = mock_coro(True) + assert await async_setup_component(hass, hmipc.DOMAIN, {}) is True + + assert len(mock_hap.return_value.mock_calls) == 1 + + mock_hap.return_value.async_reset.return_value = mock_coro(True) + assert await hmipc.async_unload_entry(hass, entry) + assert len(mock_hap.return_value.async_reset.mock_calls) == 1 + assert hass.data[hmipc.DOMAIN] == {} diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index a44d17d513db98..8e7a62e2e9faa5 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -3,18 +3,18 @@ from ipaddress import ip_network from unittest.mock import patch +import pytest from aiohttp import BasicAuth, web from aiohttp.web_exceptions import HTTPUnauthorized -import pytest -from homeassistant.const import HTTP_HEADER_HA_AUTH -from homeassistant.setup import async_setup_component from homeassistant.components.http.auth import setup_auth -from homeassistant.components.http.real_ip import setup_real_ip from homeassistant.components.http.const import KEY_AUTHENTICATED - +from homeassistant.components.http.real_ip import setup_real_ip +from homeassistant.const import HTTP_HEADER_HA_AUTH +from homeassistant.setup import async_setup_component from . import mock_real_ip + API_PASSWORD = 'test1234' # Don't add 127.0.0.1/::1 as trusted, as it may interfere with other test cases @@ -37,11 +37,21 @@ async def mock_handler(request): @pytest.fixture -def app(): +def app(hass): """Fixture to setup a web.Application.""" app = web.Application() + app['hass'] = hass + app.router.add_get('/', mock_handler) + setup_real_ip(app, False, []) + return app + + +@pytest.fixture +def app2(hass): + """Fixture to setup a web.Application without real_ip middleware.""" + app = web.Application() + app['hass'] = hass app.router.add_get('/', mock_handler) - setup_real_ip(app, False) return app @@ -57,7 +67,7 @@ async def test_auth_middleware_loaded_by_default(hass): async def test_access_without_password(app, aiohttp_client): """Test access without password.""" - setup_auth(app, [], None) + setup_auth(app, [], False, api_password=None) client = await aiohttp_client(app) resp = await client.get('/') @@ -65,8 +75,8 @@ async def test_access_without_password(app, aiohttp_client): async def test_access_with_password_in_header(app, aiohttp_client): - """Test access with password in URL.""" - setup_auth(app, [], API_PASSWORD) + """Test access with password in header.""" + setup_auth(app, [], False, api_password=API_PASSWORD) client = await aiohttp_client(app) req = await client.get( @@ -79,8 +89,8 @@ async def test_access_with_password_in_header(app, aiohttp_client): async def test_access_with_password_in_query(app, aiohttp_client): - """Test access without password.""" - setup_auth(app, [], API_PASSWORD) + """Test access with password in URL.""" + setup_auth(app, [], False, api_password=API_PASSWORD) client = await aiohttp_client(app) resp = await client.get('/', params={ @@ -99,7 +109,7 @@ async def test_access_with_password_in_query(app, aiohttp_client): async def test_basic_auth_works(app, aiohttp_client): """Test access with basic authentication.""" - setup_auth(app, [], API_PASSWORD) + setup_auth(app, [], False, api_password=API_PASSWORD) client = await aiohttp_client(app) req = await client.get( @@ -125,16 +135,68 @@ async def test_basic_auth_works(app, aiohttp_client): assert req.status == 401 -async def test_access_with_trusted_ip(aiohttp_client): +async def test_access_with_trusted_ip(app2, aiohttp_client): """Test access with an untrusted ip address.""" - app = web.Application() - app.router.add_get('/', mock_handler) + setup_auth(app2, TRUSTED_NETWORKS, False, api_password='some-pass') - setup_auth(app, TRUSTED_NETWORKS, 'some-pass') + set_mock_ip = mock_real_ip(app2) + client = await aiohttp_client(app2) - set_mock_ip = mock_real_ip(app) + for remote_addr in UNTRUSTED_ADDRESSES: + set_mock_ip(remote_addr) + resp = await client.get('/') + assert resp.status == 401, \ + "{} shouldn't be trusted".format(remote_addr) + + for remote_addr in TRUSTED_ADDRESSES: + set_mock_ip(remote_addr) + resp = await client.get('/') + assert resp.status == 200, \ + "{} should be trusted".format(remote_addr) + + +async def test_auth_active_access_with_access_token_in_header( + hass, app, aiohttp_client, hass_access_token): + """Test access with access token in header.""" + token = hass_access_token + setup_auth(app, [], True, api_password=None) client = await aiohttp_client(app) + req = await client.get( + '/', headers={'Authorization': 'Bearer {}'.format(token)}) + assert req.status == 200 + + req = await client.get( + '/', headers={'AUTHORIZATION': 'Bearer {}'.format(token)}) + assert req.status == 200 + + req = await client.get( + '/', headers={'authorization': 'Bearer {}'.format(token)}) + assert req.status == 200 + + req = await client.get( + '/', headers={'Authorization': token}) + assert req.status == 401 + + req = await client.get( + '/', headers={'Authorization': 'BEARER {}'.format(token)}) + assert req.status == 401 + + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + refresh_token.user.is_active = False + req = await client.get( + '/', headers={'Authorization': 'Bearer {}'.format(token)}) + assert req.status == 401 + + +async def test_auth_active_access_with_trusted_ip(app2, aiohttp_client): + """Test access with an untrusted ip address.""" + setup_auth(app2, TRUSTED_NETWORKS, True, api_password=None) + + set_mock_ip = mock_real_ip(app2) + client = await aiohttp_client(app2) + for remote_addr in UNTRUSTED_ADDRESSES: set_mock_ip(remote_addr) resp = await client.get('/') @@ -146,3 +208,43 @@ async def test_access_with_trusted_ip(aiohttp_client): resp = await client.get('/') assert resp.status == 200, \ "{} should be trusted".format(remote_addr) + + +async def test_auth_active_blocked_api_password_access(app, aiohttp_client): + """Test access using api_password should be blocked when auth.active.""" + setup_auth(app, [], True, api_password=API_PASSWORD) + client = await aiohttp_client(app) + + req = await client.get( + '/', headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) + assert req.status == 401 + + resp = await client.get('/', params={ + 'api_password': API_PASSWORD + }) + assert resp.status == 401 + + req = await client.get( + '/', + auth=BasicAuth('homeassistant', API_PASSWORD)) + assert req.status == 401 + + +async def test_auth_legacy_support_api_password_access(app, aiohttp_client): + """Test access using api_password if auth.support_legacy.""" + setup_auth(app, [], True, support_legacy=True, api_password=API_PASSWORD) + client = await aiohttp_client(app) + + req = await client.get( + '/', headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) + assert req.status == 200 + + resp = await client.get('/', params={ + 'api_password': API_PASSWORD + }) + assert resp.status == 200 + + req = await client.get( + '/', + auth=BasicAuth('homeassistant', API_PASSWORD)) + assert req.status == 200 diff --git a/tests/components/http/test_ban.py b/tests/components/http/test_ban.py index c5691cf3e2ac27..a6a07928113b34 100644 --- a/tests/components/http/test_ban.py +++ b/tests/components/http/test_ban.py @@ -1,14 +1,18 @@ """The tests for the Home Assistant HTTP component.""" # pylint: disable=protected-access -from unittest.mock import patch, mock_open +from ipaddress import ip_address +from unittest.mock import patch, mock_open, Mock from aiohttp import web from aiohttp.web_exceptions import HTTPUnauthorized +from aiohttp.web_middlewares import middleware +from homeassistant.components.http import KEY_AUTHENTICATED +from homeassistant.components.http.view import request_handler_factory from homeassistant.setup import async_setup_component import homeassistant.components.http as http from homeassistant.components.http.ban import ( - IpBan, IP_BANS_FILE, setup_bans, KEY_BANNED_IPS) + IpBan, IP_BANS_FILE, setup_bans, KEY_BANNED_IPS, KEY_FAILED_LOGIN_ATTEMPTS) from . import mock_real_ip @@ -88,3 +92,53 @@ async def unauth_handler(request): resp = await client.get('/') assert resp.status == 403 assert m.call_count == 1 + + +async def test_failed_login_attempts_counter(hass, aiohttp_client): + """Testing if failed login attempts counter increased.""" + app = web.Application() + app['hass'] = hass + + async def auth_handler(request): + """Return 200 status code.""" + return None, 200 + + app.router.add_get('/auth_true', request_handler_factory( + Mock(requires_auth=True), auth_handler)) + app.router.add_get('/auth_false', request_handler_factory( + Mock(requires_auth=True), auth_handler)) + app.router.add_get('/', request_handler_factory( + Mock(requires_auth=False), auth_handler)) + + setup_bans(hass, app, 5) + remote_ip = ip_address("200.201.202.204") + mock_real_ip(app)("200.201.202.204") + + @middleware + async def mock_auth(request, handler): + """Mock auth middleware.""" + if 'auth_true' in request.path: + request[KEY_AUTHENTICATED] = True + else: + request[KEY_AUTHENTICATED] = False + return await handler(request) + + app.middlewares.append(mock_auth) + + client = await aiohttp_client(app) + + resp = await client.get('/auth_false') + assert resp.status == 401 + assert app[KEY_FAILED_LOGIN_ATTEMPTS][remote_ip] == 1 + + resp = await client.get('/auth_false') + assert resp.status == 401 + assert app[KEY_FAILED_LOGIN_ATTEMPTS][remote_ip] == 2 + + resp = await client.get('/') + assert resp.status == 200 + assert app[KEY_FAILED_LOGIN_ATTEMPTS][remote_ip] == 2 + + resp = await client.get('/auth_true') + assert resp.status == 200 + assert remote_ip not in app[KEY_FAILED_LOGIN_ATTEMPTS] diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py index 27367b4173e3f1..a510d2b3829e75 100644 --- a/tests/components/http/test_cors.py +++ b/tests/components/http/test_cors.py @@ -14,19 +14,20 @@ from homeassistant.const import HTTP_HEADER_HA_AUTH from homeassistant.setup import async_setup_component from homeassistant.components.http.cors import setup_cors +from homeassistant.components.http.view import HomeAssistantView TRUSTED_ORIGIN = 'https://home-assistant.io' -async def test_cors_middleware_not_loaded_by_default(hass): +async def test_cors_middleware_loaded_by_default(hass): """Test accessing to server from banned IP when feature is off.""" with patch('homeassistant.components.http.setup_cors') as mock_setup: await async_setup_component(hass, 'http', { 'http': {} }) - assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup.mock_calls) == 1 async def test_cors_middleware_loaded_from_config(hass): @@ -96,3 +97,34 @@ async def test_cors_preflight_allowed(client): assert req.headers[ACCESS_CONTROL_ALLOW_ORIGIN] == TRUSTED_ORIGIN assert req.headers[ACCESS_CONTROL_ALLOW_HEADERS] == \ HTTP_HEADER_HA_AUTH.upper() + + +async def test_cors_middleware_with_cors_allowed_view(hass): + """Test that we can configure cors and have a cors_allowed view.""" + class MyView(HomeAssistantView): + """Test view that allows CORS.""" + + requires_auth = False + cors_allowed = True + + def __init__(self, url, name): + """Initialize test view.""" + self.url = url + self.name = name + + async def get(self, request): + """Test response.""" + return "test" + + assert await async_setup_component(hass, 'http', { + 'http': { + 'cors_allowed_origins': ['http://home-assistant.io'] + } + }) + + hass.http.register_view(MyView('/api/test', 'api:test')) + hass.http.register_view(MyView('/api/test', 'api:test2')) + hass.http.register_view(MyView('/api/test2', 'api:test')) + + hass.http.app._on_startup.freeze() + await hass.http.app.startup() diff --git a/tests/components/http/test_data_validator.py b/tests/components/http/test_data_validator.py index 2b966daff6cf9a..b5eed19eb6136c 100644 --- a/tests/components/http/test_data_validator.py +++ b/tests/components/http/test_data_validator.py @@ -23,7 +23,7 @@ async def post(self, request, data): """Test method.""" return b'' - TestView().register(app.router) + TestView().register(app, app.router) client = await aiohttp_client(app) return client diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index d5368032a376bb..9f6441c52386f3 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -1,9 +1,13 @@ """The tests for the Home Assistant HTTP component.""" import logging +import unittest +from unittest.mock import patch from homeassistant.setup import async_setup_component import homeassistant.components.http as http +from homeassistant.util.ssl import ( + server_context_modern, server_context_intermediate) class TestView(http.HomeAssistantView): @@ -33,6 +37,50 @@ async def test_registering_view_while_running(hass, aiohttp_client, hass.http.register_view(TestView) +class TestApiConfig(unittest.TestCase): + """Test API configuration methods.""" + + def test_api_base_url_with_domain(hass): + """Test setting API URL with domain.""" + api_config = http.ApiConfig('example.com') + assert api_config.base_url == 'http://example.com:8123' + + def test_api_base_url_with_ip(hass): + """Test setting API URL with IP.""" + api_config = http.ApiConfig('1.1.1.1') + assert api_config.base_url == 'http://1.1.1.1:8123' + + def test_api_base_url_with_ip_and_port(hass): + """Test setting API URL with IP and port.""" + api_config = http.ApiConfig('1.1.1.1', 8124) + assert api_config.base_url == 'http://1.1.1.1:8124' + + def test_api_base_url_with_protocol(hass): + """Test setting API URL with protocol.""" + api_config = http.ApiConfig('https://example.com') + assert api_config.base_url == 'https://example.com:8123' + + def test_api_base_url_with_protocol_and_port(hass): + """Test setting API URL with protocol and port.""" + api_config = http.ApiConfig('https://example.com', 433) + assert api_config.base_url == 'https://example.com:433' + + def test_api_base_url_with_ssl_enable(hass): + """Test setting API URL with use_ssl enabled.""" + api_config = http.ApiConfig('example.com', use_ssl=True) + assert api_config.base_url == 'https://example.com:8123' + + def test_api_base_url_with_ssl_enable_and_port(hass): + """Test setting API URL with use_ssl enabled and port.""" + api_config = http.ApiConfig('1.1.1.1', use_ssl=True, port=8888) + assert api_config.base_url == 'https://1.1.1.1:8888' + + def test_api_base_url_with_protocol_and_ssl_enable(hass): + """Test setting API URL with specific protocol and use_ssl enabled.""" + api_config = http.ApiConfig('http://example.com', use_ssl=True) + assert api_config.base_url == 'http://example.com:8123' + + async def test_api_base_url_with_domain(hass): """Test setting API URL.""" result = await async_setup_component(hass, 'http', { @@ -96,3 +144,84 @@ async def test_not_log_password(hass, aiohttp_client, caplog): # Ensure we don't log API passwords assert '/api/' in logs assert 'some-pass' not in logs + + +async def test_proxy_config(hass): + """Test use_x_forwarded_for must config together with trusted_proxies.""" + assert await async_setup_component(hass, 'http', { + 'http': { + http.CONF_USE_X_FORWARDED_FOR: True, + http.CONF_TRUSTED_PROXIES: ['127.0.0.1'] + } + }) is True + + +async def test_proxy_config_only_use_xff(hass): + """Test use_x_forwarded_for must config together with trusted_proxies.""" + assert await async_setup_component(hass, 'http', { + 'http': { + http.CONF_USE_X_FORWARDED_FOR: True + } + }) is not True + + +async def test_proxy_config_only_trust_proxies(hass): + """Test use_x_forwarded_for must config together with trusted_proxies.""" + assert await async_setup_component(hass, 'http', { + 'http': { + http.CONF_TRUSTED_PROXIES: ['127.0.0.1'] + } + }) is not True + + +async def test_ssl_profile_defaults_modern(hass): + """Test default ssl profile.""" + assert await async_setup_component(hass, 'http', {}) is True + + hass.http.ssl_certificate = 'bla' + + with patch('ssl.SSLContext.load_cert_chain'), \ + patch('homeassistant.util.ssl.server_context_modern', + side_effect=server_context_modern) as mock_context: + await hass.async_start() + await hass.async_block_till_done() + + assert len(mock_context.mock_calls) == 1 + + +async def test_ssl_profile_change_intermediate(hass): + """Test setting ssl profile to intermediate.""" + assert await async_setup_component(hass, 'http', { + 'http': { + 'ssl_profile': 'intermediate' + } + }) is True + + hass.http.ssl_certificate = 'bla' + + with patch('ssl.SSLContext.load_cert_chain'), \ + patch('homeassistant.util.ssl.server_context_intermediate', + side_effect=server_context_intermediate) as mock_context: + await hass.async_start() + await hass.async_block_till_done() + + assert len(mock_context.mock_calls) == 1 + + +async def test_ssl_profile_change_modern(hass): + """Test setting ssl profile to modern.""" + assert await async_setup_component(hass, 'http', { + 'http': { + 'ssl_profile': 'modern' + } + }) is True + + hass.http.ssl_certificate = 'bla' + + with patch('ssl.SSLContext.load_cert_chain'), \ + patch('homeassistant.util.ssl.server_context_modern', + side_effect=server_context_modern) as mock_context: + await hass.async_start() + await hass.async_block_till_done() + + assert len(mock_context.mock_calls) == 1 diff --git a/tests/components/http/test_real_ip.py b/tests/components/http/test_real_ip.py index 61846eb94c242f..6cf6fec6bce994 100644 --- a/tests/components/http/test_real_ip.py +++ b/tests/components/http/test_real_ip.py @@ -1,6 +1,7 @@ """Test real IP middleware.""" from aiohttp import web from aiohttp.hdrs import X_FORWARDED_FOR +from ipaddress import ip_network from homeassistant.components.http.real_ip import setup_real_ip from homeassistant.components.http.const import KEY_REAL_IP @@ -15,7 +16,7 @@ async def test_ignore_x_forwarded_for(aiohttp_client): """Test that we get the IP from the transport.""" app = web.Application() app.router.add_get('/', mock_handler) - setup_real_ip(app, False) + setup_real_ip(app, False, []) mock_api_client = await aiohttp_client(app) @@ -27,11 +28,27 @@ async def test_ignore_x_forwarded_for(aiohttp_client): assert text != '255.255.255.255' -async def test_use_x_forwarded_for(aiohttp_client): +async def test_use_x_forwarded_for_without_trusted_proxy(aiohttp_client): """Test that we get the IP from the transport.""" app = web.Application() app.router.add_get('/', mock_handler) - setup_real_ip(app, True) + setup_real_ip(app, True, []) + + mock_api_client = await aiohttp_client(app) + + resp = await mock_api_client.get('/', headers={ + X_FORWARDED_FOR: '255.255.255.255' + }) + assert resp.status == 200 + text = await resp.text() + assert text != '255.255.255.255' + + +async def test_use_x_forwarded_for_with_trusted_proxy(aiohttp_client): + """Test that we get the IP from the transport.""" + app = web.Application() + app.router.add_get('/', mock_handler) + setup_real_ip(app, True, [ip_network('127.0.0.1')]) mock_api_client = await aiohttp_client(app) @@ -41,3 +58,51 @@ async def test_use_x_forwarded_for(aiohttp_client): assert resp.status == 200 text = await resp.text() assert text == '255.255.255.255' + + +async def test_use_x_forwarded_for_with_untrusted_proxy(aiohttp_client): + """Test that we get the IP from the transport.""" + app = web.Application() + app.router.add_get('/', mock_handler) + setup_real_ip(app, True, [ip_network('1.1.1.1')]) + + mock_api_client = await aiohttp_client(app) + + resp = await mock_api_client.get('/', headers={ + X_FORWARDED_FOR: '255.255.255.255' + }) + assert resp.status == 200 + text = await resp.text() + assert text != '255.255.255.255' + + +async def test_use_x_forwarded_for_with_spoofed_header(aiohttp_client): + """Test that we get the IP from the transport.""" + app = web.Application() + app.router.add_get('/', mock_handler) + setup_real_ip(app, True, [ip_network('127.0.0.1')]) + + mock_api_client = await aiohttp_client(app) + + resp = await mock_api_client.get('/', headers={ + X_FORWARDED_FOR: '222.222.222.222, 255.255.255.255' + }) + assert resp.status == 200 + text = await resp.text() + assert text == '255.255.255.255' + + +async def test_use_x_forwarded_for_with_nonsense_header(aiohttp_client): + """Test that we get the IP from the transport.""" + app = web.Application() + app.router.add_get('/', mock_handler) + setup_real_ip(app, True, [ip_network('127.0.0.1')]) + + mock_api_client = await aiohttp_client(app) + + resp = await mock_api_client.get('/', headers={ + X_FORWARDED_FOR: 'This value is invalid' + }) + assert resp.status == 200 + text = await resp.text() + assert text == '127.0.0.1' diff --git a/tests/components/image_processing/test_facebox.py b/tests/components/image_processing/test_facebox.py new file mode 100644 index 00000000000000..b1d9fb8bf79d30 --- /dev/null +++ b/tests/components/image_processing/test_facebox.py @@ -0,0 +1,309 @@ +"""The tests for the facebox component.""" +from unittest.mock import Mock, mock_open, patch + +import pytest +import requests +import requests_mock + +from homeassistant.core import callback +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_NAME, CONF_FRIENDLY_NAME, CONF_PASSWORD, + CONF_USERNAME, CONF_IP_ADDRESS, CONF_PORT, + HTTP_BAD_REQUEST, HTTP_OK, HTTP_UNAUTHORIZED, STATE_UNKNOWN) +from homeassistant.setup import async_setup_component +import homeassistant.components.image_processing as ip +import homeassistant.components.image_processing.facebox as fb + +MOCK_IP = '192.168.0.1' +MOCK_PORT = '8080' + +# Mock data returned by the facebox API. +MOCK_BOX_ID = 'b893cc4f7fd6' +MOCK_ERROR_NO_FACE = "No face found" +MOCK_FACE = {'confidence': 0.5812028911604818, + 'id': 'john.jpg', + 'matched': True, + 'name': 'John Lennon', + 'rect': {'height': 75, 'left': 63, 'top': 262, 'width': 74}} + +MOCK_FILE_PATH = '/images/mock.jpg' + +MOCK_HEALTH = {'success': True, + 'hostname': 'b893cc4f7fd6', + 'metadata': {'boxname': 'facebox', 'build': 'development'}, + 'errors': []} + +MOCK_JSON = {"facesCount": 1, + "success": True, + "faces": [MOCK_FACE]} + +MOCK_NAME = 'mock_name' +MOCK_USERNAME = 'mock_username' +MOCK_PASSWORD = 'mock_password' + +# Faces data after parsing. +PARSED_FACES = [{fb.FACEBOX_NAME: 'John Lennon', + fb.ATTR_IMAGE_ID: 'john.jpg', + fb.ATTR_CONFIDENCE: 58.12, + fb.ATTR_MATCHED: True, + fb.ATTR_BOUNDING_BOX: { + 'height': 75, + 'left': 63, + 'top': 262, + 'width': 74}}] + +MATCHED_FACES = {'John Lennon': 58.12} + +VALID_ENTITY_ID = 'image_processing.facebox_demo_camera' +VALID_CONFIG = { + ip.DOMAIN: { + 'platform': 'facebox', + CONF_IP_ADDRESS: MOCK_IP, + CONF_PORT: MOCK_PORT, + ip.CONF_SOURCE: { + ip.CONF_ENTITY_ID: 'camera.demo_camera'} + }, + 'camera': { + 'platform': 'demo' + } + } + + +@pytest.fixture +def mock_healthybox(): + """Mock fb.check_box_health.""" + check_box_health = 'homeassistant.components.image_processing.' \ + 'facebox.check_box_health' + with patch(check_box_health, return_value=MOCK_BOX_ID) as _mock_healthybox: + yield _mock_healthybox + + +@pytest.fixture +def mock_isfile(): + """Mock os.path.isfile.""" + with patch('homeassistant.components.image_processing.facebox.cv.isfile', + return_value=True) as _mock_isfile: + yield _mock_isfile + + +@pytest.fixture +def mock_image(): + """Return a mock camera image.""" + with patch('homeassistant.components.camera.demo.DemoCamera.camera_image', + return_value=b'Test') as image: + yield image + + +@pytest.fixture +def mock_open_file(): + """Mock open.""" + mopen = mock_open() + with patch('homeassistant.components.image_processing.facebox.open', + mopen, create=True) as _mock_open: + yield _mock_open + + +def test_check_box_health(caplog): + """Test check box health.""" + with requests_mock.Mocker() as mock_req: + url = "http://{}:{}/healthz".format(MOCK_IP, MOCK_PORT) + mock_req.get(url, status_code=HTTP_OK, json=MOCK_HEALTH) + assert fb.check_box_health(url, 'user', 'pass') == MOCK_BOX_ID + + mock_req.get(url, status_code=HTTP_UNAUTHORIZED) + assert fb.check_box_health(url, None, None) is None + assert "AuthenticationError on facebox" in caplog.text + + mock_req.get(url, exc=requests.exceptions.ConnectTimeout) + fb.check_box_health(url, None, None) + assert "ConnectionError: Is facebox running?" in caplog.text + + +def test_encode_image(): + """Test that binary data is encoded correctly.""" + assert fb.encode_image(b'test') == 'dGVzdA==' + + +def test_get_matched_faces(): + """Test that matched_faces are parsed correctly.""" + assert fb.get_matched_faces(PARSED_FACES) == MATCHED_FACES + + +def test_parse_faces(): + """Test parsing of raw face data, and generation of matched_faces.""" + assert fb.parse_faces(MOCK_JSON['faces']) == PARSED_FACES + + +@patch('os.access', Mock(return_value=False)) +def test_valid_file_path(): + """Test that an invalid file_path is caught.""" + assert not fb.valid_file_path('test_path') + + +async def test_setup_platform(hass, mock_healthybox): + """Setup platform with one entity.""" + await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) + assert hass.states.get(VALID_ENTITY_ID) + + +async def test_setup_platform_with_auth(hass, mock_healthybox): + """Setup platform with one entity and auth.""" + valid_config_auth = VALID_CONFIG.copy() + valid_config_auth[ip.DOMAIN][CONF_USERNAME] = MOCK_USERNAME + valid_config_auth[ip.DOMAIN][CONF_PASSWORD] = MOCK_PASSWORD + + await async_setup_component(hass, ip.DOMAIN, valid_config_auth) + assert hass.states.get(VALID_ENTITY_ID) + + +async def test_process_image(hass, mock_healthybox, mock_image): + """Test successful processing of an image.""" + await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) + assert hass.states.get(VALID_ENTITY_ID) + + face_events = [] + + @callback + def mock_face_event(event): + """Mock event.""" + face_events.append(event) + + hass.bus.async_listen('image_processing.detect_face', mock_face_event) + + with requests_mock.Mocker() as mock_req: + url = "http://{}:{}/facebox/check".format(MOCK_IP, MOCK_PORT) + mock_req.post(url, json=MOCK_JSON) + data = {ATTR_ENTITY_ID: VALID_ENTITY_ID} + await hass.services.async_call(ip.DOMAIN, + ip.SERVICE_SCAN, + service_data=data) + await hass.async_block_till_done() + + state = hass.states.get(VALID_ENTITY_ID) + assert state.state == '1' + assert state.attributes.get('matched_faces') == MATCHED_FACES + assert state.attributes.get('total_matched_faces') == 1 + + PARSED_FACES[0][ATTR_ENTITY_ID] = VALID_ENTITY_ID # Update. + assert state.attributes.get('faces') == PARSED_FACES + assert state.attributes.get(CONF_FRIENDLY_NAME) == 'facebox demo_camera' + + assert len(face_events) == 1 + assert face_events[0].data[ATTR_NAME] == PARSED_FACES[0][ATTR_NAME] + assert (face_events[0].data[fb.ATTR_CONFIDENCE] + == PARSED_FACES[0][fb.ATTR_CONFIDENCE]) + assert face_events[0].data[ATTR_ENTITY_ID] == VALID_ENTITY_ID + assert (face_events[0].data[fb.ATTR_IMAGE_ID] == + PARSED_FACES[0][fb.ATTR_IMAGE_ID]) + assert (face_events[0].data[fb.ATTR_BOUNDING_BOX] == + PARSED_FACES[0][fb.ATTR_BOUNDING_BOX]) + + +async def test_process_image_errors(hass, mock_healthybox, mock_image, caplog): + """Test process_image errors.""" + await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) + assert hass.states.get(VALID_ENTITY_ID) + + # Test connection error. + with requests_mock.Mocker() as mock_req: + url = "http://{}:{}/facebox/check".format(MOCK_IP, MOCK_PORT) + mock_req.register_uri( + 'POST', url, exc=requests.exceptions.ConnectTimeout) + data = {ATTR_ENTITY_ID: VALID_ENTITY_ID} + await hass.services.async_call(ip.DOMAIN, + ip.SERVICE_SCAN, + service_data=data) + await hass.async_block_till_done() + assert "ConnectionError: Is facebox running?" in caplog.text + + state = hass.states.get(VALID_ENTITY_ID) + assert state.state == STATE_UNKNOWN + assert state.attributes.get('faces') == [] + assert state.attributes.get('matched_faces') == {} + + # Now test with bad auth. + with requests_mock.Mocker() as mock_req: + url = "http://{}:{}/facebox/check".format(MOCK_IP, MOCK_PORT) + mock_req.register_uri( + 'POST', url, status_code=HTTP_UNAUTHORIZED) + data = {ATTR_ENTITY_ID: VALID_ENTITY_ID} + await hass.services.async_call(ip.DOMAIN, + ip.SERVICE_SCAN, + service_data=data) + await hass.async_block_till_done() + assert "AuthenticationError on facebox" in caplog.text + + +async def test_teach_service( + hass, mock_healthybox, mock_image, + mock_isfile, mock_open_file, caplog): + """Test teaching of facebox.""" + await async_setup_component(hass, ip.DOMAIN, VALID_CONFIG) + assert hass.states.get(VALID_ENTITY_ID) + + # Patch out 'is_allowed_path' as the mock files aren't allowed + hass.config.is_allowed_path = Mock(return_value=True) + + # Test successful teach. + with requests_mock.Mocker() as mock_req: + url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT) + mock_req.post(url, status_code=HTTP_OK) + data = {ATTR_ENTITY_ID: VALID_ENTITY_ID, + ATTR_NAME: MOCK_NAME, + fb.FILE_PATH: MOCK_FILE_PATH} + await hass.services.async_call( + ip.DOMAIN, fb.SERVICE_TEACH_FACE, service_data=data) + await hass.async_block_till_done() + + # Now test with bad auth. + with requests_mock.Mocker() as mock_req: + url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT) + mock_req.post(url, status_code=HTTP_UNAUTHORIZED) + data = {ATTR_ENTITY_ID: VALID_ENTITY_ID, + ATTR_NAME: MOCK_NAME, + fb.FILE_PATH: MOCK_FILE_PATH} + await hass.services.async_call(ip.DOMAIN, + fb.SERVICE_TEACH_FACE, + service_data=data) + await hass.async_block_till_done() + assert "AuthenticationError on facebox" in caplog.text + + # Now test the failed teaching. + with requests_mock.Mocker() as mock_req: + url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT) + mock_req.post(url, status_code=HTTP_BAD_REQUEST, + text=MOCK_ERROR_NO_FACE) + data = {ATTR_ENTITY_ID: VALID_ENTITY_ID, + ATTR_NAME: MOCK_NAME, + fb.FILE_PATH: MOCK_FILE_PATH} + await hass.services.async_call(ip.DOMAIN, + fb.SERVICE_TEACH_FACE, + service_data=data) + await hass.async_block_till_done() + assert MOCK_ERROR_NO_FACE in caplog.text + + # Now test connection error. + with requests_mock.Mocker() as mock_req: + url = "http://{}:{}/facebox/teach".format(MOCK_IP, MOCK_PORT) + mock_req.post(url, exc=requests.exceptions.ConnectTimeout) + data = {ATTR_ENTITY_ID: VALID_ENTITY_ID, + ATTR_NAME: MOCK_NAME, + fb.FILE_PATH: MOCK_FILE_PATH} + await hass.services.async_call(ip.DOMAIN, + fb.SERVICE_TEACH_FACE, + service_data=data) + await hass.async_block_till_done() + assert "ConnectionError: Is facebox running?" in caplog.text + + +async def test_setup_platform_with_name(hass, mock_healthybox): + """Setup platform with one entity and a name.""" + named_entity_id = 'image_processing.{}'.format(MOCK_NAME) + + valid_config_named = VALID_CONFIG.copy() + valid_config_named[ip.DOMAIN][ip.CONF_SOURCE][ip.CONF_NAME] = MOCK_NAME + + await async_setup_component(hass, ip.DOMAIN, valid_config_named) + assert hass.states.get(named_entity_id) + state = hass.states.get(named_entity_id) + assert state.attributes.get(CONF_FRIENDLY_NAME) == MOCK_NAME diff --git a/tests/components/image_processing/test_init.py b/tests/components/image_processing/test_init.py index 628c5405eaa538..ab2e3be11d68a4 100644 --- a/tests/components/image_processing/test_init.py +++ b/tests/components/image_processing/test_init.py @@ -12,7 +12,7 @@ get_test_home_assistant, get_test_instance_port, assert_setup_component) -class TestSetupImageProcessing(object): +class TestSetupImageProcessing: """Test class for setup image processing.""" def setup_method(self): @@ -48,7 +48,7 @@ def test_setup_component_with_service(self): assert self.hass.services.has_service(ip.DOMAIN, 'scan') -class TestImageProcessing(object): +class TestImageProcessing: """Test class for image processing.""" def setup_method(self): @@ -109,7 +109,7 @@ def test_get_image_without_exists_camera(self, mock_image): assert state.state == '0' -class TestImageProcessingAlpr(object): +class TestImageProcessingAlpr: """Test class for alpr image processing.""" def setup_method(self): @@ -211,7 +211,7 @@ def test_alpr_event_single_call_confidence(self, confidence_mock, assert event_data[0]['entity_id'] == 'image_processing.demo_alpr' -class TestImageProcessingFace(object): +class TestImageProcessingFace: """Test class for face image processing.""" def setup_method(self): diff --git a/tests/components/image_processing/test_microsoft_face_detect.py b/tests/components/image_processing/test_microsoft_face_detect.py index b743dee97047e0..acc2519c9b722b 100644 --- a/tests/components/image_processing/test_microsoft_face_detect.py +++ b/tests/components/image_processing/test_microsoft_face_detect.py @@ -11,7 +11,7 @@ get_test_home_assistant, assert_setup_component, load_fixture, mock_coro) -class TestMicrosoftFaceDetectSetup(object): +class TestMicrosoftFaceDetectSetup: """Test class for image processing.""" def setup_method(self): @@ -74,7 +74,7 @@ def test_setup_platform_name(self, store_mock): assert self.hass.states.get('image_processing.test_local') -class TestMicrosoftFaceDetect(object): +class TestMicrosoftFaceDetect: """Test class for image processing.""" def setup_method(self): diff --git a/tests/components/image_processing/test_microsoft_face_identify.py b/tests/components/image_processing/test_microsoft_face_identify.py index c2ab5684ed0d7a..8797f661767eb2 100644 --- a/tests/components/image_processing/test_microsoft_face_identify.py +++ b/tests/components/image_processing/test_microsoft_face_identify.py @@ -11,7 +11,7 @@ get_test_home_assistant, assert_setup_component, load_fixture, mock_coro) -class TestMicrosoftFaceIdentifySetup(object): +class TestMicrosoftFaceIdentifySetup: """Test class for image processing.""" def setup_method(self): @@ -75,7 +75,7 @@ def test_setup_platform_name(self, store_mock): assert self.hass.states.get('image_processing.test_local') -class TestMicrosoftFaceIdentify(object): +class TestMicrosoftFaceIdentify: """Test class for image processing.""" def setup_method(self): diff --git a/tests/components/image_processing/test_openalpr_cloud.py b/tests/components/image_processing/test_openalpr_cloud.py index 50060e08a4b47d..65e735a6f7e01d 100644 --- a/tests/components/image_processing/test_openalpr_cloud.py +++ b/tests/components/image_processing/test_openalpr_cloud.py @@ -12,7 +12,7 @@ get_test_home_assistant, assert_setup_component, load_fixture, mock_coro) -class TestOpenAlprCloudSetup(object): +class TestOpenAlprCloudSetup: """Test class for image processing.""" def setup_method(self): @@ -103,7 +103,7 @@ def test_setup_platform_without_region(self): setup_component(self.hass, ip.DOMAIN, config) -class TestOpenAlprCloud(object): +class TestOpenAlprCloud: """Test class for image processing.""" def setup_method(self): diff --git a/tests/components/image_processing/test_openalpr_local.py b/tests/components/image_processing/test_openalpr_local.py index fc40f8e17fb50d..38e94166c5a983 100644 --- a/tests/components/image_processing/test_openalpr_local.py +++ b/tests/components/image_processing/test_openalpr_local.py @@ -26,7 +26,7 @@ def communicate(input=None): return async_popen -class TestOpenAlprLocalSetup(object): +class TestOpenAlprLocalSetup: """Test class for image processing.""" def setup_method(self): @@ -96,7 +96,7 @@ def test_setup_platform_without_region(self): setup_component(self.hass, ip.DOMAIN, config) -class TestOpenAlprLocal(object): +class TestOpenAlprLocal: """Test class for image processing.""" def setup_method(self): diff --git a/tests/components/light/test_deconz.py b/tests/components/light/test_deconz.py index 2608d77ce2ae68..df088d7a1b5dde 100644 --- a/tests/components/light/test_deconz.py +++ b/tests/components/light/test_deconz.py @@ -37,8 +37,17 @@ }, } +SWITCH = { + "1": { + "id": "Switch 1 id", + "name": "Switch 1 name", + "type": "On/Off plug-in unit", + "state": {} + } +} + -async def setup_bridge(hass, data): +async def setup_bridge(hass, data, allow_deconz_groups=True): """Load the deCONZ light platform.""" from pydeconz import DeconzSession loop = Mock() @@ -53,7 +62,9 @@ async def setup_bridge(hass, data): hass.data[deconz.DATA_DECONZ_UNSUB] = [] hass.data[deconz.DATA_DECONZ_ID] = {} config_entry = config_entries.ConfigEntry( - 1, deconz.DOMAIN, 'Mock Title', {'host': 'mock-host'}, 'test') + 1, deconz.DOMAIN, 'Mock Title', + {'host': 'mock-host', 'allow_deconz_groups': allow_deconz_groups}, + 'test') await hass.config_entries.async_forward_entry_setup(config_entry, 'light') # To flush out the service call to update the group await hass.async_block_till_done() @@ -98,3 +109,22 @@ async def test_add_new_group(hass): async_dispatcher_send(hass, 'deconz_new_group', [group]) await hass.async_block_till_done() assert "light.name" in hass.data[deconz.DATA_DECONZ_ID] + + +async def test_do_not_add_deconz_groups(hass): + """Test that clip sensors can be ignored.""" + data = {} + await setup_bridge(hass, data, allow_deconz_groups=False) + group = Mock() + group.name = 'name' + group.register_async_callback = Mock() + async_dispatcher_send(hass, 'deconz_new_group', [group]) + await hass.async_block_till_done() + assert len(hass.data[deconz.DATA_DECONZ_ID]) == 0 + + +async def test_no_switch(hass): + """Test that a switch doesn't get created as a light entity.""" + await setup_bridge(hass, {"lights": SWITCH}) + assert len(hass.data[deconz.DATA_DECONZ_ID]) == 0 + assert len(hass.states.async_all()) == 0 diff --git a/tests/components/light/test_group.py b/tests/components/light/test_group.py index 26b949720d9419..901535c5465139 100644 --- a/tests/components/light/test_group.py +++ b/tests/components/light/test_group.py @@ -200,21 +200,24 @@ async def test_effect_list(hass): }}) hass.states.async_set('light.test1', 'on', - {'effect_list': ['None', 'Random', 'Colorloop']}) + {'effect_list': ['None', 'Random', 'Colorloop'], + 'supported_features': 4}) await hass.async_block_till_done() state = hass.states.get('light.light_group') assert set(state.attributes['effect_list']) == { 'None', 'Random', 'Colorloop'} hass.states.async_set('light.test2', 'on', - {'effect_list': ['None', 'Random', 'Rainbow']}) + {'effect_list': ['None', 'Random', 'Rainbow'], + 'supported_features': 4}) await hass.async_block_till_done() state = hass.states.get('light.light_group') assert set(state.attributes['effect_list']) == { 'None', 'Random', 'Colorloop', 'Rainbow'} hass.states.async_set('light.test1', 'off', - {'effect_list': ['None', 'Colorloop', 'Seven']}) + {'effect_list': ['None', 'Colorloop', 'Seven'], + 'supported_features': 4}) await hass.async_block_till_done() state = hass.states.get('light.light_group') assert set(state.attributes['effect_list']) == { @@ -229,27 +232,27 @@ async def test_effect(hass): }}) hass.states.async_set('light.test1', 'on', - {'effect': 'None', 'supported_features': 2}) + {'effect': 'None', 'supported_features': 6}) await hass.async_block_till_done() state = hass.states.get('light.light_group') assert state.attributes['effect'] == 'None' hass.states.async_set('light.test2', 'on', - {'effect': 'None', 'supported_features': 2}) + {'effect': 'None', 'supported_features': 6}) await hass.async_block_till_done() state = hass.states.get('light.light_group') assert state.attributes['effect'] == 'None' hass.states.async_set('light.test3', 'on', - {'effect': 'Random', 'supported_features': 2}) + {'effect': 'Random', 'supported_features': 6}) await hass.async_block_till_done() state = hass.states.get('light.light_group') assert state.attributes['effect'] == 'None' hass.states.async_set('light.test1', 'off', - {'effect': 'None', 'supported_features': 2}) + {'effect': 'None', 'supported_features': 6}) hass.states.async_set('light.test2', 'off', - {'effect': 'None', 'supported_features': 2}) + {'effect': 'None', 'supported_features': 6}) await hass.async_block_till_done() state = hass.states.get('light.light_group') assert state.attributes['effect'] == 'Random' diff --git a/tests/components/light/test_hue.py b/tests/components/light/test_hue.py index a1e3867f9c3c29..db8d7e5f1e19e6 100644 --- a/tests/components/light/test_hue.py +++ b/tests/components/light/test_hue.py @@ -182,7 +182,7 @@ async def mock_request(method, path, **kwargs): if path == 'lights': return bridge.mock_light_responses.popleft() - elif path == 'groups': + if path == 'groups': return bridge.mock_group_responses.popleft() return None diff --git a/tests/components/light/test_init.py b/tests/components/light/test_init.py index 634e3774b8a684..4d779eef461271 100644 --- a/tests/components/light/test_init.py +++ b/tests/components/light/test_init.py @@ -1,14 +1,16 @@ """The tests for the Light component.""" # pylint: disable=protected-access import unittest +import unittest.mock as mock import os +from io import StringIO -from homeassistant.setup import setup_component -import homeassistant.loader as loader +from homeassistant import core, loader +from homeassistant.setup import setup_component, async_setup_component from homeassistant.const import ( ATTR_ENTITY_ID, STATE_ON, STATE_OFF, CONF_PLATFORM, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, ATTR_SUPPORTED_FEATURES) -import homeassistant.components.light as light +from homeassistant.components import light from homeassistant.helpers.intent import IntentHandleError from tests.common import ( @@ -308,6 +310,82 @@ def test_light_profiles(self): light.ATTR_BRIGHTNESS: 100 }, data) + def test_default_profiles_group(self): + """Test default turn-on light profile for all lights.""" + platform = loader.get_component(self.hass, 'light.test') + platform.init() + + user_light_file = self.hass.config.path(light.LIGHT_PROFILES_FILE) + real_isfile = os.path.isfile + real_open = open + + def _mock_isfile(path): + if path == user_light_file: + return True + return real_isfile(path) + + def _mock_open(path): + if path == user_light_file: + return StringIO(profile_data) + return real_open(path) + + profile_data = "id,x,y,brightness\n" +\ + "group.all_lights.default,.4,.6,99\n" + with mock.patch('os.path.isfile', side_effect=_mock_isfile): + with mock.patch('builtins.open', side_effect=_mock_open): + self.assertTrue(setup_component( + self.hass, light.DOMAIN, + {light.DOMAIN: {CONF_PLATFORM: 'test'}} + )) + + dev, _, _ = platform.DEVICES + light.turn_on(self.hass, dev.entity_id) + self.hass.block_till_done() + _, data = dev.last_call('turn_on') + self.assertEqual({ + light.ATTR_HS_COLOR: (71.059, 100), + light.ATTR_BRIGHTNESS: 99 + }, data) + + def test_default_profiles_light(self): + """Test default turn-on light profile for a specific light.""" + platform = loader.get_component(self.hass, 'light.test') + platform.init() + + user_light_file = self.hass.config.path(light.LIGHT_PROFILES_FILE) + real_isfile = os.path.isfile + real_open = open + + def _mock_isfile(path): + if path == user_light_file: + return True + return real_isfile(path) + + def _mock_open(path): + if path == user_light_file: + return StringIO(profile_data) + return real_open(path) + + profile_data = "id,x,y,brightness\n" +\ + "group.all_lights.default,.3,.5,200\n" +\ + "light.ceiling_2.default,.6,.6,100\n" + with mock.patch('os.path.isfile', side_effect=_mock_isfile): + with mock.patch('builtins.open', side_effect=_mock_open): + self.assertTrue(setup_component( + self.hass, light.DOMAIN, + {light.DOMAIN: {CONF_PLATFORM: 'test'}} + )) + + dev = next(filter(lambda x: x.entity_id == 'light.ceiling_2', + platform.DEVICES)) + light.turn_on(self.hass, dev.entity_id) + self.hass.block_till_done() + _, data = dev.last_call('turn_on') + self.assertEqual({ + light.ATTR_HS_COLOR: (50.353, 100), + light.ATTR_BRIGHTNESS: 100 + }, data) + async def test_intent_set_color(hass): """Test the set color intent.""" @@ -397,3 +475,24 @@ async def test_intent_set_color_and_brightness(hass): assert call.data.get(ATTR_ENTITY_ID) == 'light.hello_2' assert call.data.get(light.ATTR_RGB_COLOR) == (0, 0, 255) assert call.data.get(light.ATTR_BRIGHTNESS_PCT) == 20 + + +async def test_light_context(hass): + """Test that light context works.""" + assert await async_setup_component(hass, 'light', { + 'light': { + 'platform': 'test' + } + }) + + state = hass.states.get('light.ceiling') + assert state is not None + + await hass.services.async_call('light', 'toggle', { + 'entity_id': state.entity_id, + }, True, core.Context(user_id='abcd')) + + state2 = hass.states.get('light.ceiling') + assert state2 is not None + assert state.state != state2.state + assert state2.context.user_id == 'abcd' diff --git a/tests/components/light/test_mqtt.py b/tests/components/light/test_mqtt.py index 7f7841b1a69b0a..404d60c0a2e0fc 100644 --- a/tests/components/light/test_mqtt.py +++ b/tests/components/light/test_mqtt.py @@ -140,14 +140,16 @@ """ import unittest from unittest import mock +from unittest.mock import patch from homeassistant.setup import setup_component from homeassistant.const import ( STATE_ON, STATE_OFF, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE) import homeassistant.components.light as light +import homeassistant.core as ha from tests.common import ( assert_setup_component, get_test_home_assistant, mock_mqtt_component, - fire_mqtt_message) + fire_mqtt_message, mock_coro) class TestLightMQTT(unittest.TestCase): @@ -175,8 +177,7 @@ def test_fail_setup_if_no_command_topic(self): }) self.assertIsNone(self.hass.states.get('light.test')) - def test_no_color_brightness_color_temp_white_xy_if_no_topics(self): \ - # pylint: disable=invalid-name + def test_no_color_brightness_color_temp_white_xy_if_no_topics(self): """Test if there is no color and brightness if no topic.""" with assert_setup_component(1, light.DOMAIN): assert setup_component(self.hass, light.DOMAIN, { @@ -207,8 +208,7 @@ def test_no_color_brightness_color_temp_white_xy_if_no_topics(self): \ self.assertIsNone(state.attributes.get('white_value')) self.assertIsNone(state.attributes.get('xy_color')) - def test_controlling_state_via_topic(self): \ - # pylint: disable=invalid-name + def test_controlling_state_via_topic(self): """Test the controlling of the state via topic.""" config = {light.DOMAIN: { 'platform': 'mqtt', @@ -408,14 +408,19 @@ def test_white_value_controlling_scale(self): self.assertEqual(255, light_state.attributes['white_value']) - def test_controlling_state_via_topic_with_templates(self): \ - # pylint: disable=invalid-name + def test_controlling_state_via_topic_with_templates(self): """Test the setting og the state with a template.""" config = {light.DOMAIN: { 'platform': 'mqtt', 'name': 'test', 'state_topic': 'test_light_rgb/status', 'command_topic': 'test_light_rgb/set', + 'brightness_command_topic': 'test_light_rgb/brightness/set', + 'rgb_command_topic': 'test_light_rgb/rgb/set', + 'color_temp_command_topic': 'test_light_rgb/color_temp/set', + 'effect_command_topic': 'test_light_rgb/effect/set', + 'white_value_command_topic': 'test_light_rgb/white_value/set', + 'xy_command_topic': 'test_light_rgb/xy/set', 'brightness_state_topic': 'test_light_rgb/brightness/status', 'color_temp_state_topic': 'test_light_rgb/color_temp/status', 'effect_state_topic': 'test_light_rgb/effect/status', @@ -464,8 +469,7 @@ def test_controlling_state_via_topic_with_templates(self): \ self.assertEqual(75, state.attributes.get('white_value')) self.assertEqual((0.14, 0.131), state.attributes.get('xy_color')) - def test_sending_mqtt_commands_and_optimistic(self): \ - # pylint: disable=invalid-name + def test_sending_mqtt_commands_and_optimistic(self): """Test the sending of command in optimistic mode.""" config = {light.DOMAIN: { 'platform': 'mqtt', @@ -477,16 +481,28 @@ def test_sending_mqtt_commands_and_optimistic(self): \ 'effect_command_topic': 'test_light_rgb/effect/set', 'white_value_command_topic': 'test_light_rgb/white_value/set', 'xy_command_topic': 'test_light_rgb/xy/set', + 'effect_list': ['colorloop', 'random'], 'qos': 2, 'payload_on': 'on', 'payload_off': 'off' }} - - with assert_setup_component(1, light.DOMAIN): - assert setup_component(self.hass, light.DOMAIN, config) + fake_state = ha.State('light.test', 'on', {'brightness': 95, + 'hs_color': [100, 100], + 'effect': 'random', + 'color_temp': 100, + 'white_value': 50}) + with patch('homeassistant.components.light.mqtt.async_get_last_state', + return_value=mock_coro(fake_state)): + with assert_setup_component(1, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, config) state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) + self.assertEqual(STATE_ON, state.state) + self.assertEqual(95, state.attributes.get('brightness')) + self.assertEqual((100, 100), state.attributes.get('hs_color')) + self.assertEqual('random', state.attributes.get('effect')) + self.assertEqual(100, state.attributes.get('color_temp')) + self.assertEqual(50, state.attributes.get('white_value')) self.assertTrue(state.attributes.get(ATTR_ASSUMED_STATE)) light.turn_on(self.hass, 'light.test') @@ -510,24 +526,24 @@ def test_sending_mqtt_commands_and_optimistic(self): \ self.mock_publish.reset_mock() light.turn_on(self.hass, 'light.test', brightness=50, xy_color=[0.123, 0.123]) - light.turn_on(self.hass, 'light.test', rgb_color=[75, 75, 75], + light.turn_on(self.hass, 'light.test', rgb_color=[255, 128, 0], white_value=80) self.hass.block_till_done() self.mock_publish.async_publish.assert_has_calls([ mock.call('test_light_rgb/set', 'on', 2, False), - mock.call('test_light_rgb/rgb/set', '50,50,50', 2, False), + mock.call('test_light_rgb/rgb/set', '255,128,0', 2, False), mock.call('test_light_rgb/brightness/set', 50, 2, False), mock.call('test_light_rgb/white_value/set', 80, 2, False), - mock.call('test_light_rgb/xy/set', '0.323,0.329', 2, False), + mock.call('test_light_rgb/xy/set', '0.14,0.131', 2, False), ], any_order=True) state = self.hass.states.get('light.test') self.assertEqual(STATE_ON, state.state) - self.assertEqual((255, 255, 255), state.attributes['rgb_color']) + self.assertEqual((255, 128, 0), state.attributes['rgb_color']) self.assertEqual(50, state.attributes['brightness']) self.assertEqual(80, state.attributes['white_value']) - self.assertEqual((0.323, 0.329), state.attributes['xy_color']) + self.assertEqual((0.611, 0.375), state.attributes['xy_color']) def test_sending_mqtt_rgb_command_with_template(self): """Test the sending of RGB command with template.""" @@ -795,11 +811,11 @@ def test_on_command_brightness(self): # Turn on w/ just a color to insure brightness gets # added and sent. - light.turn_on(self.hass, 'light.test', rgb_color=[75, 75, 75]) + light.turn_on(self.hass, 'light.test', rgb_color=[255, 128, 0]) self.hass.block_till_done() self.mock_publish.async_publish.assert_has_calls([ - mock.call('test_light/rgb', '50,50,50', 0, False), + mock.call('test_light/rgb', '255,128,0', 0, False), mock.call('test_light/bright', 50, 0, False) ], any_order=True) diff --git a/tests/components/light/test_mqtt_json.py b/tests/components/light/test_mqtt_json.py index 5bae1061b7fe6e..f16685b3575827 100644 --- a/tests/components/light/test_mqtt_json.py +++ b/tests/components/light/test_mqtt_json.py @@ -90,15 +90,17 @@ import json import unittest +from unittest.mock import patch from homeassistant.setup import setup_component from homeassistant.const import ( STATE_ON, STATE_OFF, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE, ATTR_SUPPORTED_FEATURES) import homeassistant.components.light as light +import homeassistant.core as ha from tests.common import ( get_test_home_assistant, mock_mqtt_component, fire_mqtt_message, - assert_setup_component) + assert_setup_component, mock_coro) class TestLightMQTTJSON(unittest.TestCase): @@ -113,8 +115,7 @@ def tearDown(self): # pylint: disable=invalid-name """Stop everything that was started.""" self.hass.stop() - def test_fail_setup_if_no_command_topic(self): \ - # pylint: disable=invalid-name + def test_fail_setup_if_no_command_topic(self): """Test if setup fails with no command topic.""" with assert_setup_component(0, light.DOMAIN): assert setup_component(self.hass, light.DOMAIN, { @@ -125,8 +126,7 @@ def test_fail_setup_if_no_command_topic(self): \ }) self.assertIsNone(self.hass.states.get('light.test')) - def test_no_color_brightness_color_temp_white_val_if_no_topics(self): \ - # pylint: disable=invalid-name + def test_no_color_brightness_color_temp_white_val_if_no_topics(self): """Test for no RGB, brightness, color temp, effect, white val or XY.""" assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { @@ -161,8 +161,7 @@ def test_no_color_brightness_color_temp_white_val_if_no_topics(self): \ self.assertIsNone(state.attributes.get('xy_color')) self.assertIsNone(state.attributes.get('hs_color')) - def test_controlling_state_via_topic(self): \ - # pylint: disable=invalid-name + def test_controlling_state_via_topic(self): """Test the controlling of the state via topic.""" assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { @@ -281,25 +280,38 @@ def test_controlling_state_via_topic(self): \ light_state = self.hass.states.get('light.test') self.assertEqual(155, light_state.attributes.get('white_value')) - def test_sending_mqtt_commands_and_optimistic(self): \ - # pylint: disable=invalid-name + def test_sending_mqtt_commands_and_optimistic(self): """Test the sending of command in optimistic mode.""" - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_json', - 'name': 'test', - 'command_topic': 'test_light_rgb/set', - 'brightness': True, - 'color_temp': True, - 'effect': True, - 'rgb': True, - 'white_value': True, - 'qos': 2 - } - }) + fake_state = ha.State('light.test', 'on', {'brightness': 95, + 'hs_color': [100, 100], + 'effect': 'random', + 'color_temp': 100, + 'white_value': 50}) + + with patch('homeassistant.components.light.mqtt_json' + '.async_get_last_state', + return_value=mock_coro(fake_state)): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_json', + 'name': 'test', + 'command_topic': 'test_light_rgb/set', + 'brightness': True, + 'color_temp': True, + 'effect': True, + 'rgb': True, + 'white_value': True, + 'qos': 2 + } + }) state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) + self.assertEqual(STATE_ON, state.state) + self.assertEqual(95, state.attributes.get('brightness')) + self.assertEqual((100, 100), state.attributes.get('hs_color')) + self.assertEqual('random', state.attributes.get('effect')) + self.assertEqual(100, state.attributes.get('color_temp')) + self.assertEqual(50, state.attributes.get('white_value')) self.assertEqual(191, state.attributes.get(ATTR_SUPPORTED_FEATURES)) self.assertTrue(state.attributes.get(ATTR_ASSUMED_STATE)) @@ -365,8 +377,8 @@ def test_sending_mqtt_commands_and_optimistic(self): \ self.assertEqual(50, message_json["brightness"]) self.assertEqual({ 'r': 0, - 'g': 50, - 'b': 4, + 'g': 255, + 'b': 21, }, message_json["color"]) self.assertEqual("ON", message_json["state"]) @@ -397,8 +409,7 @@ def test_sending_hs_color(self): 's': 50.0, }, message_json["color"]) - def test_flash_short_and_long(self): \ - # pylint: disable=invalid-name + def test_flash_short_and_long(self): """Test for flash length being sent when included.""" assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { @@ -531,8 +542,7 @@ def test_brightness_scale(self): self.assertEqual(STATE_ON, state.state) self.assertEqual(255, state.attributes.get('brightness')) - def test_invalid_color_brightness_and_white_values(self): \ - # pylint: disable=invalid-name + def test_invalid_color_brightness_and_white_values(self): """Test that invalid color/brightness/white values are ignored.""" assert setup_component(self.hass, light.DOMAIN, { light.DOMAIN: { diff --git a/tests/components/light/test_mqtt_template.py b/tests/components/light/test_mqtt_template.py index 90d68dd10d201e..1cf09f2ccb5a95 100644 --- a/tests/components/light/test_mqtt_template.py +++ b/tests/components/light/test_mqtt_template.py @@ -27,14 +27,16 @@ If your light doesn't support RGB feature, omit `(red|green|blue)_template`. """ import unittest +from unittest.mock import patch from homeassistant.setup import setup_component from homeassistant.const import ( STATE_ON, STATE_OFF, STATE_UNAVAILABLE, ATTR_ASSUMED_STATE) import homeassistant.components.light as light +import homeassistant.core as ha from tests.common import ( get_test_home_assistant, mock_mqtt_component, fire_mqtt_message, - assert_setup_component) + assert_setup_component, mock_coro) class TestLightMQTTTemplate(unittest.TestCase): @@ -49,8 +51,7 @@ def tearDown(self): # pylint: disable=invalid-name """Stop everything that was started.""" self.hass.stop() - def test_setup_fails(self): \ - # pylint: disable=invalid-name + def test_setup_fails(self): """Test that setup fails with missing required configuration items.""" with assert_setup_component(0, light.DOMAIN): assert setup_component(self.hass, light.DOMAIN, { @@ -61,8 +62,7 @@ def test_setup_fails(self): \ }) self.assertIsNone(self.hass.states.get('light.test')) - def test_state_change_via_topic(self): \ - # pylint: disable=invalid-name + def test_state_change_via_topic(self): """Test state change via topic.""" with assert_setup_component(1, light.DOMAIN): assert setup_component(self.hass, light.DOMAIN, { @@ -101,8 +101,7 @@ def test_state_change_via_topic(self): \ self.assertIsNone(state.attributes.get('color_temp')) self.assertIsNone(state.attributes.get('white_value')) - def test_state_brightness_color_effect_temp_white_change_via_topic(self): \ - # pylint: disable=invalid-name + def test_state_brightness_color_effect_temp_white_change_via_topic(self): """Test state, bri, color, effect, color temp, white val change.""" with assert_setup_component(1, light.DOMAIN): assert setup_component(self.hass, light.DOMAIN, { @@ -204,29 +203,44 @@ def test_state_brightness_color_effect_temp_white_change_via_topic(self): \ light_state = self.hass.states.get('light.test') self.assertEqual('rainbow', light_state.attributes.get('effect')) - def test_optimistic(self): \ - # pylint: disable=invalid-name + def test_optimistic(self): """Test optimistic mode.""" - with assert_setup_component(1, light.DOMAIN): - assert setup_component(self.hass, light.DOMAIN, { - light.DOMAIN: { - 'platform': 'mqtt_template', - 'name': 'test', - 'command_topic': 'test_light_rgb/set', - 'command_on_template': 'on,' - '{{ brightness|d }},' - '{{ color_temp|d }},' - '{{ white_value|d }},' - '{{ red|d }}-' - '{{ green|d }}-' - '{{ blue|d }}', - 'command_off_template': 'off', - 'qos': 2 - } - }) + fake_state = ha.State('light.test', 'on', {'brightness': 95, + 'hs_color': [100, 100], + 'effect': 'random', + 'color_temp': 100, + 'white_value': 50}) + + with patch('homeassistant.components.light.mqtt_template' + '.async_get_last_state', + return_value=mock_coro(fake_state)): + with assert_setup_component(1, light.DOMAIN): + assert setup_component(self.hass, light.DOMAIN, { + light.DOMAIN: { + 'platform': 'mqtt_template', + 'name': 'test', + 'command_topic': 'test_light_rgb/set', + 'command_on_template': 'on,' + '{{ brightness|d }},' + '{{ color_temp|d }},' + '{{ white_value|d }},' + '{{ red|d }}-' + '{{ green|d }}-' + '{{ blue|d }}', + 'command_off_template': 'off', + 'effect_list': ['colorloop', 'random'], + 'effect_command_topic': 'test_light_rgb/effect/set', + 'qos': 2 + } + }) state = self.hass.states.get('light.test') - self.assertEqual(STATE_OFF, state.state) + self.assertEqual(STATE_ON, state.state) + self.assertEqual(95, state.attributes.get('brightness')) + self.assertEqual((100, 100), state.attributes.get('hs_color')) + self.assertEqual('random', state.attributes.get('effect')) + self.assertEqual(100, state.attributes.get('color_temp')) + self.assertEqual(50, state.attributes.get('white_value')) self.assertTrue(state.attributes.get(ATTR_ASSUMED_STATE)) # turn on the light @@ -273,8 +287,7 @@ def test_optimistic(self): \ self.assertEqual(200, state.attributes['color_temp']) self.assertEqual(139, state.attributes['white_value']) - def test_flash(self): \ - # pylint: disable=invalid-name + def test_flash(self): """Test flash.""" with assert_setup_component(1, light.DOMAIN): assert setup_component(self.hass, light.DOMAIN, { @@ -337,8 +350,7 @@ def test_transition(self): self.mock_publish.async_publish.assert_called_once_with( 'test_light_rgb/set', 'off,4', 0, False) - def test_invalid_values(self): \ - # pylint: disable=invalid-name + def test_invalid_values(self): """Test that invalid values are ignored.""" with assert_setup_component(1, light.DOMAIN): assert setup_component(self.hass, light.DOMAIN, { diff --git a/tests/components/light/test_template.py b/tests/components/light/test_template.py index 2d45ad1bf94242..962760672f1634 100644 --- a/tests/components/light/test_template.py +++ b/tests/components/light/test_template.py @@ -36,7 +36,7 @@ def teardown_method(self, method): self.hass.stop() def test_template_state_text(self): - """"Test the state text of a template.""" + """Test the state text of a template.""" with assert_setup_component(1, 'light'): assert setup.setup_component(self.hass, 'light', { 'light': { diff --git a/tests/components/light/test_tradfri.py b/tests/components/light/test_tradfri.py new file mode 100644 index 00000000000000..12c596f3f097a9 --- /dev/null +++ b/tests/components/light/test_tradfri.py @@ -0,0 +1,549 @@ +"""Tradfri lights platform tests.""" + +from copy import deepcopy +from unittest.mock import Mock, MagicMock, patch, PropertyMock + +import pytest +from pytradfri.device import Device, LightControl, Light +from pytradfri import RequestError + +from homeassistant.components import tradfri +from homeassistant.setup import async_setup_component + + +DEFAULT_TEST_FEATURES = {'can_set_dimmer': False, + 'can_set_color': False, + 'can_set_temp': False} +# [ +# {bulb features}, +# {turn_on arguments}, +# {expected result} +# ] +TURN_ON_TEST_CASES = [ + # Turn On + [ + {}, + {}, + {'state': 'on'}, + ], + # Brightness > 0 + [ + {'can_set_dimmer': True}, + {'brightness': 100}, + { + 'state': 'on', + 'brightness': 100 + } + ], + # Brightness == 0 + [ + {'can_set_dimmer': True}, + {'brightness': 0}, + { + 'brightness': 0 + } + ], + # Brightness < 0 + [ + {'can_set_dimmer': True}, + {'brightness': -1}, + { + 'brightness': 0 + } + ], + # Brightness > 254 + [ + {'can_set_dimmer': True}, + {'brightness': 1000}, + { + 'brightness': 254 + } + ], + # color_temp + [ + {'can_set_temp': True}, + {'color_temp': 250}, + {'color_temp': 250}, + ], + # color_temp < 250 + [ + {'can_set_temp': True}, + {'color_temp': 1}, + {'color_temp': 250}, + ], + # color_temp > 454 + [ + {'can_set_temp': True}, + {'color_temp': 1000}, + {'color_temp': 454}, + ], + # hs color + [ + {'can_set_color': True}, + {'hs_color': [300, 100]}, + { + 'state': 'on', + 'hs_color': [300, 100] + } + ], + # ct + brightness + [ + { + 'can_set_dimmer': True, + 'can_set_temp': True + }, + { + 'color_temp': 250, + 'brightness': 200 + }, + { + 'state': 'on', + 'color_temp': 250, + 'brightness': 200 + } + ], + # ct + brightness (no temp support) + [ + { + 'can_set_dimmer': True, + 'can_set_temp': False, + 'can_set_color': True + }, + { + 'color_temp': 250, + 'brightness': 200 + }, + { + 'state': 'on', + 'hs_color': [26.807, 34.869], + 'brightness': 200 + } + ], + # ct + brightness (no temp or color support) + [ + { + 'can_set_dimmer': True, + 'can_set_temp': False, + 'can_set_color': False + }, + { + 'color_temp': 250, + 'brightness': 200 + }, + { + 'state': 'on', + 'brightness': 200 + } + ], + # hs + brightness + [ + { + 'can_set_dimmer': True, + 'can_set_color': True + }, + { + 'hs_color': [300, 100], + 'brightness': 200 + }, + { + 'state': 'on', + 'hs_color': [300, 100], + 'brightness': 200 + } + ] +] + +# Result of transition is not tested, but data is passed to turn on service. +TRANSITION_CASES_FOR_TESTS = [None, 0, 1] + + +@pytest.fixture(autouse=True, scope='module') +def setup(request): + """Set up patches for pytradfri methods.""" + p_1 = patch('pytradfri.device.LightControl.raw', + new_callable=PropertyMock, + return_value=[{'mock': 'mock'}]) + p_2 = patch('pytradfri.device.LightControl.lights') + p_1.start() + p_2.start() + + def teardown(): + """Remove patches for pytradfri methods.""" + p_1.stop() + p_2.stop() + + request.addfinalizer(teardown) + + +@pytest.fixture +def mock_gateway(): + """Mock a Tradfri gateway.""" + def get_devices(): + """Return mock devices.""" + return gateway.mock_devices + + def get_groups(): + """Return mock groups.""" + return gateway.mock_groups + + gateway = Mock( + get_devices=get_devices, + get_groups=get_groups, + mock_devices=[], + mock_groups=[], + mock_responses=[] + ) + return gateway + + +@pytest.fixture +def mock_api(mock_gateway): + """Mock api.""" + async def api(self, command): + """Mock api function.""" + # Store the data for "real" command objects. + if(hasattr(command, '_data') and not isinstance(command, Mock)): + mock_gateway.mock_responses.append(command._data) + return command + return api + + +async def generate_psk(self, code): + """Mock psk.""" + return "mock" + + +async def setup_gateway(hass, mock_gateway, mock_api, + generate_psk=generate_psk, + known_hosts=None): + """Load the Tradfri platform with a mock gateway.""" + def request_config(_, callback, description, submit_caption, fields): + """Mock request_config.""" + hass.async_add_job(callback, {'security_code': 'mock'}) + + if known_hosts is None: + known_hosts = {} + + with patch('pytradfri.api.aiocoap_api.APIFactory.generate_psk', + generate_psk), \ + patch('pytradfri.api.aiocoap_api.APIFactory.request', mock_api), \ + patch('pytradfri.Gateway', return_value=mock_gateway), \ + patch.object(tradfri, 'load_json', return_value=known_hosts), \ + patch.object(tradfri, 'save_json'), \ + patch.object(hass.components.configurator, 'request_config', + request_config): + + await async_setup_component(hass, tradfri.DOMAIN, + { + tradfri.DOMAIN: { + 'host': 'mock-host', + 'allow_tradfri_groups': True + } + }) + await hass.async_block_till_done() + + +async def test_setup_gateway(hass, mock_gateway, mock_api): + """Test that the gateway can be setup without errors.""" + await setup_gateway(hass, mock_gateway, mock_api) + + +async def test_setup_gateway_known_host(hass, mock_gateway, mock_api): + """Test gateway setup with a known host.""" + await setup_gateway(hass, mock_gateway, mock_api, + known_hosts={ + 'mock-host': { + 'identity': 'mock', + 'key': 'mock-key' + } + }) + + +async def test_incorrect_security_code(hass, mock_gateway, mock_api): + """Test that an error is shown if the security code is incorrect.""" + async def psk_error(self, code): + """Raise RequestError when called.""" + raise RequestError + + with patch.object(hass.components.configurator, 'async_notify_errors') \ + as notify_error: + await setup_gateway(hass, mock_gateway, mock_api, + generate_psk=psk_error) + assert len(notify_error.mock_calls) > 0 + + +def mock_light(test_features={}, test_state={}, n=0): + """Mock a tradfri light.""" + mock_light_data = Mock( + **test_state + ) + + mock_light = Mock( + id='mock-light-id-{}'.format(n), + reachable=True, + observe=Mock(), + device_info=MagicMock() + ) + mock_light.name = 'tradfri_light_{}'.format(n) + + # Set supported features for the light. + features = {**DEFAULT_TEST_FEATURES, **test_features} + lc = LightControl(mock_light) + for k, v in features.items(): + setattr(lc, k, v) + # Store the initial state. + setattr(lc, 'lights', [mock_light_data]) + mock_light.light_control = lc + return mock_light + + +async def test_light(hass, mock_gateway, mock_api): + """Test that lights are correctly added.""" + features = { + 'can_set_dimmer': True, + 'can_set_color': True, + 'can_set_temp': True + } + + state = { + 'state': True, + 'dimmer': 100, + 'color_temp': 250, + 'hsb_xy_color': (100, 100, 100, 100, 100) + } + + mock_gateway.mock_devices.append( + mock_light(test_features=features, test_state=state) + ) + await setup_gateway(hass, mock_gateway, mock_api) + + lamp_1 = hass.states.get('light.tradfri_light_0') + assert lamp_1 is not None + assert lamp_1.state == 'on' + assert lamp_1.attributes['brightness'] == 100 + assert lamp_1.attributes['hs_color'] == (0.549, 0.153) + + +async def test_light_observed(hass, mock_gateway, mock_api): + """Test that lights are correctly observed.""" + light = mock_light() + mock_gateway.mock_devices.append(light) + await setup_gateway(hass, mock_gateway, mock_api) + assert len(light.observe.mock_calls) > 0 + + +async def test_light_available(hass, mock_gateway, mock_api): + """Test light available property.""" + light = mock_light({'state': True}, n=1) + light.reachable = True + + light2 = mock_light({'state': True}, n=2) + light2.reachable = False + + mock_gateway.mock_devices.append(light) + mock_gateway.mock_devices.append(light2) + await setup_gateway(hass, mock_gateway, mock_api) + + assert (hass.states.get('light.tradfri_light_1') + .state == 'on') + + assert (hass.states.get('light.tradfri_light_2') + .state == 'unavailable') + + +# Combine TURN_ON_TEST_CASES and TRANSITION_CASES_FOR_TESTS +ALL_TURN_ON_TEST_CASES = [ + ["test_features", "test_data", "expected_result", "id"], + [] +] + +idx = 1 +for tc in TURN_ON_TEST_CASES: + for trans in TRANSITION_CASES_FOR_TESTS: + case = deepcopy(tc) + if trans is not None: + case[1]['transition'] = trans + case.append(idx) + idx = idx + 1 + ALL_TURN_ON_TEST_CASES[1].append(case) + + +@pytest.mark.parametrize(*ALL_TURN_ON_TEST_CASES) +async def test_turn_on(hass, + mock_gateway, + mock_api, + test_features, + test_data, + expected_result, + id): + """Test turning on a light.""" + # Note pytradfri style, not hass. Values not really important. + initial_state = { + 'state': False, + 'dimmer': 0, + 'color_temp': 250, + 'hsb_xy_color': (100, 100, 100, 100, 100) + } + + # Setup the gateway with a mock light. + light = mock_light(test_features=test_features, + test_state=initial_state, + n=id) + mock_gateway.mock_devices.append(light) + await setup_gateway(hass, mock_gateway, mock_api) + + # Use the turn_on service call to change the light state. + await hass.services.async_call('light', 'turn_on', { + 'entity_id': 'light.tradfri_light_{}'.format(id), + **test_data + }, blocking=True) + await hass.async_block_till_done() + + # Check that the light is observed. + mock_func = light.observe + assert len(mock_func.mock_calls) > 0 + _, callkwargs = mock_func.call_args + assert 'callback' in callkwargs + # Callback function to refresh light state. + cb = callkwargs['callback'] + + responses = mock_gateway.mock_responses + # State on command data. + data = {'3311': [{'5850': 1}]} + # Add data for all sent commands. + for r in responses: + data['3311'][0] = {**data['3311'][0], **r['3311'][0]} + + # Use the callback function to update the light state. + dev = Device(data) + light_data = Light(dev, 0) + light.light_control.lights[0] = light_data + cb(light) + await hass.async_block_till_done() + + # Check that the state is correct. + states = hass.states.get('light.tradfri_light_{}'.format(id)) + for k, v in expected_result.items(): + if k == 'state': + assert states.state == v + else: + # Allow some rounding error in color conversions. + assert states.attributes[k] == pytest.approx(v, abs=0.01) + + +async def test_turn_off(hass, mock_gateway, mock_api): + """Test turning off a light.""" + state = { + 'state': True, + 'dimmer': 100, + } + + light = mock_light(test_state=state) + mock_gateway.mock_devices.append(light) + await setup_gateway(hass, mock_gateway, mock_api) + + # Use the turn_off service call to change the light state. + await hass.services.async_call('light', 'turn_off', { + 'entity_id': 'light.tradfri_light_0'}, blocking=True) + await hass.async_block_till_done() + + # Check that the light is observed. + mock_func = light.observe + assert len(mock_func.mock_calls) > 0 + _, callkwargs = mock_func.call_args + assert 'callback' in callkwargs + # Callback function to refresh light state. + cb = callkwargs['callback'] + + responses = mock_gateway.mock_responses + data = {'3311': [{}]} + # Add data for all sent commands. + for r in responses: + data['3311'][0] = {**data['3311'][0], **r['3311'][0]} + + # Use the callback function to update the light state. + dev = Device(data) + light_data = Light(dev, 0) + light.light_control.lights[0] = light_data + cb(light) + await hass.async_block_till_done() + + # Check that the state is correct. + states = hass.states.get('light.tradfri_light_0') + assert states.state == 'off' + + +def mock_group(test_state={}, n=0): + """Mock a Tradfri group.""" + default_state = { + 'state': False, + 'dimmer': 0, + } + + state = {**default_state, **test_state} + + mock_group = Mock( + member_ids=[], + observe=Mock(), + **state + ) + mock_group.name = 'tradfri_group_{}'.format(n) + return mock_group + + +async def test_group(hass, mock_gateway, mock_api): + """Test that groups are correctly added.""" + mock_gateway.mock_groups.append(mock_group()) + state = {'state': True, 'dimmer': 100} + mock_gateway.mock_groups.append(mock_group(state, 1)) + await setup_gateway(hass, mock_gateway, mock_api) + + group = hass.states.get('light.tradfri_group_0') + assert group is not None + assert group.state == 'off' + + group = hass.states.get('light.tradfri_group_1') + assert group is not None + assert group.state == 'on' + assert group.attributes['brightness'] == 100 + + +async def test_group_turn_on(hass, mock_gateway, mock_api): + """Test turning on a group.""" + group = mock_group() + group2 = mock_group(n=1) + group3 = mock_group(n=2) + mock_gateway.mock_groups.append(group) + mock_gateway.mock_groups.append(group2) + mock_gateway.mock_groups.append(group3) + await setup_gateway(hass, mock_gateway, mock_api) + + # Use the turn_off service call to change the light state. + await hass.services.async_call('light', 'turn_on', { + 'entity_id': 'light.tradfri_group_0'}, blocking=True) + await hass.services.async_call('light', 'turn_on', { + 'entity_id': 'light.tradfri_group_1', + 'brightness': 100}, blocking=True) + await hass.services.async_call('light', 'turn_on', { + 'entity_id': 'light.tradfri_group_2', + 'brightness': 100, + 'transition': 1}, blocking=True) + await hass.async_block_till_done() + + group.set_state.assert_called_with(1) + group2.set_dimmer.assert_called_with(100) + group3.set_dimmer.assert_called_with(100, transition_time=10) + + +async def test_group_turn_off(hass, mock_gateway, mock_api): + """Test turning off a group.""" + group = mock_group({'state': True}) + mock_gateway.mock_groups.append(group) + await setup_gateway(hass, mock_gateway, mock_api) + + # Use the turn_off service call to change the light state. + await hass.services.async_call('light', 'turn_off', { + 'entity_id': 'light.tradfri_group_0'}, blocking=True) + await hass.async_block_till_done() + + group.set_state.assert_called_with(0) diff --git a/tests/components/light/test_zwave.py b/tests/components/light/test_zwave.py index 4966b161360224..62bcf834b98f6a 100644 --- a/tests/components/light/test_zwave.py +++ b/tests/components/light/test_zwave.py @@ -255,6 +255,32 @@ def test_set_white_value(mock_openzwave): assert color.data == '#ffffffc800' +def test_disable_white_if_set_color(mock_openzwave): + """ + Test that _white is set to 0 if turn_on with ATTR_HS_COLOR. + + See Issue #13930 - many RGBW ZWave bulbs will only activate the RGB LED to + produce color if _white is set to zero. + """ + node = MockNode(command_classes=[const.COMMAND_CLASS_SWITCH_COLOR]) + value = MockValue(data=0, node=node) + color = MockValue(data='#0000000000', node=node) + # Supports RGB only + color_channels = MockValue(data=0x1c, node=node) + values = MockLightValues(primary=value, color=color, + color_channels=color_channels) + device = zwave.get_device(node=node, values=values, node_config={}) + device._white = 234 + + assert color.data == '#0000000000' + assert device.white_value == 234 + + device.turn_on(**{ATTR_HS_COLOR: (30, 50)}) + + assert device.white_value == 0 + assert color.data == '#ffbf7f0000' + + def test_zw098_set_color_temp(mock_openzwave): """Test setting zwave light color.""" node = MockNode(manufacturer_id='0086', product_id='0062', diff --git a/tests/components/media_player/test_blackbird.py b/tests/components/media_player/test_blackbird.py index eea6295b79eb5c..550bfe88a61e8d 100644 --- a/tests/components/media_player/test_blackbird.py +++ b/tests/components/media_player/test_blackbird.py @@ -14,7 +14,7 @@ class AttrDict(dict): - """Helper clas for mocking attributes.""" + """Helper class for mocking attributes.""" def __setattr__(self, name, value): """Set attribute.""" @@ -25,7 +25,7 @@ def __getattr__(self, item): return self[item] -class MockBlackbird(object): +class MockBlackbird: """Mock for pyblackbird object.""" def __init__(self): diff --git a/tests/components/media_player/test_cast.py b/tests/components/media_player/test_cast.py index 41cf6749b7158b..47be39c68e5836 100644 --- a/tests/components/media_player/test_cast.py +++ b/tests/components/media_player/test_cast.py @@ -17,6 +17,8 @@ from homeassistant.components.media_player import cast from homeassistant.setup import async_setup_component +from tests.common import MockConfigEntry, mock_coro + @pytest.fixture(autouse=True) def cast_mock(): @@ -359,3 +361,56 @@ async def test_disconnect_on_stop(hass: HomeAssistantType): hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) await hass.async_block_till_done() assert chromecast.disconnect.call_count == 1 + + +async def test_entry_setup_no_config(hass: HomeAssistantType): + """Test setting up entry with no config..""" + await async_setup_component(hass, 'cast', {}) + + with patch( + 'homeassistant.components.media_player.cast._async_setup_platform', + return_value=mock_coro()) as mock_setup: + await cast.async_setup_entry(hass, MockConfigEntry(), None) + + assert len(mock_setup.mock_calls) == 1 + assert mock_setup.mock_calls[0][1][1] == {} + + +async def test_entry_setup_single_config(hass: HomeAssistantType): + """Test setting up entry and having a single config option.""" + await async_setup_component(hass, 'cast', { + 'cast': { + 'media_player': { + 'host': 'bla' + } + } + }) + + with patch( + 'homeassistant.components.media_player.cast._async_setup_platform', + return_value=mock_coro()) as mock_setup: + await cast.async_setup_entry(hass, MockConfigEntry(), None) + + assert len(mock_setup.mock_calls) == 1 + assert mock_setup.mock_calls[0][1][1] == {'host': 'bla'} + + +async def test_entry_setup_list_config(hass: HomeAssistantType): + """Test setting up entry and having multiple config options.""" + await async_setup_component(hass, 'cast', { + 'cast': { + 'media_player': [ + {'host': 'bla'}, + {'host': 'blu'}, + ] + } + }) + + with patch( + 'homeassistant.components.media_player.cast._async_setup_platform', + return_value=mock_coro()) as mock_setup: + await cast.async_setup_entry(hass, MockConfigEntry(), None) + + assert len(mock_setup.mock_calls) == 2 + assert mock_setup.mock_calls[0][1][1] == {'host': 'bla'} + assert mock_setup.mock_calls[1][1][1] == {'host': 'blu'} diff --git a/tests/components/media_player/test_monoprice.py b/tests/components/media_player/test_monoprice.py index 399cdc67ca65e2..14e1769047a72b 100644 --- a/tests/components/media_player/test_monoprice.py +++ b/tests/components/media_player/test_monoprice.py @@ -27,7 +27,7 @@ def __getattr__(self, item): return self[item] -class MockMonoprice(object): +class MockMonoprice: """Mock for pymonoprice object.""" def __init__(self): diff --git a/tests/components/media_player/test_samsungtv.py b/tests/components/media_player/test_samsungtv.py index c3753eb53b52b7..349067f7cd30c8 100644 --- a/tests/components/media_player/test_samsungtv.py +++ b/tests/components/media_player/test_samsungtv.py @@ -1,11 +1,16 @@ """Tests for samsungtv Components.""" +import asyncio import unittest +from unittest.mock import call, patch, MagicMock from subprocess import CalledProcessError from asynctest import mock +import pytest + import tests.common -from homeassistant.components.media_player import SUPPORT_TURN_ON +from homeassistant.components.media_player import SUPPORT_TURN_ON, \ + MEDIA_TYPE_CHANNEL, MEDIA_TYPE_URL from homeassistant.components.media_player.samsungtv import setup_platform, \ CONF_TIMEOUT, SamsungTVDevice, SUPPORT_SAMSUNGTV from homeassistant.const import CONF_HOST, CONF_NAME, CONF_PORT, STATE_ON, \ @@ -29,7 +34,15 @@ } -class PackageException(Exception): +class AccessDenied(Exception): + """Dummy Exception.""" + + +class ConnectionClosed(Exception): + """Dummy Exception.""" + + +class UnhandledResponse(Exception): """Dummy Exception.""" @@ -45,9 +58,9 @@ def setUp(self, samsung_mock, wol_mock): self.hass.block_till_done() self.device = SamsungTVDevice(**WORKING_CONFIG) self.device._exceptions_class = mock.Mock() - self.device._exceptions_class.UnhandledResponse = PackageException - self.device._exceptions_class.AccessDenied = PackageException - self.device._exceptions_class.ConnectionClosed = PackageException + self.device._exceptions_class.UnhandledResponse = UnhandledResponse + self.device._exceptions_class.AccessDenied = AccessDenied + self.device._exceptions_class.ConnectionClosed = ConnectionClosed def tearDown(self): """Tear down test data.""" @@ -123,22 +136,46 @@ def test_send_key(self): def test_send_key_broken_pipe(self): """Testing broken pipe Exception.""" _remote = mock.Mock() - self.device.get_remote = mock.Mock() _remote.control = mock.Mock( - side_effect=BrokenPipeError("Boom")) - self.device.get_remote.return_value = _remote - self.device.send_key("HELLO") + side_effect=BrokenPipeError('Boom')) + self.device.get_remote = mock.Mock(return_value=_remote) + self.device.send_key('HELLO') + self.assertIsNone(self.device._remote) + self.assertEqual(STATE_ON, self.device._state) + + def test_send_key_connection_closed_retry_succeed(self): + """Test retry on connection closed.""" + _remote = mock.Mock() + _remote.control = mock.Mock(side_effect=[ + self.device._exceptions_class.ConnectionClosed('Boom'), + mock.DEFAULT]) + self.device.get_remote = mock.Mock(return_value=_remote) + command = 'HELLO' + self.device.send_key(command) + self.assertEqual(STATE_ON, self.device._state) + # verify that _remote.control() get called twice because of retry logic + expected = [mock.call(command), + mock.call(command)] + self.assertEqual(expected, _remote.control.call_args_list) + + def test_send_key_unhandled_response(self): + """Testing unhandled response exception.""" + _remote = mock.Mock() + _remote.control = mock.Mock( + side_effect=self.device._exceptions_class.UnhandledResponse('Boom') + ) + self.device.get_remote = mock.Mock(return_value=_remote) + self.device.send_key('HELLO') self.assertIsNone(self.device._remote) self.assertEqual(STATE_ON, self.device._state) def test_send_key_os_error(self): """Testing broken pipe Exception.""" _remote = mock.Mock() - self.device.get_remote = mock.Mock() _remote.control = mock.Mock( - side_effect=OSError("Boom")) - self.device.get_remote.return_value = _remote - self.device.send_key("HELLO") + side_effect=OSError('Boom')) + self.device.get_remote = mock.Mock(return_value=_remote) + self.device.send_key('HELLO') self.assertIsNone(self.device._remote) self.assertEqual(STATE_OFF, self.device._state) @@ -269,3 +306,59 @@ def test_turn_on(self): self.device._mac = "fake" self.device.turn_on() self.device._wol.send_magic_packet.assert_called_once_with("fake") + + +@pytest.fixture +def samsung_mock(): + """Mock samsungctl.""" + with patch.dict('sys.modules', { + 'samsungctl': MagicMock(), + }): + yield + + +async def test_play_media(hass, samsung_mock): + """Test for play_media.""" + asyncio_sleep = asyncio.sleep + sleeps = [] + + async def sleep(duration, loop): + sleeps.append(duration) + await asyncio_sleep(0, loop=loop) + + with patch('asyncio.sleep', new=sleep): + device = SamsungTVDevice(**WORKING_CONFIG) + device.hass = hass + + device.send_key = mock.Mock() + await device.async_play_media(MEDIA_TYPE_CHANNEL, "576") + + exp = [call("KEY_5"), call("KEY_7"), call("KEY_6")] + assert device.send_key.call_args_list == exp + assert len(sleeps) == 3 + + +async def test_play_media_invalid_type(hass, samsung_mock): + """Test for play_media with invalid media type.""" + url = "https://example.com" + device = SamsungTVDevice(**WORKING_CONFIG) + device.send_key = mock.Mock() + await device.async_play_media(MEDIA_TYPE_URL, url) + assert device.send_key.call_count == 0 + + +async def test_play_media_channel_as_string(hass, samsung_mock): + """Test for play_media with invalid channel as string.""" + url = "https://example.com" + device = SamsungTVDevice(**WORKING_CONFIG) + device.send_key = mock.Mock() + await device.async_play_media(MEDIA_TYPE_CHANNEL, url) + assert device.send_key.call_count == 0 + + +async def test_play_media_channel_as_non_positive(hass, samsung_mock): + """Test for play_media with invalid channel as non positive integer.""" + device = SamsungTVDevice(**WORKING_CONFIG) + device.send_key = mock.Mock() + await device.async_play_media(MEDIA_TYPE_CHANNEL, "-4") + assert device.send_key.call_count == 0 diff --git a/tests/components/media_player/test_yamaha.py b/tests/components/media_player/test_yamaha.py index e17241485db8b0..980284737a2b1e 100644 --- a/tests/components/media_player/test_yamaha.py +++ b/tests/components/media_player/test_yamaha.py @@ -15,7 +15,7 @@ def _create_zone_mock(name, url): return zone -class FakeYamahaDevice(object): +class FakeYamahaDevice: """A fake Yamaha device.""" def __init__(self, ctrl_url, name, zones=None): diff --git a/tests/components/mqtt/test_discovery.py b/tests/components/mqtt/test_discovery.py index 1dd29909ffdfb4..9e0ef14a3faca5 100644 --- a/tests/components/mqtt/test_discovery.py +++ b/tests/components/mqtt/test_discovery.py @@ -52,12 +52,21 @@ def test_invalid_json(mock_load_platform, hass, mqtt_mock, caplog): @asyncio.coroutine def test_only_valid_components(mock_load_platform, hass, mqtt_mock, caplog): """Test for a valid component.""" + invalid_component = "timer" + mock_load_platform.return_value = mock_coro() yield from async_start(hass, 'homeassistant', {}) - async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config', '{}') + async_fire_mqtt_message(hass, 'homeassistant/{}/bla/config'.format( + invalid_component + ), '{}') + yield from hass.async_block_till_done() - assert 'Component climate is not supported' in caplog.text + + assert 'Component {} is not supported'.format( + invalid_component + ) in caplog.text + assert not mock_load_platform.called @@ -94,6 +103,49 @@ def test_discover_fan(hass, mqtt_mock, caplog): assert ('fan', 'bla') in hass.data[ALREADY_DISCOVERED] +@asyncio.coroutine +def test_discover_climate(hass, mqtt_mock, caplog): + """Test discovering an MQTT climate component.""" + yield from async_start(hass, 'homeassistant', {}) + + data = ( + '{ "name": "ClimateTest",' + ' "current_temperature_topic": "climate/bla/current_temp",' + ' "temperature_command_topic": "climate/bla/target_temp" }' + ) + + async_fire_mqtt_message(hass, 'homeassistant/climate/bla/config', data) + yield from hass.async_block_till_done() + + state = hass.states.get('climate.ClimateTest') + + assert state is not None + assert state.name == 'ClimateTest' + assert ('climate', 'bla') in hass.data[ALREADY_DISCOVERED] + + +@asyncio.coroutine +def test_discover_alarm_control_panel(hass, mqtt_mock, caplog): + """Test discovering an MQTT alarm control panel component.""" + yield from async_start(hass, 'homeassistant', {}) + + data = ( + '{ "name": "AlarmControlPanelTest",' + ' "state_topic": "test_topic",' + ' "command_topic": "test_topic" }' + ) + + async_fire_mqtt_message( + hass, 'homeassistant/alarm_control_panel/bla/config', data) + yield from hass.async_block_till_done() + + state = hass.states.get('alarm_control_panel.AlarmControlPanelTest') + + assert state is not None + assert state.name == 'AlarmControlPanelTest' + assert ('alarm_control_panel', 'bla') in hass.data[ALREADY_DISCOVERED] + + @asyncio.coroutine def test_discovery_incl_nodeid(hass, mqtt_mock, caplog): """Test sending in correct JSON with optional node_id included.""" diff --git a/tests/components/mqtt/test_server.py b/tests/components/mqtt/test_server.py index 9b4c0c69ac63f2..d5d54f457d683e 100644 --- a/tests/components/mqtt/test_server.py +++ b/tests/components/mqtt/test_server.py @@ -1,21 +1,25 @@ """The tests for the MQTT component embedded server.""" from unittest.mock import Mock, MagicMock, patch +import sys +import pytest + +from homeassistant.const import CONF_PASSWORD from homeassistant.setup import setup_component import homeassistant.components.mqtt as mqtt from tests.common import get_test_home_assistant, mock_coro +# Until https://github.com/beerfactory/hbmqtt/pull/139 is released +@pytest.mark.skipif(sys.version_info[:2] >= (3, 7), + reason='Package incompatible with Python 3.7') class TestMQTT: """Test the MQTT component.""" def setup_method(self, method): """Setup things to be run when tests are started.""" self.hass = get_test_home_assistant() - setup_component(self.hass, 'http', { - 'api_password': 'super_secret' - }) def teardown_method(self, method): """Stop everything that was started.""" @@ -26,14 +30,61 @@ def teardown_method(self, method): @patch('hbmqtt.broker.Broker', Mock(return_value=MagicMock())) @patch('hbmqtt.broker.Broker.start', Mock(return_value=mock_coro())) @patch('homeassistant.components.mqtt.MQTT') - def test_creating_config_with_http_pass(self, mock_mqtt): - """Test if the MQTT server gets started and subscribe/publish msg.""" + def test_creating_config_with_http_pass_only(self, mock_mqtt): + """Test if the MQTT server failed starts. + + Since 0.77, MQTT server has to setup its own password. + If user has api_password but don't have mqtt.password, MQTT component + will fail to start + """ mock_mqtt().async_connect.return_value = mock_coro(True) self.hass.bus.listen_once = MagicMock() - password = 'super_secret' + assert not setup_component(self.hass, mqtt.DOMAIN, { + 'http': {'api_password': 'http_secret'} + }) - self.hass.config.api = MagicMock(api_password=password) - assert setup_component(self.hass, mqtt.DOMAIN, {}) + @patch('passlib.apps.custom_app_context', Mock(return_value='')) + @patch('tempfile.NamedTemporaryFile', Mock(return_value=MagicMock())) + @patch('hbmqtt.broker.Broker', Mock(return_value=MagicMock())) + @patch('hbmqtt.broker.Broker.start', Mock(return_value=mock_coro())) + @patch('homeassistant.components.mqtt.MQTT') + def test_creating_config_with_pass_and_no_http_pass(self, mock_mqtt): + """Test if the MQTT server gets started with password. + + Since 0.77, MQTT server has to setup its own password. + """ + mock_mqtt().async_connect.return_value = mock_coro(True) + self.hass.bus.listen_once = MagicMock() + password = 'mqtt_secret' + + assert setup_component(self.hass, mqtt.DOMAIN, { + mqtt.DOMAIN: {CONF_PASSWORD: password}, + }) + assert mock_mqtt.called + from pprint import pprint + pprint(mock_mqtt.mock_calls) + assert mock_mqtt.mock_calls[1][1][5] == 'homeassistant' + assert mock_mqtt.mock_calls[1][1][6] == password + + @patch('passlib.apps.custom_app_context', Mock(return_value='')) + @patch('tempfile.NamedTemporaryFile', Mock(return_value=MagicMock())) + @patch('hbmqtt.broker.Broker', Mock(return_value=MagicMock())) + @patch('hbmqtt.broker.Broker.start', Mock(return_value=mock_coro())) + @patch('homeassistant.components.mqtt.MQTT') + def test_creating_config_with_pass_and_http_pass(self, mock_mqtt): + """Test if the MQTT server gets started with password. + + Since 0.77, MQTT server has to setup its own password. + """ + mock_mqtt().async_connect.return_value = mock_coro(True) + self.hass.bus.listen_once = MagicMock() + password = 'mqtt_secret' + + self.hass.config.api = MagicMock(api_password='api_password') + assert setup_component(self.hass, mqtt.DOMAIN, { + 'http': {'api_password': 'http_secret'}, + mqtt.DOMAIN: {CONF_PASSWORD: password}, + }) assert mock_mqtt.called from pprint import pprint pprint(mock_mqtt.mock_calls) @@ -45,8 +96,8 @@ def test_creating_config_with_http_pass(self, mock_mqtt): @patch('hbmqtt.broker.Broker', Mock(return_value=MagicMock())) @patch('hbmqtt.broker.Broker.start', Mock(return_value=mock_coro())) @patch('homeassistant.components.mqtt.MQTT') - def test_creating_config_with_http_no_pass(self, mock_mqtt): - """Test if the MQTT server gets started and subscribe/publish msg.""" + def test_creating_config_without_pass(self, mock_mqtt): + """Test if the MQTT server gets started without password.""" mock_mqtt().async_connect.return_value = mock_coro(True) self.hass.bus.listen_once = MagicMock() diff --git a/tests/components/nest/__init__.py b/tests/components/nest/__init__.py new file mode 100644 index 00000000000000..313cfccc76169d --- /dev/null +++ b/tests/components/nest/__init__.py @@ -0,0 +1 @@ +"""Tests for the Nest component.""" diff --git a/tests/components/nest/test_config_flow.py b/tests/components/nest/test_config_flow.py new file mode 100644 index 00000000000000..e80d18a98626e7 --- /dev/null +++ b/tests/components/nest/test_config_flow.py @@ -0,0 +1,218 @@ +"""Tests for the Nest config flow.""" +import asyncio +from unittest.mock import Mock, patch + +from homeassistant import data_entry_flow +from homeassistant.setup import async_setup_component +from homeassistant.components.nest import config_flow, DOMAIN + +from tests.common import mock_coro + + +async def test_abort_if_no_implementation_registered(hass): + """Test we abort if no implementation is registered.""" + flow = config_flow.NestFlowHandler() + flow.hass = hass + result = await flow.async_step_init() + + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'no_flows' + + +async def test_abort_if_already_setup(hass): + """Test we abort if Nest is already setup.""" + flow = config_flow.NestFlowHandler() + flow.hass = hass + + with patch.object(hass.config_entries, 'async_entries', return_value=[{}]): + result = await flow.async_step_init() + + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'already_setup' + + +async def test_full_flow_implementation(hass): + """Test registering an implementation and finishing flow works.""" + gen_authorize_url = Mock(return_value=mock_coro('https://example.com')) + convert_code = Mock(return_value=mock_coro({'access_token': 'yoo'})) + config_flow.register_flow_implementation( + hass, 'test', 'Test', gen_authorize_url, convert_code) + config_flow.register_flow_implementation( + hass, 'test-other', 'Test Other', None, None) + + flow = config_flow.NestFlowHandler() + flow.hass = hass + result = await flow.async_step_init() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'init' + + result = await flow.async_step_init({'flow_impl': 'test'}) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'link' + assert result['description_placeholders'] == { + 'url': 'https://example.com', + } + + result = await flow.async_step_link({'code': '123ABC'}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['data']['tokens'] == {'access_token': 'yoo'} + assert result['data']['impl_domain'] == 'test' + assert result['title'] == 'Nest (via Test)' + + +async def test_not_pick_implementation_if_only_one(hass): + """Test we allow picking implementation if we have two.""" + gen_authorize_url = Mock(return_value=mock_coro('https://example.com')) + config_flow.register_flow_implementation( + hass, 'test', 'Test', gen_authorize_url, None) + + flow = config_flow.NestFlowHandler() + flow.hass = hass + result = await flow.async_step_init() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'link' + + +async def test_abort_if_timeout_generating_auth_url(hass): + """Test we abort if generating authorize url fails.""" + gen_authorize_url = Mock(side_effect=asyncio.TimeoutError) + config_flow.register_flow_implementation( + hass, 'test', 'Test', gen_authorize_url, None) + + flow = config_flow.NestFlowHandler() + flow.hass = hass + result = await flow.async_step_init() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'authorize_url_timeout' + + +async def test_abort_if_exception_generating_auth_url(hass): + """Test we abort if generating authorize url blows up.""" + gen_authorize_url = Mock(side_effect=ValueError) + config_flow.register_flow_implementation( + hass, 'test', 'Test', gen_authorize_url, None) + + flow = config_flow.NestFlowHandler() + flow.hass = hass + result = await flow.async_step_init() + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'authorize_url_fail' + + +async def test_verify_code_timeout(hass): + """Test verify code timing out.""" + gen_authorize_url = Mock(return_value=mock_coro('https://example.com')) + convert_code = Mock(side_effect=asyncio.TimeoutError) + config_flow.register_flow_implementation( + hass, 'test', 'Test', gen_authorize_url, convert_code) + + flow = config_flow.NestFlowHandler() + flow.hass = hass + result = await flow.async_step_init() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'link' + + result = await flow.async_step_link({'code': '123ABC'}) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'link' + assert result['errors'] == {'code': 'timeout'} + + +async def test_verify_code_invalid(hass): + """Test verify code invalid.""" + gen_authorize_url = Mock(return_value=mock_coro('https://example.com')) + convert_code = Mock(side_effect=config_flow.CodeInvalid) + config_flow.register_flow_implementation( + hass, 'test', 'Test', gen_authorize_url, convert_code) + + flow = config_flow.NestFlowHandler() + flow.hass = hass + result = await flow.async_step_init() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'link' + + result = await flow.async_step_link({'code': '123ABC'}) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'link' + assert result['errors'] == {'code': 'invalid_code'} + + +async def test_verify_code_unknown_error(hass): + """Test verify code unknown error.""" + gen_authorize_url = Mock(return_value=mock_coro('https://example.com')) + convert_code = Mock(side_effect=config_flow.NestAuthError) + config_flow.register_flow_implementation( + hass, 'test', 'Test', gen_authorize_url, convert_code) + + flow = config_flow.NestFlowHandler() + flow.hass = hass + result = await flow.async_step_init() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'link' + + result = await flow.async_step_link({'code': '123ABC'}) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'link' + assert result['errors'] == {'code': 'unknown'} + + +async def test_verify_code_exception(hass): + """Test verify code blows up.""" + gen_authorize_url = Mock(return_value=mock_coro('https://example.com')) + convert_code = Mock(side_effect=ValueError) + config_flow.register_flow_implementation( + hass, 'test', 'Test', gen_authorize_url, convert_code) + + flow = config_flow.NestFlowHandler() + flow.hass = hass + result = await flow.async_step_init() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'link' + + result = await flow.async_step_link({'code': '123ABC'}) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'link' + assert result['errors'] == {'code': 'internal_error'} + + +async def test_step_import(hass): + """Test that we trigger import when configuring with client.""" + with patch('os.path.isfile', return_value=False): + assert await async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'client_id': 'bla', + 'client_secret': 'bla', + }, + }) + await hass.async_block_till_done() + + flow = hass.config_entries.flow.async_progress()[0] + result = await hass.config_entries.flow.async_configure(flow['flow_id']) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'link' + + +async def test_step_import_with_token_cache(hass): + """Test that we import existing token cache.""" + with patch('os.path.isfile', return_value=True), \ + patch('homeassistant.components.nest.config_flow.load_json', + return_value={'access_token': 'yo'}), \ + patch('homeassistant.components.nest.async_setup_entry', + return_value=mock_coro(True)): + assert await async_setup_component(hass, DOMAIN, { + DOMAIN: { + 'client_id': 'bla', + 'client_secret': 'bla', + }, + }) + await hass.async_block_till_done() + + entry = hass.config_entries.async_entries(DOMAIN)[0] + + assert entry.data == { + 'impl_domain': 'nest', + 'tokens': { + 'access_token': 'yo' + } + } diff --git a/tests/components/nest/test_local_auth.py b/tests/components/nest/test_local_auth.py new file mode 100644 index 00000000000000..44a5299b33dbd3 --- /dev/null +++ b/tests/components/nest/test_local_auth.py @@ -0,0 +1,51 @@ +"""Test Nest local auth.""" +from homeassistant.components.nest import const, config_flow, local_auth +from urllib.parse import parse_qsl + +import pytest + +import requests_mock as rmock + + +@pytest.fixture +def registered_flow(hass): + """Mock a registered flow.""" + local_auth.initialize(hass, 'TEST-CLIENT-ID', 'TEST-CLIENT-SECRET') + return hass.data[config_flow.DATA_FLOW_IMPL][const.DOMAIN] + + +async def test_generate_auth_url(registered_flow): + """Test generating an auth url. + + Mainly testing that it doesn't blow up. + """ + url = await registered_flow['gen_authorize_url']('TEST-FLOW-ID') + assert url is not None + + +async def test_convert_code(requests_mock, registered_flow): + """Test converting a code.""" + from nest.nest import ACCESS_TOKEN_URL + + def token_matcher(request): + """Match a fetch token request.""" + if request.url != ACCESS_TOKEN_URL: + return None + + assert dict(parse_qsl(request.text)) == { + 'client_id': 'TEST-CLIENT-ID', + 'client_secret': 'TEST-CLIENT-SECRET', + 'code': 'TEST-CODE', + 'grant_type': 'authorization_code' + } + + return rmock.create_response(request, json={ + 'access_token': 'TEST-ACCESS-TOKEN' + }) + + requests_mock.add_matcher(token_matcher) + + tokens = await registered_flow['convert_code']('TEST-CODE') + assert tokens == { + 'access_token': 'TEST-ACCESS-TOKEN' + } diff --git a/tests/components/notify/test_demo.py b/tests/components/notify/test_demo.py index 5bd3270b9223d4..71b472afe74c47 100644 --- a/tests/components/notify/test_demo.py +++ b/tests/components/notify/test_demo.py @@ -33,7 +33,7 @@ def record_event(event): self.hass.bus.listen(demo.EVENT_NOTIFY, record_event) def tearDown(self): # pylint: disable=invalid-name - """"Stop down everything that was started.""" + """Stop down everything that was started.""" self.hass.stop() def _setup_notify(self): diff --git a/tests/components/notify/test_file.py b/tests/components/notify/test_file.py index c5064fca851bc3..d59bbe4d720ac4 100644 --- a/tests/components/notify/test_file.py +++ b/tests/components/notify/test_file.py @@ -20,7 +20,7 @@ def setUp(self): # pylint: disable=invalid-name self.hass = get_test_home_assistant() def tearDown(self): # pylint: disable=invalid-name - """"Stop down everything that was started.""" + """Stop down everything that was started.""" self.hass.stop() def test_bad_config(self): diff --git a/tests/components/notify/test_group.py b/tests/components/notify/test_group.py index c96a49d7cb3d3c..8e7ef4348f7dfd 100644 --- a/tests/components/notify/test_group.py +++ b/tests/components/notify/test_group.py @@ -26,8 +26,7 @@ def setUp(self): # pylint: disable=invalid-name def mock_get_service(hass, config, discovery_info=None): if config['name'] == 'demo1': return self.service1 - else: - return self.service2 + return self.service2 with assert_setup_component(2), \ patch.object(demo, 'get_service', mock_get_service): @@ -53,7 +52,7 @@ def mock_get_service(hass, config, discovery_info=None): assert self.service is not None def tearDown(self): # pylint: disable=invalid-name - """"Stop everything that was started.""" + """Stop everything that was started.""" self.hass.stop() def test_send_message_with_data(self): diff --git a/tests/components/notify/test_html5.py b/tests/components/notify/test_html5.py index 318f3c7512ca70..486300679b7414 100644 --- a/tests/components/notify/test_html5.py +++ b/tests/components/notify/test_html5.py @@ -65,7 +65,7 @@ async def mock_client(hass, aiohttp_client, registrations=None): return await aiohttp_client(hass.http.app) -class TestHtml5Notify(object): +class TestHtml5Notify: """Tests for HTML5 notify platform.""" def test_get_service_with_no_json(self): diff --git a/tests/components/notify/test_smtp.py b/tests/components/notify/test_smtp.py index 127eecae2b7755..29e34974c6c50f 100644 --- a/tests/components/notify/test_smtp.py +++ b/tests/components/notify/test_smtp.py @@ -27,7 +27,7 @@ def setUp(self): # pylint: disable=invalid-name 'HomeAssistant', 0) def tearDown(self): # pylint: disable=invalid-name - """"Stop down everything that was started.""" + """Stop down everything that was started.""" self.hass.stop() @patch('email.utils.make_msgid', return_value='') diff --git a/tests/components/onboarding/__init__.py b/tests/components/onboarding/__init__.py new file mode 100644 index 00000000000000..62c6dc929a1052 --- /dev/null +++ b/tests/components/onboarding/__init__.py @@ -0,0 +1,11 @@ +"""Tests for the onboarding component.""" + +from homeassistant.components import onboarding + + +def mock_storage(hass_storage, data): + """Mock the onboarding storage.""" + hass_storage[onboarding.STORAGE_KEY] = { + 'version': onboarding.STORAGE_VERSION, + 'data': data + } diff --git a/tests/components/onboarding/test_init.py b/tests/components/onboarding/test_init.py new file mode 100644 index 00000000000000..57a81a78da34b7 --- /dev/null +++ b/tests/components/onboarding/test_init.py @@ -0,0 +1,77 @@ +"""Tests for the init.""" +from unittest.mock import patch, Mock + +from homeassistant.setup import async_setup_component +from homeassistant.components import onboarding + +from tests.common import mock_coro, MockUser + +from . import mock_storage + +# Temporarily: if auth not active, always set onboarded=True + + +async def test_not_setup_views_if_onboarded(hass, hass_storage): + """Test if onboarding is done, we don't setup views.""" + mock_storage(hass_storage, { + 'done': onboarding.STEPS + }) + + with patch( + 'homeassistant.components.onboarding.views.async_setup' + ) as mock_setup: + assert await async_setup_component(hass, 'onboarding', {}) + + assert len(mock_setup.mock_calls) == 0 + assert onboarding.DOMAIN not in hass.data + assert onboarding.async_is_onboarded(hass) + + +async def test_setup_views_if_not_onboarded(hass): + """Test if onboarding is not done, we setup views.""" + with patch( + 'homeassistant.components.onboarding.views.async_setup', + return_value=mock_coro() + ) as mock_setup: + assert await async_setup_component(hass, 'onboarding', {}) + + assert len(mock_setup.mock_calls) == 1 + assert onboarding.DOMAIN in hass.data + + with patch('homeassistant.auth.AuthManager.active', return_value=True): + assert not onboarding.async_is_onboarded(hass) + + +async def test_is_onboarded(): + """Test the is onboarded function.""" + hass = Mock() + hass.data = {} + + with patch('homeassistant.auth.AuthManager.active', return_value=False): + assert onboarding.async_is_onboarded(hass) + + with patch('homeassistant.auth.AuthManager.active', return_value=True): + assert onboarding.async_is_onboarded(hass) + + hass.data[onboarding.DOMAIN] = True + assert onboarding.async_is_onboarded(hass) + + hass.data[onboarding.DOMAIN] = False + assert not onboarding.async_is_onboarded(hass) + + +async def test_having_owner_finishes_user_step(hass, hass_storage): + """If owner user already exists, mark user step as complete.""" + MockUser(is_owner=True).add_to_hass(hass) + + with patch( + 'homeassistant.components.onboarding.views.async_setup' + ) as mock_setup, patch.object(onboarding, 'STEPS', [onboarding.STEP_USER]): + assert await async_setup_component(hass, 'onboarding', {}) + + assert len(mock_setup.mock_calls) == 0 + assert onboarding.DOMAIN not in hass.data + assert onboarding.async_is_onboarded(hass) + + done = hass_storage[onboarding.STORAGE_KEY]['data']['done'] + assert onboarding.STEP_USER in done diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py new file mode 100644 index 00000000000000..d6a4030190de42 --- /dev/null +++ b/tests/components/onboarding/test_views.py @@ -0,0 +1,137 @@ +"""Test the onboarding views.""" +import asyncio +from unittest.mock import patch + +import pytest + +from homeassistant.setup import async_setup_component +from homeassistant.components import onboarding +from homeassistant.components.onboarding import views + +from tests.common import register_auth_provider + +from . import mock_storage + + +@pytest.fixture(autouse=True) +def auth_active(hass): + """Ensure auth is always active.""" + hass.loop.run_until_complete(register_auth_provider(hass, { + 'type': 'homeassistant' + })) + + +async def test_onboarding_progress(hass, hass_storage, aiohttp_client): + """Test fetching progress.""" + mock_storage(hass_storage, { + 'done': ['hello'] + }) + + assert await async_setup_component(hass, 'onboarding', {}) + client = await aiohttp_client(hass.http.app) + + with patch.object(views, 'STEPS', ['hello', 'world']): + resp = await client.get('/api/onboarding') + + assert resp.status == 200 + data = await resp.json() + assert len(data) == 2 + assert data[0] == { + 'step': 'hello', + 'done': True + } + assert data[1] == { + 'step': 'world', + 'done': False + } + + +async def test_onboarding_user_already_done(hass, hass_storage, + aiohttp_client): + """Test creating a new user when user step already done.""" + mock_storage(hass_storage, { + 'done': [views.STEP_USER] + }) + + with patch.object(onboarding, 'STEPS', ['hello', 'world']): + assert await async_setup_component(hass, 'onboarding', {}) + + client = await aiohttp_client(hass.http.app) + + resp = await client.post('/api/onboarding/users', json={ + 'name': 'Test Name', + 'username': 'test-user', + 'password': 'test-pass', + }) + + assert resp.status == 403 + + +async def test_onboarding_user(hass, hass_storage, aiohttp_client): + """Test creating a new user.""" + mock_storage(hass_storage, { + 'done': ['hello'] + }) + + assert await async_setup_component(hass, 'onboarding', {}) + + client = await aiohttp_client(hass.http.app) + + resp = await client.post('/api/onboarding/users', json={ + 'name': 'Test Name', + 'username': 'test-user', + 'password': 'test-pass', + }) + + assert resp.status == 200 + users = await hass.auth.async_get_users() + assert len(users) == 1 + user = users[0] + assert user.name == 'Test Name' + assert len(user.credentials) == 1 + assert user.credentials[0].data['username'] == 'test-user' + + +async def test_onboarding_user_invalid_name(hass, hass_storage, + aiohttp_client): + """Test not providing name.""" + mock_storage(hass_storage, { + 'done': ['hello'] + }) + + assert await async_setup_component(hass, 'onboarding', {}) + + client = await aiohttp_client(hass.http.app) + + resp = await client.post('/api/onboarding/users', json={ + 'username': 'test-user', + 'password': 'test-pass', + }) + + assert resp.status == 400 + + +async def test_onboarding_user_race(hass, hass_storage, aiohttp_client): + """Test race condition on creating new user.""" + mock_storage(hass_storage, { + 'done': ['hello'] + }) + + assert await async_setup_component(hass, 'onboarding', {}) + + client = await aiohttp_client(hass.http.app) + + resp1 = client.post('/api/onboarding/users', json={ + 'name': 'Test 1', + 'username': '1-user', + 'password': '1-pass', + }) + resp2 = client.post('/api/onboarding/users', json={ + 'name': 'Test 2', + 'username': '2-user', + 'password': '2-pass', + }) + + res1, res2 = await asyncio.gather(resp1, resp2) + + assert sorted([res1.status, res2.status]) == [200, 403] diff --git a/tests/components/recorder/models_original.py b/tests/components/recorder/models_original.py index 31ec5ee7ed7320..990414d77135d0 100644 --- a/tests/components/recorder/models_original.py +++ b/tests/components/recorder/models_original.py @@ -157,7 +157,6 @@ def _process_timestamp(ts): """Process a timestamp into datetime object.""" if ts is None: return None - elif ts.tzinfo is None: + if ts.tzinfo is None: return dt_util.UTC.localize(ts) - else: - return dt_util.as_utc(ts) + return dt_util.as_utc(ts) diff --git a/tests/components/recorder/test_migrate.py b/tests/components/recorder/test_migrate.py index 5ac9b3adb817c8..93da4ec109bd87 100644 --- a/tests/components/recorder/test_migrate.py +++ b/tests/components/recorder/test_migrate.py @@ -5,11 +5,11 @@ import pytest from sqlalchemy import create_engine +from sqlalchemy.pool import StaticPool from homeassistant.bootstrap import async_setup_component -from homeassistant.components.recorder import wait_connection_ready, migration -from homeassistant.components.recorder.models import SCHEMA_VERSION -from homeassistant.components.recorder.const import DATA_INSTANCE +from homeassistant.components.recorder import ( + wait_connection_ready, migration, const, models) from tests.components.recorder import models_original @@ -37,8 +37,8 @@ def test_schema_update_calls(hass): yield from wait_connection_ready(hass) update.assert_has_calls([ - call(hass.data[DATA_INSTANCE].engine, version+1, 0) for version - in range(0, SCHEMA_VERSION)]) + call(hass.data[const.DATA_INSTANCE].engine, version+1, 0) for version + in range(0, models.SCHEMA_VERSION)]) @asyncio.coroutine @@ -65,3 +65,28 @@ def test_invalid_update(): """Test that an invalid new version raises an exception.""" with pytest.raises(ValueError): migration._apply_update(None, -1, 0) + + +def test_forgiving_add_column(): + """Test that add column will continue if column exists.""" + engine = create_engine( + 'sqlite://', + poolclass=StaticPool + ) + engine.execute('CREATE TABLE hello (id int)') + migration._add_columns(engine, 'hello', [ + 'context_id CHARACTER(36)', + ]) + migration._add_columns(engine, 'hello', [ + 'context_id CHARACTER(36)', + ]) + + +def test_forgiving_add_index(): + """Test that add index will continue if index exists.""" + engine = create_engine( + 'sqlite://', + poolclass=StaticPool + ) + models.Base.metadata.create_all(engine) + migration._create_index(engine, "states", "ix_states_context_id") diff --git a/tests/components/recorder/test_models.py b/tests/components/recorder/test_models.py index c616f3d0af1b9e..3d1beb3a642bdd 100644 --- a/tests/components/recorder/test_models.py +++ b/tests/components/recorder/test_models.py @@ -60,7 +60,7 @@ def test_from_event(self): 'entity_id': 'sensor.temperature', 'old_state': None, 'new_state': state, - }) + }, context=state.context) assert state == States.from_event(event).to_native() def test_from_event_to_delete_state(self): diff --git a/tests/components/sensor/test_arlo.py b/tests/components/sensor/test_arlo.py new file mode 100644 index 00000000000000..d31490ab2afa10 --- /dev/null +++ b/tests/components/sensor/test_arlo.py @@ -0,0 +1,240 @@ +"""The tests for the Netgear Arlo sensors.""" +from collections import namedtuple +from unittest.mock import patch, MagicMock +import pytest +from homeassistant.const import ( + DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_HUMIDITY, ATTR_ATTRIBUTION) +from homeassistant.components.sensor import arlo +from homeassistant.components.arlo import DATA_ARLO + + +def _get_named_tuple(input_dict): + return namedtuple('Struct', input_dict.keys())(*input_dict.values()) + + +def _get_sensor(name='Last', sensor_type='last_capture', data=None): + if data is None: + data = {} + return arlo.ArloSensor(name, data, sensor_type) + + +@pytest.fixture() +def default_sensor(): + """Create an ArloSensor with default values.""" + return _get_sensor() + + +@pytest.fixture() +def battery_sensor(): + """Create an ArloSensor with battery data.""" + data = _get_named_tuple({ + 'battery_level': 50 + }) + return _get_sensor('Battery Level', 'battery_level', data) + + +@pytest.fixture() +def temperature_sensor(): + """Create a temperature ArloSensor.""" + return _get_sensor('Temperature', 'temperature') + + +@pytest.fixture() +def humidity_sensor(): + """Create a humidity ArloSensor.""" + return _get_sensor('Humidity', 'humidity') + + +@pytest.fixture() +def cameras_sensor(): + """Create a total cameras ArloSensor.""" + data = _get_named_tuple({ + 'cameras': [0, 0] + }) + return _get_sensor('Arlo Cameras', 'total_cameras', data) + + +@pytest.fixture() +def captured_sensor(): + """Create a captured today ArloSensor.""" + data = _get_named_tuple({ + 'captured_today': [0, 0, 0, 0, 0] + }) + return _get_sensor('Captured Today', 'captured_today', data) + + +class PlatformSetupFixture(): + """Fixture for testing platform setup call to add_devices().""" + + def __init__(self): + """Instantiate the platform setup fixture.""" + self.sensors = None + self.update = False + + def add_devices(self, sensors, update): + """Mock method for adding devices.""" + self.sensors = sensors + self.update = update + + +@pytest.fixture() +def platform_setup(): + """Create an instance of the PlatformSetupFixture class.""" + return PlatformSetupFixture() + + +@pytest.fixture() +def sensor_with_hass_data(default_sensor, hass): + """Create a sensor with async_dispatcher_connected mocked.""" + hass.data = {} + default_sensor.hass = hass + return default_sensor + + +@pytest.fixture() +def mock_dispatch(): + """Mock the dispatcher connect method.""" + target = 'homeassistant.components.sensor.arlo.async_dispatcher_connect' + with patch(target, MagicMock()) as _mock: + yield _mock + + +def test_setup_with_no_data(platform_setup, hass): + """Test setup_platform with no data.""" + arlo.setup_platform(hass, None, platform_setup.add_devices) + assert platform_setup.sensors is None + assert not platform_setup.update + + +def test_setup_with_valid_data(platform_setup, hass): + """Test setup_platform with valid data.""" + config = { + 'monitored_conditions': [ + 'last_capture', + 'total_cameras', + 'captured_today', + 'battery_level', + 'signal_strength', + 'temperature', + 'humidity', + 'air_quality' + ] + } + + hass.data[DATA_ARLO] = _get_named_tuple({ + 'cameras': [_get_named_tuple({ + 'name': 'Camera', + 'model_id': 'ABC1000' + })], + 'base_stations': [_get_named_tuple({ + 'name': 'Base Station', + 'model_id': 'ABC1000' + })] + }) + + arlo.setup_platform(hass, config, platform_setup.add_devices) + assert len(platform_setup.sensors) == 8 + assert platform_setup.update + + +def test_sensor_name(default_sensor): + """Test the name property.""" + assert default_sensor.name == 'Last' + + +async def test_async_added_to_hass(sensor_with_hass_data, mock_dispatch): + """Test dispatcher called when added.""" + await sensor_with_hass_data.async_added_to_hass() + assert len(mock_dispatch.mock_calls) == 1 + kall = mock_dispatch.call_args + args, kwargs = kall + assert len(args) == 3 + assert args[0] == sensor_with_hass_data.hass + assert args[1] == 'arlo_update' + assert not kwargs + + +def test_sensor_state_default(default_sensor): + """Test the state property.""" + assert default_sensor.state is None + + +def test_sensor_icon_battery(battery_sensor): + """Test the battery icon.""" + assert battery_sensor.icon == 'mdi:battery-50' + + +def test_sensor_icon(temperature_sensor): + """Test the icon property.""" + assert temperature_sensor.icon == 'mdi:thermometer' + + +def test_unit_of_measure(default_sensor, battery_sensor): + """Test the unit_of_measurement property.""" + assert default_sensor.unit_of_measurement is None + assert battery_sensor.unit_of_measurement == '%' + + +def test_device_class(default_sensor, temperature_sensor, humidity_sensor): + """Test the device_class property.""" + assert default_sensor.device_class is None + assert temperature_sensor.device_class == DEVICE_CLASS_TEMPERATURE + assert humidity_sensor.device_class == DEVICE_CLASS_HUMIDITY + + +def test_update_total_cameras(cameras_sensor): + """Test update method for total_cameras sensor type.""" + cameras_sensor.update() + assert cameras_sensor.state == 2 + + +def test_update_captured_today(captured_sensor): + """Test update method for captured_today sensor type.""" + captured_sensor.update() + assert captured_sensor.state == 5 + + +def _test_attributes(sensor_type): + data = _get_named_tuple({ + 'model_id': 'TEST123' + }) + sensor = _get_sensor('test', sensor_type, data) + attrs = sensor.device_state_attributes + assert attrs.get(ATTR_ATTRIBUTION) == 'Data provided by arlo.netgear.com' + assert attrs.get('brand') == 'Netgear Arlo' + assert attrs.get('model') == 'TEST123' + + +def test_state_attributes(): + """Test attributes for camera sensor types.""" + _test_attributes('battery_level') + _test_attributes('signal_strength') + _test_attributes('temperature') + _test_attributes('humidity') + _test_attributes('air_quality') + + +def test_attributes_total_cameras(cameras_sensor): + """Test attributes for total cameras sensor type.""" + attrs = cameras_sensor.device_state_attributes + assert attrs.get(ATTR_ATTRIBUTION) == 'Data provided by arlo.netgear.com' + assert attrs.get('brand') == 'Netgear Arlo' + assert attrs.get('model') is None + + +def _test_update(sensor_type, key, value): + data = _get_named_tuple({ + key: value + }) + sensor = _get_sensor('test', sensor_type, data) + sensor.update() + assert sensor.state == value + + +def test_update(): + """Test update method for direct transcription sensor types.""" + _test_update('battery_level', 'battery_level', 100) + _test_update('signal_strength', 'signal_strength', 100) + _test_update('temperature', 'ambient_temperature', 21.4) + _test_update('humidity', 'ambient_humidity', 45.1) + _test_update('air_quality', 'ambient_air_quality', 14.2) diff --git a/tests/components/sensor/test_bom.py b/tests/components/sensor/test_bom.py new file mode 100644 index 00000000000000..5e5a829662af1c --- /dev/null +++ b/tests/components/sensor/test_bom.py @@ -0,0 +1,99 @@ +"""The tests for the BOM Weather sensor platform.""" +import json +import re +import unittest +from unittest.mock import patch +from urllib.parse import urlparse + +import requests +from tests.common import ( + assert_setup_component, get_test_home_assistant, load_fixture) + +from homeassistant.components import sensor +from homeassistant.setup import setup_component + +VALID_CONFIG = { + 'platform': 'bom', + 'station': 'IDN60901.94767', + 'name': 'Fake', + 'monitored_conditions': [ + 'apparent_t', + 'press', + 'weather' + ] +} + + +def mocked_requests(*args, **kwargs): + """Mock requests.get invocations.""" + class MockResponse: + """Class to represent a mocked response.""" + + def __init__(self, json_data, status_code): + """Initialize the mock response class.""" + self.json_data = json_data + self.status_code = status_code + + def json(self): + """Return the json of the response.""" + return self.json_data + + @property + def content(self): + """Return the content of the response.""" + return self.json() + + def raise_for_status(self): + """Raise an HTTPError if status is not 200.""" + if self.status_code != 200: + raise requests.HTTPError(self.status_code) + + url = urlparse(args[0]) + if re.match(r'^/fwo/[\w]+/[\w.]+\.json', url.path): + return MockResponse(json.loads(load_fixture('bom_weather.json')), 200) + + raise NotImplementedError('Unknown route {}'.format(url.path)) + + +class TestBOMWeatherSensor(unittest.TestCase): + """Test the BOM Weather sensor.""" + + def setUp(self): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.config = VALID_CONFIG + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @patch('requests.get', side_effect=mocked_requests) + def test_setup(self, mock_get): + """Test the setup with custom settings.""" + with assert_setup_component(1, sensor.DOMAIN): + self.assertTrue(setup_component(self.hass, sensor.DOMAIN, { + 'sensor': VALID_CONFIG})) + + fake_entities = [ + 'bom_fake_feels_like_c', + 'bom_fake_pressure_mb', + 'bom_fake_weather'] + + for entity_id in fake_entities: + state = self.hass.states.get('sensor.{}'.format(entity_id)) + self.assertIsNotNone(state) + + @patch('requests.get', side_effect=mocked_requests) + def test_sensor_values(self, mock_get): + """Test retrieval of sensor values.""" + self.assertTrue(setup_component( + self.hass, sensor.DOMAIN, {'sensor': VALID_CONFIG})) + + weather = self.hass.states.get('sensor.bom_fake_weather').state + self.assertEqual('Fine', weather) + + pressure = self.hass.states.get('sensor.bom_fake_pressure_mb').state + self.assertEqual('1021.7', pressure) + + feels_like = self.hass.states.get('sensor.bom_fake_feels_like_c').state + self.assertEqual('25.0', feels_like) diff --git a/tests/components/sensor/test_coinmarketcap.py b/tests/components/sensor/test_coinmarketcap.py index 15c254bfb274ab..37a63e5cba5ad1 100644 --- a/tests/components/sensor/test_coinmarketcap.py +++ b/tests/components/sensor/test_coinmarketcap.py @@ -11,8 +11,9 @@ VALID_CONFIG = { 'platform': 'coinmarketcap', - 'currency': 'ethereum', + 'currency_id': 1027, 'display_currency': 'EUR', + 'display_currency_decimals': 3 } @@ -39,6 +40,6 @@ def test_setup(self, mock_request): state = self.hass.states.get('sensor.ethereum') assert state is not None - assert state.state == '240.47' + assert state.state == '493.455' assert state.attributes.get('symbol') == 'ETH' assert state.attributes.get('unit_of_measurement') == 'EUR' diff --git a/tests/components/sensor/test_command_line.py b/tests/components/sensor/test_command_line.py index bc073a04c47647..808f8cff6a146d 100644 --- a/tests/components/sensor/test_command_line.py +++ b/tests/components/sensor/test_command_line.py @@ -1,5 +1,6 @@ """The tests for the Command line sensor platform.""" import unittest +from unittest.mock import patch from homeassistant.helpers.template import Template from homeassistant.components.sensor import command_line @@ -17,11 +18,16 @@ def tearDown(self): """Stop everything that was started.""" self.hass.stop() + def update_side_effect(self, data): + """Side effect function for mocking CommandSensorData.update().""" + self.commandline.data = data + def test_setup(self): """Test sensor setup.""" config = {'name': 'Test', 'unit_of_measurement': 'in', - 'command': 'echo 5' + 'command': 'echo 5', + 'command_timeout': 15 } devices = [] @@ -41,11 +47,11 @@ def add_dev_callback(devs, update): def test_template(self): """Test command sensor with template.""" - data = command_line.CommandSensorData(self.hass, 'echo 50') + data = command_line.CommandSensorData(self.hass, 'echo 50', 15) entity = command_line.CommandSensor( self.hass, data, 'test', 'in', - Template('{{ value | multiply(0.1) }}', self.hass)) + Template('{{ value | multiply(0.1) }}', self.hass), []) entity.update() self.assertEqual(5, float(entity.state)) @@ -55,7 +61,7 @@ def test_template_render(self): self.hass.states.set('sensor.test_state', 'Works') data = command_line.CommandSensorData( self.hass, - 'echo {{ states.sensor.test_state.state }}' + 'echo {{ states.sensor.test_state.state }}', 15 ) data.update() @@ -63,7 +69,109 @@ def test_template_render(self): def test_bad_command(self): """Test bad command.""" - data = command_line.CommandSensorData(self.hass, 'asdfasdf') + data = command_line.CommandSensorData(self.hass, 'asdfasdf', 15) data.update() self.assertEqual(None, data.value) + + def test_update_with_json_attrs(self): + """Test attributes get extracted from a JSON result.""" + data = command_line.CommandSensorData( + self.hass, + ('echo { \\"key\\": \\"some_json_value\\", \\"another_key\\":\ + \\"another_json_value\\", \\"key_three\\": \\"value_three\\" }'), + 15 + ) + + self.sensor = command_line.CommandSensor(self.hass, data, 'test', + None, None, ['key', + 'another_key', + 'key_three']) + self.sensor.update() + self.assertEqual('some_json_value', + self.sensor.device_state_attributes['key']) + self.assertEqual('another_json_value', + self.sensor.device_state_attributes['another_key']) + self.assertEqual('value_three', + self.sensor.device_state_attributes['key_three']) + + @patch('homeassistant.components.sensor.command_line._LOGGER') + def test_update_with_json_attrs_no_data(self, mock_logger): + """Test attributes when no JSON result fetched.""" + data = command_line.CommandSensorData( + self.hass, + 'echo ', 15 + ) + self.sensor = command_line.CommandSensor(self.hass, data, 'test', + None, None, ['key']) + self.sensor.update() + self.assertEqual({}, self.sensor.device_state_attributes) + self.assertTrue(mock_logger.warning.called) + + @patch('homeassistant.components.sensor.command_line._LOGGER') + def test_update_with_json_attrs_not_dict(self, mock_logger): + """Test attributes get extracted from a JSON result.""" + data = command_line.CommandSensorData( + self.hass, + 'echo [1, 2, 3]', 15 + ) + self.sensor = command_line.CommandSensor(self.hass, data, 'test', + None, None, ['key']) + self.sensor.update() + self.assertEqual({}, self.sensor.device_state_attributes) + self.assertTrue(mock_logger.warning.called) + + @patch('homeassistant.components.sensor.command_line._LOGGER') + def test_update_with_json_attrs_bad_JSON(self, mock_logger): + """Test attributes get extracted from a JSON result.""" + data = command_line.CommandSensorData( + self.hass, + 'echo This is text rather than JSON data.', 15 + ) + self.sensor = command_line.CommandSensor(self.hass, data, 'test', + None, None, ['key']) + self.sensor.update() + self.assertEqual({}, self.sensor.device_state_attributes) + self.assertTrue(mock_logger.warning.called) + + def test_update_with_missing_json_attrs(self): + """Test attributes get extracted from a JSON result.""" + data = command_line.CommandSensorData( + self.hass, + ('echo { \\"key\\": \\"some_json_value\\", \\"another_key\\":\ + \\"another_json_value\\", \\"key_three\\": \\"value_three\\" }'), + 15 + ) + + self.sensor = command_line.CommandSensor(self.hass, data, 'test', + None, None, ['key', + 'another_key', + 'key_three', + 'special_key']) + self.sensor.update() + self.assertEqual('some_json_value', + self.sensor.device_state_attributes['key']) + self.assertEqual('another_json_value', + self.sensor.device_state_attributes['another_key']) + self.assertEqual('value_three', + self.sensor.device_state_attributes['key_three']) + self.assertFalse('special_key' in self.sensor.device_state_attributes) + + def test_update_with_unnecessary_json_attrs(self): + """Test attributes get extracted from a JSON result.""" + data = command_line.CommandSensorData( + self.hass, + ('echo { \\"key\\": \\"some_json_value\\", \\"another_key\\":\ + \\"another_json_value\\", \\"key_three\\": \\"value_three\\" }'), + 15 + ) + + self.sensor = command_line.CommandSensor(self.hass, data, 'test', + None, None, ['key', + 'another_key']) + self.sensor.update() + self.assertEqual('some_json_value', + self.sensor.device_state_attributes['key']) + self.assertEqual('another_json_value', + self.sensor.device_state_attributes['another_key']) + self.assertFalse('key_three' in self.sensor.device_state_attributes) diff --git a/tests/components/sensor/test_deconz.py b/tests/components/sensor/test_deconz.py index 8f6a53e6e6510f..d7cdb458646154 100644 --- a/tests/components/sensor/test_deconz.py +++ b/tests/components/sensor/test_deconz.py @@ -41,7 +41,7 @@ } -async def setup_bridge(hass, data): +async def setup_bridge(hass, data, allow_clip_sensor=True): """Load the deCONZ sensor platform.""" from pydeconz import DeconzSession loop = Mock() @@ -57,7 +57,8 @@ async def setup_bridge(hass, data): hass.data[deconz.DATA_DECONZ_EVENT] = [] hass.data[deconz.DATA_DECONZ_ID] = {} config_entry = config_entries.ConfigEntry( - 1, deconz.DOMAIN, 'Mock Title', {'host': 'mock-host'}, 'test') + 1, deconz.DOMAIN, 'Mock Title', + {'host': 'mock-host', 'allow_clip_sensor': allow_clip_sensor}, 'test') await hass.config_entries.async_forward_entry_setup(config_entry, 'sensor') # To flush out the service call to update the group await hass.async_block_till_done() @@ -97,3 +98,16 @@ async def test_add_new_sensor(hass): async_dispatcher_send(hass, 'deconz_new_sensor', [sensor]) await hass.async_block_till_done() assert "sensor.name" in hass.data[deconz.DATA_DECONZ_ID] + + +async def test_do_not_allow_clipsensor(hass): + """Test that clip sensors can be ignored.""" + data = {} + await setup_bridge(hass, data, allow_clip_sensor=False) + sensor = Mock() + sensor.name = 'name' + sensor.type = 'CLIPTemperature' + sensor.register_async_callback = Mock() + async_dispatcher_send(hass, 'deconz_new_sensor', [sensor]) + await hass.async_block_till_done() + assert len(hass.data[deconz.DATA_DECONZ_ID]) == 0 diff --git a/tests/components/sensor/test_efergy.py b/tests/components/sensor/test_efergy.py index 83309329a11d97..9a79ab5b81c473 100644 --- a/tests/components/sensor/test_efergy.py +++ b/tests/components/sensor/test_efergy.py @@ -14,21 +14,20 @@ 'platform': 'efergy', 'app_token': token, 'utc_offset': '300', - 'monitored_variables': [{'type': 'amount', 'period': 'day'}, - {'type': 'instant_readings'}, - {'type': 'budget'}, - {'type': 'cost', 'period': 'day', 'currency': '$'}, - {'type': 'current_values'} - ] + 'monitored_variables': [ + {'type': 'amount', 'period': 'day'}, + {'type': 'instant_readings'}, + {'type': 'budget'}, + {'type': 'cost', 'period': 'day', 'currency': '$'}, + {'type': 'current_values'}, + ] } MULTI_SENSOR_CONFIG = { 'platform': 'efergy', 'app_token': multi_sensor_token, 'utc_offset': '300', - 'monitored_variables': [ - {'type': 'current_values'} - ] + 'monitored_variables': [{'type': 'current_values'}], } @@ -36,22 +35,23 @@ def mock_responses(mock): """Mock responses for Efergy.""" base_url = 'https://engage.efergy.com/mobile_proxy/' mock.get( - base_url + 'getInstant?token=' + token, + '{}getInstant?token={}'.format(base_url, token), text=load_fixture('efergy_instant.json')) mock.get( - base_url + 'getEnergy?token=' + token + '&offset=300&period=day', + '{}getEnergy?token={}&offset=300&period=day'.format(base_url, token), text=load_fixture('efergy_energy.json')) mock.get( - base_url + 'getBudget?token=' + token, + '{}getBudget?token={}'.format(base_url, token), text=load_fixture('efergy_budget.json')) mock.get( - base_url + 'getCost?token=' + token + '&offset=300&period=day', + '{}getCost?token={}&offset=300&period=day'.format(base_url, token), text=load_fixture('efergy_cost.json')) mock.get( - base_url + 'getCurrentValuesSummary?token=' + token, + '{}getCurrentValuesSummary?token={}'.format(base_url, token), text=load_fixture('efergy_current_values_single.json')) mock.get( - base_url + 'getCurrentValuesSummary?token=' + multi_sensor_token, + '{}getCurrentValuesSummary?token={}'.format( + base_url, multi_sensor_token), text=load_fixture('efergy_current_values_multi.json')) @@ -69,7 +69,7 @@ def add_devices(self, devices, mock): self.DEVICES.append(device) def setUp(self): - """Initialize values for this testcase class.""" + """Initialize values for this test case class.""" self.hass = get_test_home_assistant() self.config = ONE_SENSOR_CONFIG @@ -82,27 +82,31 @@ def test_single_sensor_readings(self, mock): """Test for successfully setting up the Efergy platform.""" mock_responses(mock) assert setup_component(self.hass, 'sensor', { - 'sensor': ONE_SENSOR_CONFIG}) - self.assertEqual('38.21', - self.hass.states.get('sensor.energy_consumed').state) - self.assertEqual('1580', - self.hass.states.get('sensor.energy_usage').state) - self.assertEqual('ok', - self.hass.states.get('sensor.energy_budget').state) - self.assertEqual('5.27', - self.hass.states.get('sensor.energy_cost').state) - self.assertEqual('1628', - self.hass.states.get('sensor.efergy_728386').state) + 'sensor': ONE_SENSOR_CONFIG, + }) + + self.assertEqual( + '38.21', self.hass.states.get('sensor.energy_consumed').state) + self.assertEqual( + '1580', self.hass.states.get('sensor.energy_usage').state) + self.assertEqual( + 'ok', self.hass.states.get('sensor.energy_budget').state) + self.assertEqual( + '5.27', self.hass.states.get('sensor.energy_cost').state) + self.assertEqual( + '1628', self.hass.states.get('sensor.efergy_728386').state) @requests_mock.Mocker() def test_multi_sensor_readings(self, mock): """Test for multiple sensors in one household.""" mock_responses(mock) assert setup_component(self.hass, 'sensor', { - 'sensor': MULTI_SENSOR_CONFIG}) - self.assertEqual('218', - self.hass.states.get('sensor.efergy_728386').state) - self.assertEqual('1808', - self.hass.states.get('sensor.efergy_0').state) - self.assertEqual('312', - self.hass.states.get('sensor.efergy_728387').state) + 'sensor': MULTI_SENSOR_CONFIG, + }) + + self.assertEqual( + '218', self.hass.states.get('sensor.efergy_728386').state) + self.assertEqual( + '1808', self.hass.states.get('sensor.efergy_0').state) + self.assertEqual( + '312', self.hass.states.get('sensor.efergy_728387').state) diff --git a/tests/components/sensor/test_filter.py b/tests/components/sensor/test_filter.py index 8e79306fe136a2..cf2cc9c42054db 100644 --- a/tests/components/sensor/test_filter.py +++ b/tests/components/sensor/test_filter.py @@ -4,7 +4,8 @@ from unittest.mock import patch from homeassistant.components.sensor.filter import ( - LowPassFilter, OutlierFilter, ThrottleFilter, TimeSMAFilter) + LowPassFilter, OutlierFilter, ThrottleFilter, TimeSMAFilter, + RangeFilter) import homeassistant.util.dt as dt_util from homeassistant.setup import setup_component import homeassistant.core as ha @@ -131,6 +132,23 @@ def test_lowpass(self): filtered = filt.filter_state(state) self.assertEqual(18.05, filtered.state) + def test_range(self): + """Test if range filter works.""" + lower = 10 + upper = 20 + filt = RangeFilter(entity=None, + lower_bound=lower, + upper_bound=upper) + for unf_state in self.values: + unf = float(unf_state.state) + filtered = filt.filter_state(unf_state) + if unf < lower: + self.assertEqual(lower, filtered.state) + elif unf > upper: + self.assertEqual(upper, filtered.state) + else: + self.assertEqual(unf, filtered.state) + def test_throttle(self): """Test if lowpass filter works.""" filt = ThrottleFilter(window_size=3, diff --git a/tests/components/sensor/test_geo_rss_events.py b/tests/components/sensor/test_geo_rss_events.py index f9ec83cc8be0e8..cc57c80143005a 100644 --- a/tests/components/sensor/test_geo_rss_events.py +++ b/tests/components/sensor/test_geo_rss_events.py @@ -1,7 +1,10 @@ """The test for the geo rss events sensor platform.""" import unittest from unittest import mock +import sys + import feedparser +import pytest from homeassistant.setup import setup_component from tests.common import load_fixture, get_test_home_assistant @@ -22,6 +25,9 @@ } +# Until https://github.com/kurtmckee/feedparser/pull/131 is released. +@pytest.mark.skipif(sys.version_info[:2] >= (3, 7), + reason='Package incompatible with Python 3.7') class TestGeoRssServiceUpdater(unittest.TestCase): """Test the GeoRss service updater.""" diff --git a/tests/components/sensor/test_moon.py b/tests/components/sensor/test_moon.py index 334dd9a0bec0c6..9086df6e79b4c6 100644 --- a/tests/components/sensor/test_moon.py +++ b/tests/components/sensor/test_moon.py @@ -37,7 +37,7 @@ def test_moon_day1(self, mock_request): assert setup_component(self.hass, 'sensor', config) state = self.hass.states.get('sensor.moon_day1') - self.assertEqual(state.state, 'Waxing crescent') + self.assertEqual(state.state, 'waxing_crescent') @patch('homeassistant.components.sensor.moon.dt_util.utcnow', return_value=DAY2) @@ -53,4 +53,4 @@ def test_moon_day2(self, mock_request): assert setup_component(self.hass, 'sensor', config) state = self.hass.states.get('sensor.moon_day2') - self.assertEqual(state.state, 'Waning gibbous') + self.assertEqual(state.state, 'waning_gibbous') diff --git a/tests/components/sensor/test_nsw_fuel_station.py b/tests/components/sensor/test_nsw_fuel_station.py new file mode 100644 index 00000000000000..1ee314d9eee094 --- /dev/null +++ b/tests/components/sensor/test_nsw_fuel_station.py @@ -0,0 +1,117 @@ +"""The tests for the NSW Fuel Station sensor platform.""" +import unittest +from unittest.mock import patch + +from homeassistant.components import sensor +from homeassistant.setup import setup_component +from tests.common import ( + get_test_home_assistant, assert_setup_component, MockDependency) + +VALID_CONFIG = { + 'platform': 'nsw_fuel_station', + 'station_id': 350, + 'fuel_types': ['E10', 'P95'], +} + + +class MockPrice(): + """Mock Price implementation.""" + + def __init__(self, price, fuel_type, last_updated, + price_unit, station_code): + """Initialize a mock price instance.""" + self.price = price + self.fuel_type = fuel_type + self.last_updated = last_updated + self.price_unit = price_unit + self.station_code = station_code + + +class MockStation(): + """Mock Station implementation.""" + + def __init__(self, name, code): + """Initialize a mock Station instance.""" + self.name = name + self.code = code + + +class MockGetReferenceDataResponse(): + """Mock GetReferenceDataResponse implementation.""" + + def __init__(self, stations): + """Initialize a mock GetReferenceDataResponse instance.""" + self.stations = stations + + +class FuelCheckClientMock(): + """Mock FuelCheckClient implementation.""" + + def get_fuel_prices_for_station(self, station): + """Return a fake fuel prices response.""" + return [ + MockPrice( + price=150.0, + fuel_type='P95', + last_updated=None, + price_unit=None, + station_code=350 + ), + MockPrice( + price=140.0, + fuel_type='E10', + last_updated=None, + price_unit=None, + station_code=350 + ) + ] + + def get_reference_data(self): + """Return a fake reference data response.""" + return MockGetReferenceDataResponse( + stations=[ + MockStation(code=350, name="My Fake Station") + ] + ) + + +class TestNSWFuelStation(unittest.TestCase): + """Test the NSW Fuel Station sensor platform.""" + + def setUp(self): + """Set up things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.config = VALID_CONFIG + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @MockDependency('nsw_fuel') + @patch('nsw_fuel.FuelCheckClient', new=FuelCheckClientMock) + def test_setup(self, mock_nsw_fuel): + """Test the setup with custom settings.""" + with assert_setup_component(1, sensor.DOMAIN): + self.assertTrue(setup_component(self.hass, sensor.DOMAIN, { + 'sensor': VALID_CONFIG})) + + fake_entities = [ + 'my_fake_station_p95', + 'my_fake_station_e10' + ] + + for entity_id in fake_entities: + state = self.hass.states.get('sensor.{}'.format(entity_id)) + self.assertIsNotNone(state) + + @MockDependency('nsw_fuel') + @patch('nsw_fuel.FuelCheckClient', new=FuelCheckClientMock) + def test_sensor_values(self, mock_nsw_fuel): + """Test retrieval of sensor values.""" + self.assertTrue(setup_component( + self.hass, sensor.DOMAIN, {'sensor': VALID_CONFIG})) + + self.assertEqual('140.0', self.hass.states.get( + 'sensor.my_fake_station_e10').state) + self.assertEqual('150.0', self.hass.states.get( + 'sensor.my_fake_station_p95').state) diff --git a/tests/components/sensor/test_radarr.py b/tests/components/sensor/test_radarr.py index 94eeafad7b19e5..0d6aca9d0b7067 100644 --- a/tests/components/sensor/test_radarr.py +++ b/tests/components/sensor/test_radarr.py @@ -83,7 +83,7 @@ def json(self): "id": 12 } ], 200) - elif 'api/command' in url: + if 'api/command' in url: return MockResponse([ { "name": "RescanMovie", @@ -94,7 +94,7 @@ def json(self): "id": 24 } ], 200) - elif 'api/movie' in url: + if 'api/movie' in url: return MockResponse([ { "title": "Assassin's Creed", @@ -149,7 +149,7 @@ def json(self): "id": 1 } ], 200) - elif 'api/diskspace' in url: + if 'api/diskspace' in url: return MockResponse([ { "path": "/data", @@ -158,7 +158,7 @@ def json(self): "totalSpace": 499738734592 } ], 200) - elif 'api/system/status' in url: + if 'api/system/status' in url: return MockResponse({ "version": "0.2.0.210", "buildTime": "2017-01-22T23:12:49Z", @@ -182,10 +182,9 @@ def json(self): "(Stable 4.6.1.3/abb06f1 " "Mon Oct 3 07:57:59 UTC 2016)") }, 200) - else: - return MockResponse({ - "error": "Unauthorized" - }, 401) + return MockResponse({ + "error": "Unauthorized" + }, 401) class TestRadarrSetup(unittest.TestCase): diff --git a/tests/components/sensor/test_rflink.py b/tests/components/sensor/test_rflink.py index a99d14cc735007..a250a75ab99912 100644 --- a/tests/components/sensor/test_rflink.py +++ b/tests/components/sensor/test_rflink.py @@ -8,6 +8,9 @@ import asyncio from ..test_rflink import mock_rflink +from homeassistant.components.rflink import ( + CONF_RECONNECT_INTERVAL) +from homeassistant.const import STATE_UNKNOWN DOMAIN = 'sensor' @@ -32,7 +35,7 @@ def test_default_setup(hass, monkeypatch): """Test all basic functionality of the rflink sensor component.""" # setup mocking rflink module - event_callback, create, _, _ = yield from mock_rflink( + event_callback, create, _, disconnect_callback = yield from mock_rflink( hass, CONFIG, DOMAIN, monkeypatch) # make sure arguments are passed @@ -100,3 +103,38 @@ def test_disable_automatic_add(hass, monkeypatch): # make sure new device is not added assert not hass.states.get('sensor.test2') + + +@asyncio.coroutine +def test_entity_availability(hass, monkeypatch): + """If Rflink device is disconnected, entities should become unavailable.""" + # Make sure Rflink mock does not 'recover' to quickly from the + # disconnect or else the unavailability cannot be measured + config = CONFIG + failures = [True, True] + config[CONF_RECONNECT_INTERVAL] = 60 + + # Create platform and entities + event_callback, create, _, disconnect_callback = yield from mock_rflink( + hass, config, DOMAIN, monkeypatch, failures=failures) + + # Entities are available by default + assert hass.states.get('sensor.test').state == STATE_UNKNOWN + + # Mock a disconnect of the Rflink device + disconnect_callback() + + # Wait for dispatch events to propagate + yield from hass.async_block_till_done() + + # Entity should be unavailable + assert hass.states.get('sensor.test').state == 'unavailable' + + # Reconnect the Rflink device + disconnect_callback() + + # Wait for dispatch events to propagate + yield from hass.async_block_till_done() + + # Entities should be available again + assert hass.states.get('sensor.test').state == STATE_UNKNOWN diff --git a/tests/components/sensor/test_ring.py b/tests/components/sensor/test_ring.py index 0cce0ea681d318..4d34018ce52c85 100644 --- a/tests/components/sensor/test_ring.py +++ b/tests/components/sensor/test_ring.py @@ -51,6 +51,8 @@ def tearDown(self): @requests_mock.Mocker() def test_sensor(self, mock): """Test the Ring sensor class and methods.""" + mock.post('https://oauth.ring.com/oauth/token', + text=load_fixture('ring_oauth.json')) mock.post('https://api.ring.com/clients_api/session', text=load_fixture('ring_session.json')) mock.get('https://api.ring.com/clients_api/ring_devices', diff --git a/tests/components/sensor/test_rmvtransport.py b/tests/components/sensor/test_rmvtransport.py new file mode 100644 index 00000000000000..9db19ecde499ed --- /dev/null +++ b/tests/components/sensor/test_rmvtransport.py @@ -0,0 +1,173 @@ +"""The tests for the rmvtransport platform.""" +import unittest +from unittest.mock import patch +import datetime + +from homeassistant.setup import setup_component + +from tests.common import get_test_home_assistant + +VALID_CONFIG_MINIMAL = {'sensor': {'platform': 'rmvtransport', + 'next_departure': [{'station': '3000010'}]}} + +VALID_CONFIG_NAME = {'sensor': { + 'platform': 'rmvtransport', + 'next_departure': [ + { + 'station': '3000010', + 'name': 'My Station', + } + ]}} + +VALID_CONFIG_MISC = {'sensor': { + 'platform': 'rmvtransport', + 'next_departure': [ + { + 'station': '3000010', + 'lines': [21, 'S8'], + 'max_journeys': 2, + 'time_offset': 10 + } + ]}} + +VALID_CONFIG_DEST = {'sensor': { + 'platform': 'rmvtransport', + 'next_departure': [ + { + 'station': '3000010', + 'destinations': ['Frankfurt (Main) Flughafen Regionalbahnhof', + 'Frankfurt (Main) Stadion'] + } + ]}} + + +def get_departuresMock(stationId, maxJourneys, + products): # pylint: disable=invalid-name + """Mock rmvtransport departures loading.""" + data = {'station': 'Frankfurt (Main) Hauptbahnhof', + 'stationId': '3000010', 'filter': '11111111111', 'journeys': [ + {'product': 'Tram', 'number': 12, 'trainId': '1123456', + 'direction': 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife', + 'departure_time': datetime.datetime(2018, 8, 6, 14, 21), + 'minutes': 7, 'delay': 3, 'stops': [ + 'Frankfurt (Main) Willy-Brandt-Platz', + 'Frankfurt (Main) Römer/Paulskirche', + 'Frankfurt (Main) Börneplatz', + 'Frankfurt (Main) Konstablerwache', + 'Frankfurt (Main) Bornheim Mitte', + 'Frankfurt (Main) Saalburg-/Wittelsbacherallee', + 'Frankfurt (Main) Eissporthalle/Festplatz', + 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife'], + 'info': None, 'info_long': None, + 'icon': 'https://products/32_pic.png'}, + {'product': 'Bus', 'number': 21, 'trainId': '1234567', + 'direction': 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife', + 'departure_time': datetime.datetime(2018, 8, 6, 14, 22), + 'minutes': 8, 'delay': 1, 'stops': [ + 'Frankfurt (Main) Weser-/Münchener Straße', + 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife'], + 'info': None, 'info_long': None, + 'icon': 'https://products/32_pic.png'}, + {'product': 'Bus', 'number': 12, 'trainId': '1234568', + 'direction': 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife', + 'departure_time': datetime.datetime(2018, 8, 6, 14, 25), + 'minutes': 11, 'delay': 1, 'stops': [ + 'Frankfurt (Main) Stadion'], + 'info': None, 'info_long': None, + 'icon': 'https://products/32_pic.png'}, + {'product': 'Bus', 'number': 21, 'trainId': '1234569', + 'direction': 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife', + 'departure_time': datetime.datetime(2018, 8, 6, 14, 25), + 'minutes': 11, 'delay': 1, 'stops': [], + 'info': None, 'info_long': None, + 'icon': 'https://products/32_pic.png'}, + {'product': 'Bus', 'number': 12, 'trainId': '1234570', + 'direction': 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife', + 'departure_time': datetime.datetime(2018, 8, 6, 14, 25), + 'minutes': 11, 'delay': 1, 'stops': [], + 'info': None, 'info_long': None, + 'icon': 'https://products/32_pic.png'}, + {'product': 'Bus', 'number': 21, 'trainId': '1234571', + 'direction': 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife', + 'departure_time': datetime.datetime(2018, 8, 6, 14, 25), + 'minutes': 11, 'delay': 1, 'stops': [], + 'info': None, 'info_long': None, + 'icon': 'https://products/32_pic.png'} + ]} + return data + + +def get_errDeparturesMock(stationId, maxJourneys, + products): # pylint: disable=invalid-name + """Mock rmvtransport departures erroneous loading.""" + raise ValueError + + +class TestRMVtransportSensor(unittest.TestCase): + """Test the rmvtransport sensor.""" + + def setUp(self): + """Set up things to run when tests begin.""" + self.hass = get_test_home_assistant() + self.config = VALID_CONFIG_MINIMAL + self.reference = {} + self.entities = [] + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + @patch('RMVtransport.RMVtransport.get_departures', + side_effect=get_departuresMock) + def test_rmvtransport_min_config(self, mock_get_departures): + """Test minimal rmvtransport configuration.""" + assert setup_component(self.hass, 'sensor', VALID_CONFIG_MINIMAL) + state = self.hass.states.get('sensor.frankfurt_main_hauptbahnhof') + self.assertEqual(state.state, '7') + self.assertEqual(state.attributes['departure_time'], + datetime.datetime(2018, 8, 6, 14, 21)) + self.assertEqual(state.attributes['direction'], + 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife') + self.assertEqual(state.attributes['product'], 'Tram') + self.assertEqual(state.attributes['line'], 12) + self.assertEqual(state.attributes['icon'], 'mdi:tram') + self.assertEqual(state.attributes['friendly_name'], + 'Frankfurt (Main) Hauptbahnhof') + + @patch('RMVtransport.RMVtransport.get_departures', + side_effect=get_departuresMock) + def test_rmvtransport_name_config(self, mock_get_departures): + """Test custom name configuration.""" + assert setup_component(self.hass, 'sensor', VALID_CONFIG_NAME) + state = self.hass.states.get('sensor.my_station') + self.assertEqual(state.attributes['friendly_name'], 'My Station') + + @patch('RMVtransport.RMVtransport.get_departures', + side_effect=get_errDeparturesMock) + def test_rmvtransport_err_config(self, mock_get_departures): + """Test erroneous rmvtransport configuration.""" + assert setup_component(self.hass, 'sensor', VALID_CONFIG_MINIMAL) + + @patch('RMVtransport.RMVtransport.get_departures', + side_effect=get_departuresMock) + def test_rmvtransport_misc_config(self, mock_get_departures): + """Test misc configuration.""" + assert setup_component(self.hass, 'sensor', VALID_CONFIG_MISC) + state = self.hass.states.get('sensor.frankfurt_main_hauptbahnhof') + self.assertEqual(state.attributes['friendly_name'], + 'Frankfurt (Main) Hauptbahnhof') + self.assertEqual(state.attributes['line'], 21) + + @patch('RMVtransport.RMVtransport.get_departures', + side_effect=get_departuresMock) + def test_rmvtransport_dest_config(self, mock_get_departures): + """Test misc configuration.""" + assert setup_component(self.hass, 'sensor', VALID_CONFIG_DEST) + state = self.hass.states.get('sensor.frankfurt_main_hauptbahnhof') + self.assertEqual(state.state, '11') + self.assertEqual(state.attributes['direction'], + 'Frankfurt (Main) Hugo-Junkers-Straße/Schleife') + self.assertEqual(state.attributes['line'], 12) + self.assertEqual(state.attributes['minutes'], 11) + self.assertEqual(state.attributes['departure_time'], + datetime.datetime(2018, 8, 6, 14, 25)) diff --git a/tests/components/sensor/test_sigfox.py b/tests/components/sensor/test_sigfox.py index dcdeef56b98799..569fab584ad50e 100644 --- a/tests/components/sensor/test_sigfox.py +++ b/tests/components/sensor/test_sigfox.py @@ -38,7 +38,7 @@ def tearDown(self): self.hass.stop() def test_invalid_credentials(self): - """Test for a invalid credentials.""" + """Test for invalid credentials.""" with requests_mock.Mocker() as mock_req: url = re.compile(API_URL + 'devicetypes') mock_req.get(url, text='{}', status_code=401) @@ -47,7 +47,7 @@ def test_invalid_credentials(self): assert len(self.hass.states.entity_ids()) == 0 def test_valid_credentials(self): - """Test for a valid credentials.""" + """Test for valid credentials.""" with requests_mock.Mocker() as mock_req: url1 = re.compile(API_URL + 'devicetypes') mock_req.get(url1, text='{"data":[{"id":"fake_type"}]}', diff --git a/tests/components/sensor/test_simulated.py b/tests/components/sensor/test_simulated.py index 3bfccc629fdd52..50552baa33e24c 100644 --- a/tests/components/sensor/test_simulated.py +++ b/tests/components/sensor/test_simulated.py @@ -1,13 +1,14 @@ """The tests for the simulated sensor.""" import unittest +from tests.common import get_test_home_assistant + from homeassistant.components.sensor.simulated import ( - CONF_UNIT, CONF_AMP, CONF_MEAN, CONF_PERIOD, CONF_PHASE, CONF_FWHM, - CONF_SEED, DEFAULT_NAME, DEFAULT_AMP, DEFAULT_MEAN, - DEFAULT_PHASE, DEFAULT_FWHM, DEFAULT_SEED) + CONF_AMP, CONF_FWHM, CONF_MEAN, CONF_PERIOD, CONF_PHASE, CONF_SEED, + CONF_UNIT, CONF_RELATIVE_TO_EPOCH, DEFAULT_AMP, DEFAULT_FWHM, DEFAULT_MEAN, + DEFAULT_NAME, DEFAULT_PHASE, DEFAULT_SEED, DEFAULT_RELATIVE_TO_EPOCH) from homeassistant.const import CONF_FRIENDLY_NAME from homeassistant.setup import setup_component -from tests.common import get_test_home_assistant class TestSimulatedSensor(unittest.TestCase): @@ -27,24 +28,19 @@ def test_default_config(self): 'sensor': { 'platform': 'simulated'} } - self.assertTrue( - setup_component(self.hass, 'sensor', config)) + self.assertTrue(setup_component(self.hass, 'sensor', config)) self.hass.block_till_done() + assert len(self.hass.states.entity_ids()) == 1 state = self.hass.states.get('sensor.simulated') - assert state.attributes.get( - CONF_FRIENDLY_NAME) == DEFAULT_NAME - assert state.attributes.get( - CONF_AMP) == DEFAULT_AMP - assert state.attributes.get( - CONF_UNIT) is None - assert state.attributes.get( - CONF_MEAN) == DEFAULT_MEAN - assert state.attributes.get( - CONF_PERIOD) == 60.0 - assert state.attributes.get( - CONF_PHASE) == DEFAULT_PHASE - assert state.attributes.get( - CONF_FWHM) == DEFAULT_FWHM - assert state.attributes.get( - CONF_SEED) == DEFAULT_SEED + + assert state.attributes.get(CONF_FRIENDLY_NAME) == DEFAULT_NAME + assert state.attributes.get(CONF_AMP) == DEFAULT_AMP + assert state.attributes.get(CONF_UNIT) is None + assert state.attributes.get(CONF_MEAN) == DEFAULT_MEAN + assert state.attributes.get(CONF_PERIOD) == 60.0 + assert state.attributes.get(CONF_PHASE) == DEFAULT_PHASE + assert state.attributes.get(CONF_FWHM) == DEFAULT_FWHM + assert state.attributes.get(CONF_SEED) == DEFAULT_SEED + assert state.attributes.get( + CONF_RELATIVE_TO_EPOCH) == DEFAULT_RELATIVE_TO_EPOCH diff --git a/tests/components/sensor/test_sonarr.py b/tests/components/sensor/test_sonarr.py index 9e2050e850c21e..275bb4a1e8b16a 100644 --- a/tests/components/sensor/test_sonarr.py +++ b/tests/components/sensor/test_sonarr.py @@ -139,7 +139,7 @@ def json(self): "id": 14402 } ], 200) - elif 'api/command' in url: + if 'api/command' in url: return MockResponse([ { "name": "RescanSeries", @@ -150,7 +150,7 @@ def json(self): "id": 24 } ], 200) - elif 'api/wanted/missing' in url or 'totalRecords' in url: + if 'api/wanted/missing' in url or 'totalRecords' in url: return MockResponse( { "page": 1, @@ -325,7 +325,7 @@ def json(self): } ] }, 200) - elif 'api/queue' in url: + if 'api/queue' in url: return MockResponse([ { "series": { @@ -449,7 +449,7 @@ def json(self): "id": 1503378561 } ], 200) - elif 'api/series' in url: + if 'api/series' in url: return MockResponse([ { "title": "Marvel's Daredevil", @@ -540,7 +540,7 @@ def json(self): "id": 7 } ], 200) - elif 'api/diskspace' in url: + if 'api/diskspace' in url: return MockResponse([ { "path": "/data", @@ -549,7 +549,7 @@ def json(self): "totalSpace": 499738734592 } ], 200) - elif 'api/system/status' in url: + if 'api/system/status' in url: return MockResponse({ "version": "2.0.0.1121", "buildTime": "2014-02-08T20:49:36.5560392Z", @@ -568,10 +568,9 @@ def json(self): "startOfWeek": 0, "urlBase": "" }, 200) - else: - return MockResponse({ - "error": "Unauthorized" - }, 401) + return MockResponse({ + "error": "Unauthorized" + }, 401) class TestSonarrSetup(unittest.TestCase): diff --git a/tests/components/sensor/test_template.py b/tests/components/sensor/test_template.py index f8d912f24ddc92..6861d3a5070d0c 100644 --- a/tests/components/sensor/test_template.py +++ b/tests/components/sensor/test_template.py @@ -269,7 +269,7 @@ def test_missing_template_does_not_create(self): assert self.hass.states.all() == [] def test_setup_invalid_device_class(self): - """"Test setup with invalid device_class.""" + """Test setup with invalid device_class.""" with assert_setup_component(0): assert setup_component(self.hass, 'sensor', { 'sensor': { @@ -284,7 +284,7 @@ def test_setup_invalid_device_class(self): }) def test_setup_valid_device_class(self): - """"Test setup with valid device_class.""" + """Test setup with valid device_class.""" with assert_setup_component(1): assert setup_component(self.hass, 'sensor', { 'sensor': { diff --git a/tests/components/sensor/test_wsdot.py b/tests/components/sensor/test_wsdot.py index ee2cec3bb2aa6f..8eb542b2b6897f 100644 --- a/tests/components/sensor/test_wsdot.py +++ b/tests/components/sensor/test_wsdot.py @@ -1,17 +1,16 @@ """The tests for the WSDOT platform.""" +from datetime import datetime, timedelta, timezone import re import unittest -from datetime import timedelta, datetime, timezone import requests_mock +from tests.common import get_test_home_assistant, load_fixture from homeassistant.components.sensor import wsdot from homeassistant.components.sensor.wsdot import ( - WashingtonStateTravelTimeSensor, ATTR_DESCRIPTION, - ATTR_TIME_UPDATED, CONF_API_KEY, CONF_NAME, - CONF_ID, CONF_TRAVEL_TIMES, SCAN_INTERVAL) + ATTR_DESCRIPTION, ATTR_TIME_UPDATED, CONF_API_KEY, CONF_ID, CONF_NAME, + CONF_TRAVEL_TIMES, RESOURCE, SCAN_INTERVAL) from homeassistant.setup import setup_component -from tests.common import load_fixture, get_test_home_assistant class TestWSDOT(unittest.TestCase): @@ -50,7 +49,7 @@ def test_setup_with_config(self): @requests_mock.Mocker() def test_setup(self, mock_req): """Test for operational WSDOT sensor with proper attributes.""" - uri = re.compile(WashingtonStateTravelTimeSensor.RESOURCE + '*') + uri = re.compile(RESOURCE + '*') mock_req.get(uri, text=load_fixture('wsdot.json')) wsdot.setup_platform(self.hass, self.config, self.add_entities) self.assertEqual(len(self.entities), 1) diff --git a/tests/components/sonos/__init__.py b/tests/components/sonos/__init__.py new file mode 100644 index 00000000000000..878e0c17318946 --- /dev/null +++ b/tests/components/sonos/__init__.py @@ -0,0 +1 @@ +"""Tests for the Sonos component.""" diff --git a/tests/components/sonos/test_init.py b/tests/components/sonos/test_init.py new file mode 100644 index 00000000000000..ab4eed31fee702 --- /dev/null +++ b/tests/components/sonos/test_init.py @@ -0,0 +1,48 @@ +"""Tests for the Sonos config flow.""" +from unittest.mock import patch + +from homeassistant import config_entries, data_entry_flow +from homeassistant.setup import async_setup_component +from homeassistant.components import sonos + +from tests.common import mock_coro + + +async def test_creating_entry_sets_up_media_player(hass): + """Test setting up Sonos loads the media player.""" + with patch('homeassistant.components.media_player.sonos.async_setup_entry', + return_value=mock_coro(True)) as mock_setup, \ + patch('soco.discover', return_value=True): + result = await hass.config_entries.flow.async_init( + sonos.DOMAIN, context={'source': config_entries.SOURCE_USER}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + await hass.async_block_till_done() + + assert len(mock_setup.mock_calls) == 1 + + +async def test_configuring_sonos_creates_entry(hass): + """Test that specifying config will create an entry.""" + with patch('homeassistant.components.sonos.async_setup_entry', + return_value=mock_coro(True)) as mock_setup, \ + patch('soco.discover', return_value=True): + await async_setup_component(hass, sonos.DOMAIN, { + 'sonos': { + 'some_config': 'to_trigger_import' + } + }) + await hass.async_block_till_done() + + assert len(mock_setup.mock_calls) == 1 + + +async def test_not_configuring_sonos_not_creates_entry(hass): + """Test that no config will not create an entry.""" + with patch('homeassistant.components.sonos.async_setup_entry', + return_value=mock_coro(True)) as mock_setup, \ + patch('soco.discover', return_value=True): + await async_setup_component(hass, sonos.DOMAIN, {}) + await hass.async_block_till_done() + + assert len(mock_setup.mock_calls) == 0 diff --git a/tests/components/switch/test_deconz.py b/tests/components/switch/test_deconz.py new file mode 100644 index 00000000000000..57fc8b3bcd9c23 --- /dev/null +++ b/tests/components/switch/test_deconz.py @@ -0,0 +1,97 @@ +"""deCONZ switch platform tests.""" +from unittest.mock import Mock, patch + +from homeassistant import config_entries +from homeassistant.components import deconz +from homeassistant.components.deconz.const import SWITCH_TYPES +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from tests.common import mock_coro + +SUPPORTED_SWITCHES = { + "1": { + "id": "Switch 1 id", + "name": "Switch 1 name", + "type": "On/Off plug-in unit", + "state": {} + }, + "2": { + "id": "Switch 2 id", + "name": "Switch 2 name", + "type": "Smart plug", + "state": {} + }, + "3": { + "id": "Switch 3 id", + "name": "Switch 3 name", + "type": "Warning device", + "state": {} + } +} + +UNSUPPORTED_SWITCH = { + "1": { + "id": "Switch id", + "name": "Unsupported switch", + "type": "Not a smart plug", + "state": {} + } +} + + +async def setup_bridge(hass, data): + """Load the deCONZ switch platform.""" + from pydeconz import DeconzSession + loop = Mock() + session = Mock() + entry = Mock() + entry.data = {'host': '1.2.3.4', 'port': 80, 'api_key': '1234567890ABCDEF'} + bridge = DeconzSession(loop, session, **entry.data) + with patch('pydeconz.DeconzSession.async_get_state', + return_value=mock_coro(data)): + await bridge.async_load_parameters() + hass.data[deconz.DOMAIN] = bridge + hass.data[deconz.DATA_DECONZ_UNSUB] = [] + hass.data[deconz.DATA_DECONZ_ID] = {} + config_entry = config_entries.ConfigEntry( + 1, deconz.DOMAIN, 'Mock Title', {'host': 'mock-host'}, 'test') + await hass.config_entries.async_forward_entry_setup(config_entry, 'switch') + # To flush out the service call to update the group + await hass.async_block_till_done() + + +async def test_no_switches(hass): + """Test that no switch entities are created.""" + data = {} + await setup_bridge(hass, data) + assert len(hass.data[deconz.DATA_DECONZ_ID]) == 0 + assert len(hass.states.async_all()) == 0 + + +async def test_switch(hass): + """Test that all supported switch entities and switch group are created.""" + await setup_bridge(hass, {"lights": SUPPORTED_SWITCHES}) + assert "switch.switch_1_name" in hass.data[deconz.DATA_DECONZ_ID] + assert "switch.switch_2_name" in hass.data[deconz.DATA_DECONZ_ID] + assert "switch.switch_3_name" in hass.data[deconz.DATA_DECONZ_ID] + assert len(SUPPORTED_SWITCHES) == len(SWITCH_TYPES) + assert len(hass.states.async_all()) == 4 + + +async def test_add_new_switch(hass): + """Test successful creation of switch entity.""" + data = {} + await setup_bridge(hass, data) + switch = Mock() + switch.name = 'name' + switch.type = "Smart plug" + switch.register_async_callback = Mock() + async_dispatcher_send(hass, 'deconz_new_light', [switch]) + await hass.async_block_till_done() + assert "switch.name" in hass.data[deconz.DATA_DECONZ_ID] + + +async def test_unsupported_switch(hass): + """Test that unsupported switches are not created.""" + await setup_bridge(hass, {"lights": UNSUPPORTED_SWITCH}) + assert len(hass.states.async_all()) == 0 diff --git a/tests/components/switch/test_flux.py b/tests/components/switch/test_flux.py index 61e665f265c4fa..155ed85dac2d4b 100644 --- a/tests/components/switch/test_flux.py +++ b/tests/components/switch/test_flux.py @@ -92,8 +92,7 @@ def test_flux_when_switch_is_off(self): def event_date(hass, event, now=None): if event == 'sunrise': return sunrise_time - else: - return sunset_time + return sunset_time with patch('homeassistant.util.dt.now', return_value=test_time): with patch('homeassistant.helpers.sun.get_astral_event_date', @@ -134,8 +133,7 @@ def test_flux_before_sunrise(self): def event_date(hass, event, now=None): if event == 'sunrise': return sunrise_time - else: - return sunset_time + return sunset_time with patch('homeassistant.util.dt.now', return_value=test_time): with patch('homeassistant.helpers.sun.get_astral_event_date', @@ -181,8 +179,7 @@ def test_flux_after_sunrise_before_sunset(self): def event_date(hass, event, now=None): if event == 'sunrise': return sunrise_time - else: - return sunset_time + return sunset_time with patch('homeassistant.util.dt.now', return_value=test_time): with patch('homeassistant.helpers.sun.get_astral_event_date', @@ -228,8 +225,7 @@ def test_flux_after_sunset_before_stop(self): def event_date(hass, event, now=None): if event == 'sunrise': return sunrise_time - else: - return sunset_time + return sunset_time with patch('homeassistant.util.dt.now', return_value=test_time): with patch('homeassistant.helpers.sun.get_astral_event_date', @@ -276,8 +272,7 @@ def test_flux_after_stop_before_sunrise(self): def event_date(hass, event, now=None): if event == 'sunrise': return sunrise_time - else: - return sunset_time + return sunset_time with patch('homeassistant.util.dt.now', return_value=test_time): with patch('homeassistant.helpers.sun.get_astral_event_date', @@ -323,8 +318,7 @@ def test_flux_with_custom_start_stop_times(self): def event_date(hass, event, now=None): if event == 'sunrise': return sunrise_time - else: - return sunset_time + return sunset_time with patch('homeassistant.util.dt.now', return_value=test_time): with patch('homeassistant.helpers.sun.get_astral_event_date', @@ -374,8 +368,7 @@ def test_flux_before_sunrise_stop_next_day(self): def event_date(hass, event, now=None): if event == 'sunrise': return sunrise_time - else: - return sunset_time + return sunset_time with patch('homeassistant.util.dt.now', return_value=test_time): with patch('homeassistant.helpers.sun.get_astral_event_date', @@ -426,8 +419,7 @@ def test_flux_after_sunrise_before_sunset_stop_next_day(self): def event_date(hass, event, now=None): if event == 'sunrise': return sunrise_time - else: - return sunset_time + return sunset_time with patch('homeassistant.util.dt.now', return_value=test_time): with patch('homeassistant.helpers.sun.get_astral_event_date', @@ -477,8 +469,7 @@ def test_flux_after_sunset_before_midnight_stop_next_day(self): def event_date(hass, event, now=None): if event == 'sunrise': return sunrise_time - else: - return sunset_time + return sunset_time with patch('homeassistant.util.dt.now', return_value=test_time): with patch('homeassistant.helpers.sun.get_astral_event_date', @@ -528,8 +519,7 @@ def test_flux_after_sunset_after_midnight_stop_next_day(self): def event_date(hass, event, now=None): if event == 'sunrise': return sunrise_time - else: - return sunset_time + return sunset_time with patch('homeassistant.util.dt.now', return_value=test_time): with patch('homeassistant.helpers.sun.get_astral_event_date', @@ -579,8 +569,7 @@ def test_flux_after_stop_before_sunrise_stop_next_day(self): def event_date(hass, event, now=None): if event == 'sunrise': return sunrise_time - else: - return sunset_time + return sunset_time with patch('homeassistant.util.dt.now', return_value=test_time): with patch('homeassistant.helpers.sun.get_astral_event_date', @@ -627,8 +616,7 @@ def test_flux_with_custom_colortemps(self): def event_date(hass, event, now=None): if event == 'sunrise': return sunrise_time - else: - return sunset_time + return sunset_time with patch('homeassistant.util.dt.now', return_value=test_time): with patch('homeassistant.helpers.sun.get_astral_event_date', @@ -677,8 +665,7 @@ def test_flux_with_custom_brightness(self): def event_date(hass, event, now=None): if event == 'sunrise': return sunrise_time - else: - return sunset_time + return sunset_time with patch('homeassistant.util.dt.now', return_value=test_time): with patch('homeassistant.helpers.sun.get_astral_event_date', @@ -739,9 +726,8 @@ def event_date(hass, event, now=None): if event == 'sunrise': print('sunrise {}'.format(sunrise_time)) return sunrise_time - else: - print('sunset {}'.format(sunset_time)) - return sunset_time + print('sunset {}'.format(sunset_time)) + return sunset_time with patch('homeassistant.util.dt.now', return_value=test_time): with patch('homeassistant.helpers.sun.get_astral_event_date', @@ -793,8 +779,7 @@ def test_flux_with_mired(self): def event_date(hass, event, now=None): if event == 'sunrise': return sunrise_time - else: - return sunset_time + return sunset_time with patch('homeassistant.util.dt.now', return_value=test_time): with patch('homeassistant.helpers.sun.get_astral_event_date', @@ -838,8 +823,7 @@ def test_flux_with_rgb(self): def event_date(hass, event, now=None): if event == 'sunrise': return sunrise_time - else: - return sunset_time + return sunset_time with patch('homeassistant.util.dt.now', return_value=test_time): with patch('homeassistant.helpers.sun.get_astral_event_date', diff --git a/tests/components/switch/test_init.py b/tests/components/switch/test_init.py index d679aa2c827371..55e44299294828 100644 --- a/tests/components/switch/test_init.py +++ b/tests/components/switch/test_init.py @@ -2,8 +2,8 @@ # pylint: disable=protected-access import unittest -from homeassistant.setup import setup_component -from homeassistant import loader +from homeassistant.setup import setup_component, async_setup_component +from homeassistant import core, loader from homeassistant.components import switch from homeassistant.const import STATE_ON, STATE_OFF, CONF_PLATFORM @@ -91,3 +91,24 @@ def test_setup_two_platforms(self): '{} 2'.format(switch.DOMAIN): {CONF_PLATFORM: 'test2'}, } )) + + +async def test_switch_context(hass): + """Test that switch context works.""" + assert await async_setup_component(hass, 'switch', { + 'switch': { + 'platform': 'test' + } + }) + + state = hass.states.get('switch.ac') + assert state is not None + + await hass.services.async_call('switch', 'toggle', { + 'entity_id': state.entity_id, + }, True, core.Context(user_id='abcd')) + + state2 = hass.states.get('switch.ac') + assert state2 is not None + assert state.state != state2.state + assert state2.context.user_id == 'abcd' diff --git a/tests/components/switch/test_mqtt.py b/tests/components/switch/test_mqtt.py index b5e2a0b0395497..7cd5a42b4a3e51 100644 --- a/tests/components/switch/test_mqtt.py +++ b/tests/components/switch/test_mqtt.py @@ -20,7 +20,7 @@ def setUp(self): # pylint: disable=invalid-name self.mock_publish = mock_mqtt_component(self.hass) def tearDown(self): # pylint: disable=invalid-name - """"Stop everything that was started.""" + """Stop everything that was started.""" self.hass.stop() def test_controlling_state_via_topic(self): @@ -248,3 +248,57 @@ def test_custom_availability_payload(self): state = self.hass.states.get('switch.test') self.assertEqual(STATE_ON, state.state) + + def test_custom_state_payload(self): + """Test the state payload.""" + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'state-topic', + 'command_topic': 'command-topic', + 'payload_on': 1, + 'payload_off': 0, + 'state_on': "HIGH", + 'state_off': "LOW", + } + }) + + state = self.hass.states.get('switch.test') + self.assertEqual(STATE_OFF, state.state) + self.assertFalse(state.attributes.get(ATTR_ASSUMED_STATE)) + + fire_mqtt_message(self.hass, 'state-topic', 'HIGH') + self.hass.block_till_done() + + state = self.hass.states.get('switch.test') + self.assertEqual(STATE_ON, state.state) + + fire_mqtt_message(self.hass, 'state-topic', 'LOW') + self.hass.block_till_done() + + state = self.hass.states.get('switch.test') + self.assertEqual(STATE_OFF, state.state) + + def test_unique_id(self): + """Test unique id option only creates one switch per unique_id.""" + assert setup_component(self.hass, switch.DOMAIN, { + switch.DOMAIN: [{ + 'platform': 'mqtt', + 'name': 'Test 1', + 'state_topic': 'test-topic', + 'command_topic': 'command-topic', + 'unique_id': 'TOTALLY_UNIQUE' + }, { + 'platform': 'mqtt', + 'name': 'Test 2', + 'state_topic': 'test-topic', + 'command_topic': 'command-topic', + 'unique_id': 'TOTALLY_UNIQUE' + }] + }) + + fire_mqtt_message(self.hass, 'test-topic', 'payload') + self.hass.block_till_done() + assert len(self.hass.states.async_entity_ids()) == 2 + # all switches group is 1, unique id created is 1 diff --git a/tests/components/switch/test_template.py b/tests/components/switch/test_template.py index 7456ae11a0de6f..8f7bbda8e98702 100644 --- a/tests/components/switch/test_template.py +++ b/tests/components/switch/test_template.py @@ -32,7 +32,7 @@ def teardown_method(self, method): self.hass.stop() def test_template_state_text(self): - """"Test the state text of a template.""" + """Test the state text of a template.""" with assert_setup_component(1, 'switch'): assert setup.setup_component(self.hass, 'switch', { 'switch': { diff --git a/tests/components/test_api.py b/tests/components/test_api.py index c9dae27d14c822..2be1168b86a829 100644 --- a/tests/components/test_api.py +++ b/tests/components/test_api.py @@ -12,7 +12,7 @@ import homeassistant.core as ha from homeassistant.setup import async_setup_component -from tests.common import mock_coro +from tests.common import async_mock_service @pytest.fixture @@ -420,14 +420,77 @@ async def test_api_error_log(hass, aiohttp_client): assert resp.status == 401 with patch( - 'homeassistant.components.http.view.HomeAssistantView.file', - return_value=mock_coro(web.Response(status=200, text='Hello')) + 'aiohttp.web.FileResponse', + return_value=web.Response(status=200, text='Hello') ) as mock_file: resp = await client.get(const.URL_API_ERROR_LOG, headers={ 'x-ha-access': 'yolo' }) assert len(mock_file.mock_calls) == 1 - assert mock_file.mock_calls[0][1][1] == hass.data[DATA_LOGGING] + assert mock_file.mock_calls[0][1][0] == hass.data[DATA_LOGGING] assert resp.status == 200 assert await resp.text() == 'Hello' + + +async def test_api_fire_event_context(hass, mock_api_client, + hass_access_token): + """Test if the API sets right context if we fire an event.""" + test_value = [] + + @ha.callback + def listener(event): + """Helper method that will verify our event got called.""" + test_value.append(event) + + hass.bus.async_listen("test.event", listener) + + await mock_api_client.post( + const.URL_API_EVENTS_EVENT.format("test.event"), + headers={ + 'authorization': 'Bearer {}'.format(hass_access_token) + }) + await hass.async_block_till_done() + + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + + assert len(test_value) == 1 + assert test_value[0].context.user_id == refresh_token.user.id + + +async def test_api_call_service_context(hass, mock_api_client, + hass_access_token): + """Test if the API sets right context if we call a service.""" + calls = async_mock_service(hass, 'test_domain', 'test_service') + + await mock_api_client.post( + '/api/services/test_domain/test_service', + headers={ + 'authorization': 'Bearer {}'.format(hass_access_token) + }) + await hass.async_block_till_done() + + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + + assert len(calls) == 1 + assert calls[0].context.user_id == refresh_token.user.id + + +async def test_api_set_state_context(hass, mock_api_client, hass_access_token): + """Test if the API sets right context if we set state.""" + await mock_api_client.post( + '/api/states/light.kitchen', + json={ + 'state': 'on' + }, + headers={ + 'authorization': 'Bearer {}'.format(hass_access_token) + }) + + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + + state = hass.states.get('light.kitchen') + assert state.context.user_id == refresh_token.user.id diff --git a/tests/components/test_conversation.py b/tests/components/test_conversation.py index d9c29cdae837cc..6a1d5a55c47eb9 100644 --- a/tests/components/test_conversation.py +++ b/tests/components/test_conversation.py @@ -89,7 +89,7 @@ async def test_register_before_setup(hass): assert intent.text_input == 'I would like the Grolsch beer' -async def test_http_processing_intent(hass, test_client): +async def test_http_processing_intent(hass, aiohttp_client): """Test processing intent via HTTP API.""" class TestIntentHandler(intent.IntentHandler): """Test Intent Handler.""" @@ -119,7 +119,7 @@ async def async_handle(self, intent): }) assert result - client = await test_client(hass.http.app) + client = await aiohttp_client(hass.http.app) resp = await client.post('/api/conversation/process', json={ 'text': 'I would like the Grolsch beer' }) @@ -243,7 +243,7 @@ async def test_toggle_intent(hass, sentence): assert call.data == {'entity_id': 'light.kitchen'} -async def test_http_api(hass, test_client): +async def test_http_api(hass, aiohttp_client): """Test the HTTP conversation API.""" result = await component.async_setup(hass, {}) assert result @@ -251,7 +251,7 @@ async def test_http_api(hass, test_client): result = await async_setup_component(hass, 'conversation', {}) assert result - client = await test_client(hass.http.app) + client = await aiohttp_client(hass.http.app) hass.states.async_set('light.kitchen', 'off') calls = async_mock_service(hass, 'homeassistant', 'turn_on') @@ -267,7 +267,7 @@ async def test_http_api(hass, test_client): assert call.data == {'entity_id': 'light.kitchen'} -async def test_http_api_wrong_data(hass, test_client): +async def test_http_api_wrong_data(hass, aiohttp_client): """Test the HTTP conversation API.""" result = await component.async_setup(hass, {}) assert result @@ -275,7 +275,7 @@ async def test_http_api_wrong_data(hass, test_client): result = await async_setup_component(hass, 'conversation', {}) assert result - client = await test_client(hass.http.app) + client = await aiohttp_client(hass.http.app) resp = await client.post('/api/conversation/process', json={ 'text': 123 diff --git a/tests/components/test_device_sun_light_trigger.py b/tests/components/test_device_sun_light_trigger.py index a8b8a201217d6c..774185c51c1cdc 100644 --- a/tests/components/test_device_sun_light_trigger.py +++ b/tests/components/test_device_sun_light_trigger.py @@ -79,8 +79,7 @@ def test_lights_on_when_sun_sets(self): self.assertTrue(light.is_on(self.hass)) - def test_lights_turn_off_when_everyone_leaves(self): \ - # pylint: disable=invalid-name + def test_lights_turn_off_when_everyone_leaves(self): """Test lights turn off when everyone leaves the house.""" light.turn_on(self.hass) @@ -97,8 +96,7 @@ def test_lights_turn_off_when_everyone_leaves(self): \ self.assertFalse(light.is_on(self.hass)) - def test_lights_turn_on_when_coming_home_after_sun_set(self): \ - # pylint: disable=invalid-name + def test_lights_turn_on_when_coming_home_after_sun_set(self): """Test lights turn on when coming home after sun set.""" test_time = datetime(2017, 4, 5, 3, 2, 3, tzinfo=dt_util.UTC) with patch('homeassistant.util.dt.utcnow', return_value=test_time): diff --git a/tests/components/test_discovery.py b/tests/components/test_discovery.py index dd22c87cb18058..8b997cb911cf62 100644 --- a/tests/components/test_discovery.py +++ b/tests/components/test_discovery.py @@ -5,7 +5,7 @@ import pytest -from homeassistant import data_entry_flow +from homeassistant import config_entries from homeassistant.bootstrap import async_setup_component from homeassistant.components import discovery from homeassistant.util.dt import utcnow @@ -175,5 +175,5 @@ def discover(netdisco): assert len(m_init.mock_calls) == 1 args, kwargs = m_init.mock_calls[0][1:] assert args == ('mock-component',) - assert kwargs['source'] == data_entry_flow.SOURCE_DISCOVERY + assert kwargs['context']['source'] == config_entries.SOURCE_DISCOVERY assert kwargs['data'] == discovery_info diff --git a/tests/components/test_feedreader.py b/tests/components/test_feedreader.py new file mode 100644 index 00000000000000..336d19664b42ff --- /dev/null +++ b/tests/components/test_feedreader.py @@ -0,0 +1,188 @@ +"""The tests for the feedreader component.""" +import time +from datetime import timedelta + +import unittest +from genericpath import exists +from logging import getLogger +from os import remove +from unittest import mock +from unittest.mock import patch + +from homeassistant.components import feedreader +from homeassistant.components.feedreader import CONF_URLS, FeedManager, \ + StoredData, EVENT_FEEDREADER, DEFAULT_SCAN_INTERVAL, CONF_MAX_ENTRIES, \ + DEFAULT_MAX_ENTRIES +from homeassistant.const import EVENT_HOMEASSISTANT_START, CONF_SCAN_INTERVAL +from homeassistant.core import callback +from homeassistant.setup import setup_component +from tests.common import get_test_home_assistant, load_fixture + +_LOGGER = getLogger(__name__) + +URL = 'http://some.rss.local/rss_feed.xml' +VALID_CONFIG_1 = { + feedreader.DOMAIN: { + CONF_URLS: [URL] + } +} +VALID_CONFIG_2 = { + feedreader.DOMAIN: { + CONF_URLS: [URL], + CONF_SCAN_INTERVAL: 60 + } +} +VALID_CONFIG_3 = { + feedreader.DOMAIN: { + CONF_URLS: [URL], + CONF_MAX_ENTRIES: 100 + } +} + + +class TestFeedreaderComponent(unittest.TestCase): + """Test the feedreader component.""" + + def setUp(self): + """Initialize values for this testcase class.""" + self.hass = get_test_home_assistant() + # Delete any previously stored data + data_file = self.hass.config.path("{}.pickle".format('feedreader')) + if exists(data_file): + remove(data_file) + + def tearDown(self): + """Stop everything that was started.""" + self.hass.stop() + + def test_setup_one_feed(self): + """Test the general setup of this component.""" + with patch("homeassistant.components.feedreader." + "track_time_interval") as track_method: + self.assertTrue(setup_component(self.hass, feedreader.DOMAIN, + VALID_CONFIG_1)) + track_method.assert_called_once_with(self.hass, mock.ANY, + DEFAULT_SCAN_INTERVAL) + + def test_setup_scan_interval(self): + """Test the setup of this component with scan interval.""" + with patch("homeassistant.components.feedreader." + "track_time_interval") as track_method: + self.assertTrue(setup_component(self.hass, feedreader.DOMAIN, + VALID_CONFIG_2)) + track_method.assert_called_once_with(self.hass, mock.ANY, + timedelta(seconds=60)) + + def test_setup_max_entries(self): + """Test the setup of this component with max entries.""" + self.assertTrue(setup_component(self.hass, feedreader.DOMAIN, + VALID_CONFIG_3)) + + def setup_manager(self, feed_data, max_entries=DEFAULT_MAX_ENTRIES): + """Generic test setup method.""" + events = [] + + @callback + def record_event(event): + """Add recorded event to set.""" + events.append(event) + + self.hass.bus.listen(EVENT_FEEDREADER, record_event) + + # Loading raw data from fixture and plug in to data object as URL + # works since the third-party feedparser library accepts a URL + # as well as the actual data. + data_file = self.hass.config.path("{}.pickle".format( + feedreader.DOMAIN)) + storage = StoredData(data_file) + with patch("homeassistant.components.feedreader." + "track_time_interval") as track_method: + manager = FeedManager(feed_data, DEFAULT_SCAN_INTERVAL, + max_entries, self.hass, storage) + # Can't use 'assert_called_once' here because it's not available + # in Python 3.5 yet. + track_method.assert_called_once_with(self.hass, mock.ANY, + DEFAULT_SCAN_INTERVAL) + # Artificially trigger update. + self.hass.bus.fire(EVENT_HOMEASSISTANT_START) + # Collect events. + self.hass.block_till_done() + return manager, events + + def test_feed(self): + """Test simple feed with valid data.""" + feed_data = load_fixture('feedreader.xml') + manager, events = self.setup_manager(feed_data) + assert len(events) == 1 + assert events[0].data.title == "Title 1" + assert events[0].data.description == "Description 1" + assert events[0].data.link == "http://www.example.com/link/1" + assert events[0].data.id == "GUID 1" + assert events[0].data.published_parsed.tm_year == 2018 + assert events[0].data.published_parsed.tm_mon == 4 + assert events[0].data.published_parsed.tm_mday == 30 + assert events[0].data.published_parsed.tm_hour == 5 + assert events[0].data.published_parsed.tm_min == 10 + assert manager.last_update_successful is True + + def test_feed_updates(self): + """Test feed updates.""" + # 1. Run + feed_data = load_fixture('feedreader.xml') + manager, events = self.setup_manager(feed_data) + assert len(events) == 1 + # 2. Run + feed_data2 = load_fixture('feedreader1.xml') + # Must patch 'get_timestamp' method because the timestamp is stored + # with the URL which in these tests is the raw XML data. + with patch("homeassistant.components.feedreader.StoredData." + "get_timestamp", return_value=time.struct_time( + (2018, 4, 30, 5, 10, 0, 0, 120, 0))): + manager2, events2 = self.setup_manager(feed_data2) + assert len(events2) == 1 + # 3. Run + feed_data3 = load_fixture('feedreader1.xml') + with patch("homeassistant.components.feedreader.StoredData." + "get_timestamp", return_value=time.struct_time( + (2018, 4, 30, 5, 11, 0, 0, 120, 0))): + manager3, events3 = self.setup_manager(feed_data3) + assert len(events3) == 0 + + def test_feed_default_max_length(self): + """Test long feed beyond the default 20 entry limit.""" + feed_data = load_fixture('feedreader2.xml') + manager, events = self.setup_manager(feed_data) + assert len(events) == 20 + + def test_feed_max_length(self): + """Test long feed beyond a configured 5 entry limit.""" + feed_data = load_fixture('feedreader2.xml') + manager, events = self.setup_manager(feed_data, max_entries=5) + assert len(events) == 5 + + def test_feed_without_publication_date(self): + """Test simple feed with entry without publication date.""" + feed_data = load_fixture('feedreader3.xml') + manager, events = self.setup_manager(feed_data) + assert len(events) == 2 + + def test_feed_invalid_data(self): + """Test feed with invalid data.""" + feed_data = "INVALID DATA" + manager, events = self.setup_manager(feed_data) + assert len(events) == 0 + assert manager.last_update_successful is True + + @mock.patch('feedparser.parse', return_value=None) + def test_feed_parsing_failed(self, mock_parse): + """Test feed where parsing fails.""" + data_file = self.hass.config.path("{}.pickle".format( + feedreader.DOMAIN)) + storage = StoredData(data_file) + manager = FeedManager("FEED DATA", DEFAULT_SCAN_INTERVAL, + DEFAULT_MAX_ENTRIES, self.hass, storage) + # Artificially trigger update. + self.hass.bus.fire(EVENT_HOMEASSISTANT_START) + # Collect events. + self.hass.block_till_done() + assert manager.last_update_successful is False diff --git a/tests/components/test_ffmpeg.py b/tests/components/test_ffmpeg.py index 5a5fdffd5a3bb0..44c3a1dd69545a 100644 --- a/tests/components/test_ffmpeg.py +++ b/tests/components/test_ffmpeg.py @@ -38,7 +38,7 @@ def _async_stop_ffmpeg(self, entity_ids): self.called_entities = entity_ids -class TestFFmpegSetup(object): +class TestFFmpegSetup: """Test class for ffmpeg.""" def setup_method(self): diff --git a/tests/components/test_folder_watcher.py b/tests/components/test_folder_watcher.py index 16ec7a58a029ab..b5ac9cca9d9b9e 100644 --- a/tests/components/test_folder_watcher.py +++ b/tests/components/test_folder_watcher.py @@ -8,7 +8,7 @@ async def test_invalid_path_setup(hass): - """Test that a invalid path is not setup.""" + """Test that an invalid path is not setup.""" assert not await async_setup_component( hass, folder_watcher.DOMAIN, { folder_watcher.DOMAIN: { diff --git a/tests/components/test_frontend.py b/tests/components/test_frontend.py deleted file mode 100644 index 973544495d70dc..00000000000000 --- a/tests/components/test_frontend.py +++ /dev/null @@ -1,215 +0,0 @@ -"""The tests for Home Assistant frontend.""" -import asyncio -import re -from unittest.mock import patch - -import pytest - -from homeassistant.setup import async_setup_component -from homeassistant.components.frontend import ( - DOMAIN, CONF_JS_VERSION, CONF_THEMES, CONF_EXTRA_HTML_URL, - CONF_EXTRA_HTML_URL_ES5, DATA_PANELS) -from homeassistant.components import websocket_api as wapi - - -@pytest.fixture -def mock_http_client(hass, aiohttp_client): - """Start the Hass HTTP component.""" - hass.loop.run_until_complete(async_setup_component(hass, 'frontend', {})) - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) - - -@pytest.fixture -def mock_http_client_with_themes(hass, aiohttp_client): - """Start the Hass HTTP component.""" - hass.loop.run_until_complete(async_setup_component(hass, 'frontend', { - DOMAIN: { - CONF_THEMES: { - 'happy': { - 'primary-color': 'red' - } - } - }})) - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) - - -@pytest.fixture -def mock_http_client_with_urls(hass, aiohttp_client): - """Start the Hass HTTP component.""" - hass.loop.run_until_complete(async_setup_component(hass, 'frontend', { - DOMAIN: { - CONF_JS_VERSION: 'auto', - CONF_EXTRA_HTML_URL: ["https://domain.com/my_extra_url.html"], - CONF_EXTRA_HTML_URL_ES5: - ["https://domain.com/my_extra_url_es5.html"] - }})) - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) - - -@asyncio.coroutine -def test_frontend_and_static(mock_http_client): - """Test if we can get the frontend.""" - resp = yield from mock_http_client.get('') - assert resp.status == 200 - assert 'cache-control' not in resp.headers - - text = yield from resp.text() - - # Test we can retrieve frontend.js - frontendjs = re.search( - r'(?P\/frontend_es5\/frontend-[A-Za-z0-9]{32}.html)', text) - - assert frontendjs is not None - resp = yield from mock_http_client.get(frontendjs.groups(0)[0]) - assert resp.status == 200 - assert 'public' in resp.headers.get('cache-control') - - -@asyncio.coroutine -def test_dont_cache_service_worker(mock_http_client): - """Test that we don't cache the service worker.""" - resp = yield from mock_http_client.get('/service_worker_es5.js') - assert resp.status == 200 - assert 'cache-control' not in resp.headers - - resp = yield from mock_http_client.get('/service_worker.js') - assert resp.status == 200 - assert 'cache-control' not in resp.headers - - -@asyncio.coroutine -def test_404(mock_http_client): - """Test for HTTP 404 error.""" - resp = yield from mock_http_client.get('/not-existing') - assert resp.status == 404 - - -@asyncio.coroutine -def test_we_cannot_POST_to_root(mock_http_client): - """Test that POST is not allow to root.""" - resp = yield from mock_http_client.post('/') - assert resp.status == 405 - - -@asyncio.coroutine -def test_states_routes(mock_http_client): - """All served by index.""" - resp = yield from mock_http_client.get('/states') - assert resp.status == 200 - - resp = yield from mock_http_client.get('/states/group.existing') - assert resp.status == 200 - - -@asyncio.coroutine -def test_themes_api(mock_http_client_with_themes): - """Test that /api/themes returns correct data.""" - resp = yield from mock_http_client_with_themes.get('/api/themes') - json = yield from resp.json() - assert json['default_theme'] == 'default' - assert json['themes'] == {'happy': {'primary-color': 'red'}} - - -@asyncio.coroutine -def test_themes_set_theme(hass, mock_http_client_with_themes): - """Test frontend.set_theme service.""" - yield from hass.services.async_call(DOMAIN, 'set_theme', {'name': 'happy'}) - yield from hass.async_block_till_done() - resp = yield from mock_http_client_with_themes.get('/api/themes') - json = yield from resp.json() - assert json['default_theme'] == 'happy' - - yield from hass.services.async_call( - DOMAIN, 'set_theme', {'name': 'default'}) - yield from hass.async_block_till_done() - resp = yield from mock_http_client_with_themes.get('/api/themes') - json = yield from resp.json() - assert json['default_theme'] == 'default' - - -@asyncio.coroutine -def test_themes_set_theme_wrong_name(hass, mock_http_client_with_themes): - """Test frontend.set_theme service called with wrong name.""" - yield from hass.services.async_call(DOMAIN, 'set_theme', {'name': 'wrong'}) - yield from hass.async_block_till_done() - resp = yield from mock_http_client_with_themes.get('/api/themes') - json = yield from resp.json() - assert json['default_theme'] == 'default' - - -@asyncio.coroutine -def test_themes_reload_themes(hass, mock_http_client_with_themes): - """Test frontend.reload_themes service.""" - with patch('homeassistant.components.frontend.load_yaml_config_file', - return_value={DOMAIN: { - CONF_THEMES: { - 'sad': {'primary-color': 'blue'} - }}}): - yield from hass.services.async_call(DOMAIN, 'set_theme', - {'name': 'happy'}) - yield from hass.services.async_call(DOMAIN, 'reload_themes') - yield from hass.async_block_till_done() - resp = yield from mock_http_client_with_themes.get('/api/themes') - json = yield from resp.json() - assert json['themes'] == {'sad': {'primary-color': 'blue'}} - assert json['default_theme'] == 'default' - - -@asyncio.coroutine -def test_missing_themes(mock_http_client): - """Test that themes API works when themes are not defined.""" - resp = yield from mock_http_client.get('/api/themes') - assert resp.status == 200 - json = yield from resp.json() - assert json['default_theme'] == 'default' - assert json['themes'] == {} - - -@asyncio.coroutine -def test_extra_urls(mock_http_client_with_urls): - """Test that extra urls are loaded.""" - resp = yield from mock_http_client_with_urls.get('/states?latest') - assert resp.status == 200 - text = yield from resp.text() - assert text.find('href="https://domain.com/my_extra_url.html"') >= 0 - - -@asyncio.coroutine -def test_extra_urls_es5(mock_http_client_with_urls): - """Test that es5 extra urls are loaded.""" - resp = yield from mock_http_client_with_urls.get('/states?es5') - assert resp.status == 200 - text = yield from resp.text() - assert text.find('href="https://domain.com/my_extra_url_es5.html"') >= 0 - - -@asyncio.coroutine -def test_panel_without_path(hass): - """Test panel registration without file path.""" - yield from hass.components.frontend.async_register_panel( - 'test_component', 'nonexistant_file') - yield from async_setup_component(hass, 'frontend', {}) - assert 'test_component' not in hass.data[DATA_PANELS] - - -async def test_get_panels(hass, hass_ws_client): - """Test get_panels command.""" - await async_setup_component(hass, 'frontend') - await hass.components.frontend.async_register_built_in_panel( - 'map', 'Map', 'mdi:account-location') - - client = await hass_ws_client(hass) - await client.send_json({ - 'id': 5, - 'type': 'get_panels', - }) - - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == wapi.TYPE_RESULT - assert msg['success'] - assert msg['result']['map']['component_name'] == 'map' - assert msg['result']['map']['url_path'] == 'map' - assert msg['result']['map']['icon'] == 'mdi:account-location' - assert msg['result']['map']['title'] == 'Map' diff --git a/tests/components/test_graphite.py b/tests/components/test_graphite.py index 280704fdc31867..892fe5b5f4d3e5 100644 --- a/tests/components/test_graphite.py +++ b/tests/components/test_graphite.py @@ -224,13 +224,12 @@ def test_run(self): def fake_get(): if len(runs) >= 2: return self.gf._quit_object - elif runs: + if runs: runs.append(1) return mock.MagicMock(event_type='somethingelse', data={'new_event': None}) - else: - runs.append(1) - return event + runs.append(1) + return event with mock.patch.object(self.gf, '_queue') as mock_queue: with mock.patch.object(self.gf, '_report_attributes') as mock_r: diff --git a/tests/components/test_history.py b/tests/components/test_history.py index 5d909492380c12..b348498b07e25f 100644 --- a/tests/components/test_history.py +++ b/tests/components/test_history.py @@ -83,9 +83,10 @@ def test_get_states(self): self.wait_recording_done() # Get states returns everything before POINT - self.assertEqual(states, - sorted(history.get_states(self.hass, future), - key=lambda state: state.entity_id)) + for state1, state2 in zip( + states, sorted(history.get_states(self.hass, future), + key=lambda state: state.entity_id)): + assert state1 == state2 # Test get_state here because we have a DB setup self.assertEqual( @@ -428,8 +429,7 @@ def test_get_significant_states_include_exclude(self): history.CONF_ENTITIES: ['media_player.test']}}}) self.check_significant_states(zero, four, states, config) - def check_significant_states(self, zero, four, states, config): \ - # pylint: disable=no-self-use + def check_significant_states(self, zero, four, states, config): """Check if significant states are retrieved.""" filters = history.Filters() exclude = config[history.DOMAIN].get(history.CONF_EXCLUDE) diff --git a/tests/components/test_init.py b/tests/components/test_init.py index c8c7e0d809b5a9..1e565054637766 100644 --- a/tests/components/test_init.py +++ b/tests/components/test_init.py @@ -74,30 +74,6 @@ def test_toggle(self): self.hass.block_till_done() self.assertEqual(1, len(calls)) - @patch('homeassistant.core.ServiceRegistry.call') - async def test_turn_on_to_not_block_for_domains_without_service(self, - mock_call): - """Test if turn_on is blocking domain with no service.""" - async_mock_service(self.hass, 'light', SERVICE_TURN_ON) - - # We can't test if our service call results in services being called - # because by mocking out the call service method, we mock out all - # So we mimic how the service registry calls services - service_call = ha.ServiceCall('homeassistant', 'turn_on', { - 'entity_id': ['light.test', 'sensor.bla', 'light.bla'] - }) - service = self.hass.services._services['homeassistant']['turn_on'] - await service.func(service_call) - - self.assertEqual(2, mock_call.call_count) - self.assertEqual( - ('light', 'turn_on', {'entity_id': ['light.bla', 'light.test']}, - True), - mock_call.call_args_list[0][0]) - self.assertEqual( - ('sensor', 'turn_on', {'entity_id': ['sensor.bla']}, False), - mock_call.call_args_list[1][0]) - @patch('homeassistant.config.os.path.isfile', Mock(return_value=True)) def test_reload_core_conf(self): """Test reload core conf service.""" @@ -284,3 +260,29 @@ async def test_turn_on_multiple_intent(hass): assert call.domain == 'light' assert call.service == 'turn_on' assert call.data == {'entity_id': ['light.test_lights_2']} + + +async def test_turn_on_to_not_block_for_domains_without_service(hass): + """Test if turn_on is blocking domain with no service.""" + await comps.async_setup(hass, {}) + async_mock_service(hass, 'light', SERVICE_TURN_ON) + hass.states.async_set('light.Bowl', STATE_ON) + hass.states.async_set('light.Ceiling', STATE_OFF) + + # We can't test if our service call results in services being called + # because by mocking out the call service method, we mock out all + # So we mimic how the service registry calls services + service_call = ha.ServiceCall('homeassistant', 'turn_on', { + 'entity_id': ['light.test', 'sensor.bla', 'light.bla'] + }) + service = hass.services._services['homeassistant']['turn_on'] + + with patch('homeassistant.core.ServiceRegistry.async_call', + side_effect=lambda *args: mock_coro()) as mock_call: + await service.func(service_call) + + assert mock_call.call_count == 2 + assert mock_call.call_args_list[0][0] == ( + 'light', 'turn_on', {'entity_id': ['light.bla', 'light.test']}, True) + assert mock_call.call_args_list[1][0] == ( + 'sensor', 'turn_on', {'entity_id': ['sensor.bla']}, False) diff --git a/tests/components/test_logbook.py b/tests/components/test_logbook.py index 6c71a263afa39d..a3a5273ed4e1d1 100644 --- a/tests/components/test_logbook.py +++ b/tests/components/test_logbook.py @@ -542,8 +542,7 @@ def assert_entry(self, entry, when=None, name=None, message=None, def create_state_changed_event(self, event_time_fired, entity_id, state, attributes=None, last_changed=None, - last_updated=None): \ - # pylint: disable=no-self-use + last_updated=None): """Create state changed event.""" # Logbook only cares about state change events that # contain an old state but will not actually act on it. diff --git a/tests/components/test_logger.py b/tests/components/test_logger.py index 61cb42e8bb5bcd..a55a66c6505fe5 100644 --- a/tests/components/test_logger.py +++ b/tests/components/test_logger.py @@ -10,6 +10,7 @@ RECORD = namedtuple('record', ('name', 'levelno')) +NO_DEFAULT_CONFIG = {'logger': {}} NO_LOGS_CONFIG = {'logger': {'default': 'info'}} TEST_CONFIG = { 'logger': { @@ -99,3 +100,29 @@ def test_set_filter(self): self.assert_logged('asdf', logging.DEBUG) self.assert_logged('dummy', logging.WARNING) + + def test_set_default_filter_empty_config(self): + """Test change default log level from empty configuration.""" + self.setup_logger(NO_DEFAULT_CONFIG) + + self.assert_logged('test', logging.DEBUG) + + self.hass.services.call( + logger.DOMAIN, 'set_default_level', {'level': 'warning'}) + self.hass.block_till_done() + + self.assert_not_logged('test', logging.DEBUG) + + def test_set_default_filter(self): + """Test change default log level with existing default.""" + self.setup_logger(TEST_CONFIG) + + self.assert_not_logged('asdf', logging.DEBUG) + self.assert_logged('dummy', logging.WARNING) + + self.hass.services.call( + logger.DOMAIN, 'set_default_level', {'level': 'debug'}) + self.hass.block_till_done() + + self.assert_logged('asdf', logging.DEBUG) + self.assert_logged('dummy', logging.WARNING) diff --git a/tests/components/test_microsoft_face.py b/tests/components/test_microsoft_face.py index 370059a0a095f5..92f840b8033d7f 100644 --- a/tests/components/test_microsoft_face.py +++ b/tests/components/test_microsoft_face.py @@ -9,7 +9,7 @@ get_test_home_assistant, assert_setup_component, mock_coro, load_fixture) -class TestMicrosoftFaceSetup(object): +class TestMicrosoftFaceSetup: """Test the microsoft face component.""" def setup_method(self): diff --git a/tests/components/test_mqtt_eventstream.py b/tests/components/test_mqtt_eventstream.py index f4fc3e89ee096a..8da1311c87dc9e 100644 --- a/tests/components/test_mqtt_eventstream.py +++ b/tests/components/test_mqtt_eventstream.py @@ -18,7 +18,7 @@ ) -class TestMqttEventStream(object): +class TestMqttEventStream: """Test the MQTT eventstream module.""" def setup_method(self): @@ -44,11 +44,11 @@ def add_eventstream(self, sub_topic=None, pub_topic=None, eventstream.DOMAIN: config}) def test_setup_succeeds(self): - """"Test the success of the setup.""" + """Test the success of the setup.""" assert self.add_eventstream() def test_setup_with_pub(self): - """"Test the setup with subscription.""" + """Test the setup with subscription.""" # Should start off with no listeners for all events assert self.hass.bus.listeners.get('*') is None @@ -60,7 +60,7 @@ def test_setup_with_pub(self): @patch('homeassistant.components.mqtt.async_subscribe') def test_subscribe(self, mock_sub): - """"Test the subscription.""" + """Test the subscription.""" sub_topic = 'foo' assert self.add_eventstream(sub_topic=sub_topic) self.hass.block_till_done() @@ -71,7 +71,7 @@ def test_subscribe(self, mock_sub): @patch('homeassistant.components.mqtt.async_publish') @patch('homeassistant.core.dt_util.utcnow') def test_state_changed_event_sends_message(self, mock_utcnow, mock_pub): - """"Test the sending of a new message if event changed.""" + """Test the sending of a new message if event changed.""" now = dt_util.as_utc(dt_util.now()) e_id = 'fake.entity' pub_topic = 'bar' @@ -104,16 +104,18 @@ def test_state_changed_event_sends_message(self, mock_utcnow, mock_pub): "state": "on", "entity_id": e_id, "attributes": {}, - "last_changed": now.isoformat() + "last_changed": now.isoformat(), } event['event_data'] = {"new_state": new_state, "entity_id": e_id} # Verify that the message received was that expected - assert json.loads(msg) == event + result = json.loads(msg) + result['event_data']['new_state'].pop('context') + assert result == event @patch('homeassistant.components.mqtt.async_publish') def test_time_event_does_not_send_message(self, mock_pub): - """"Test the sending of a new message if time event.""" + """Test the sending of a new message if time event.""" assert self.add_eventstream(pub_topic='bar') self.hass.block_till_done() @@ -125,7 +127,7 @@ def test_time_event_does_not_send_message(self, mock_pub): assert not mock_pub.called def test_receiving_remote_event_fires_hass_event(self): - """"Test the receiving of the remotely fired event.""" + """Test the receiving of the remotely fired event.""" sub_topic = 'foo' assert self.add_eventstream(sub_topic=sub_topic) self.hass.block_till_done() @@ -150,7 +152,7 @@ def listener(_): @patch('homeassistant.components.mqtt.async_publish') def test_ignored_event_doesnt_send_over_stream(self, mock_pub): - """"Test the ignoring of sending events if defined.""" + """Test the ignoring of sending events if defined.""" assert self.add_eventstream(pub_topic='bar', ignore_event=['state_changed']) self.hass.block_till_done() @@ -177,7 +179,7 @@ def test_ignored_event_doesnt_send_over_stream(self, mock_pub): @patch('homeassistant.components.mqtt.async_publish') def test_wrong_ignored_event_sends_over_stream(self, mock_pub): - """"Test the ignoring of sending events if defined.""" + """Test the ignoring of sending events if defined.""" assert self.add_eventstream(pub_topic='bar', ignore_event=['statee_changed']) self.hass.block_till_done() diff --git a/tests/components/test_mqtt_statestream.py b/tests/components/test_mqtt_statestream.py index e120c3a7dd2e46..4cf79e679cd470 100644 --- a/tests/components/test_mqtt_statestream.py +++ b/tests/components/test_mqtt_statestream.py @@ -12,7 +12,7 @@ ) -class TestMqttStateStream(object): +class TestMqttStateStream: """Test the MQTT statestream module.""" def setup_method(self): @@ -47,17 +47,17 @@ def test_fails_with_no_base(self): assert self.add_statestream() is False def test_setup_succeeds_without_attributes(self): - """"Test the success of the setup with a valid base_topic.""" + """Test the success of the setup with a valid base_topic.""" assert self.add_statestream(base_topic='pub') def test_setup_succeeds_with_attributes(self): - """"Test setup with a valid base_topic and publish_attributes.""" + """Test setup with a valid base_topic and publish_attributes.""" assert self.add_statestream(base_topic='pub', publish_attributes=True) @patch('homeassistant.components.mqtt.async_publish') @patch('homeassistant.core.dt_util.utcnow') def test_state_changed_event_sends_message(self, mock_utcnow, mock_pub): - """"Test the sending of a new message if event changed.""" + """Test the sending of a new message if event changed.""" e_id = 'fake.entity' base_topic = 'pub' @@ -84,7 +84,7 @@ def test_state_changed_event_sends_message_and_timestamp( self, mock_utcnow, mock_pub): - """"Test the sending of a message and timestamps if event changed.""" + """Test the sending of a message and timestamps if event changed.""" e_id = 'another.entity' base_topic = 'pub' @@ -118,7 +118,7 @@ def test_state_changed_event_sends_message_and_timestamp( @patch('homeassistant.components.mqtt.async_publish') @patch('homeassistant.core.dt_util.utcnow') def test_state_changed_attr_sends_message(self, mock_utcnow, mock_pub): - """"Test the sending of a new message if attribute changed.""" + """Test the sending of a new message if attribute changed.""" e_id = 'fake.entity' base_topic = 'pub' @@ -160,7 +160,7 @@ def test_state_changed_attr_sends_message(self, mock_utcnow, mock_pub): @patch('homeassistant.components.mqtt.async_publish') @patch('homeassistant.core.dt_util.utcnow') def test_state_changed_event_include_domain(self, mock_utcnow, mock_pub): - """"Test that filtering on included domain works as expected.""" + """Test that filtering on included domain works as expected.""" base_topic = 'pub' incl = { @@ -198,7 +198,7 @@ def test_state_changed_event_include_domain(self, mock_utcnow, mock_pub): @patch('homeassistant.components.mqtt.async_publish') @patch('homeassistant.core.dt_util.utcnow') def test_state_changed_event_include_entity(self, mock_utcnow, mock_pub): - """"Test that filtering on included entity works as expected.""" + """Test that filtering on included entity works as expected.""" base_topic = 'pub' incl = { @@ -236,7 +236,7 @@ def test_state_changed_event_include_entity(self, mock_utcnow, mock_pub): @patch('homeassistant.components.mqtt.async_publish') @patch('homeassistant.core.dt_util.utcnow') def test_state_changed_event_exclude_domain(self, mock_utcnow, mock_pub): - """"Test that filtering on excluded domain works as expected.""" + """Test that filtering on excluded domain works as expected.""" base_topic = 'pub' incl = {} @@ -274,7 +274,7 @@ def test_state_changed_event_exclude_domain(self, mock_utcnow, mock_pub): @patch('homeassistant.components.mqtt.async_publish') @patch('homeassistant.core.dt_util.utcnow') def test_state_changed_event_exclude_entity(self, mock_utcnow, mock_pub): - """"Test that filtering on excluded entity works as expected.""" + """Test that filtering on excluded entity works as expected.""" base_topic = 'pub' incl = {} @@ -313,7 +313,7 @@ def test_state_changed_event_exclude_entity(self, mock_utcnow, mock_pub): @patch('homeassistant.core.dt_util.utcnow') def test_state_changed_event_exclude_domain_include_entity( self, mock_utcnow, mock_pub): - """"Test filtering with excluded domain and included entity.""" + """Test filtering with excluded domain and included entity.""" base_topic = 'pub' incl = { @@ -354,7 +354,7 @@ def test_state_changed_event_exclude_domain_include_entity( @patch('homeassistant.core.dt_util.utcnow') def test_state_changed_event_include_domain_exclude_entity( self, mock_utcnow, mock_pub): - """"Test filtering with included domain and excluded entity.""" + """Test filtering with included domain and excluded entity.""" base_topic = 'pub' incl = { diff --git a/tests/components/test_panel_custom.py b/tests/components/test_panel_custom.py index d33221da2a7af2..596aa1b3c0b25c 100644 --- a/tests/components/test_panel_custom.py +++ b/tests/components/test_panel_custom.py @@ -1,23 +1,11 @@ """The tests for the panel_custom component.""" -import asyncio from unittest.mock import Mock, patch -import pytest - from homeassistant import setup from homeassistant.components import frontend -from tests.common import mock_component - - -@pytest.fixture(autouse=True) -def mock_frontend_loaded(hass): - """Mock frontend is loaded.""" - mock_component(hass, 'frontend') - -@asyncio.coroutine -def test_webcomponent_custom_path_not_found(hass): +async def test_webcomponent_custom_path_not_found(hass): """Test if a web component is found in config panels dir.""" filename = 'mock.file' @@ -33,45 +21,96 @@ def test_webcomponent_custom_path_not_found(hass): } with patch('os.path.isfile', Mock(return_value=False)): - result = yield from setup.async_setup_component( + result = await setup.async_setup_component( hass, 'panel_custom', config ) assert not result assert len(hass.data.get(frontend.DATA_PANELS, {})) == 0 -@asyncio.coroutine -def test_webcomponent_custom_path(hass): +async def test_webcomponent_custom_path(hass): """Test if a web component is found in config panels dir.""" filename = 'mock.file' config = { 'panel_custom': { - 'name': 'todomvc', + 'name': 'todo-mvc', 'webcomponent_path': filename, 'sidebar_title': 'Sidebar Title', 'sidebar_icon': 'mdi:iconicon', 'url_path': 'nice_url', - 'config': 5, + 'config': { + 'hello': 'world', + } } } with patch('os.path.isfile', Mock(return_value=True)): with patch('os.access', Mock(return_value=True)): - result = yield from setup.async_setup_component( + result = await setup.async_setup_component( hass, 'panel_custom', config ) assert result panels = hass.data.get(frontend.DATA_PANELS, []) - assert len(panels) == 1 + assert panels assert 'nice_url' in panels panel = panels['nice_url'] - assert panel.config == 5 + assert panel.config == { + 'hello': 'world', + '_panel_custom': { + 'html_url': '/api/panel_custom/todo-mvc', + 'name': 'todo-mvc', + 'embed_iframe': False, + 'trust_external': False, + }, + } assert panel.frontend_url_path == 'nice_url' assert panel.sidebar_icon == 'mdi:iconicon' assert panel.sidebar_title == 'Sidebar Title' - assert panel.path == filename + + +async def test_js_webcomponent(hass): + """Test if a web component is found in config panels dir.""" + config = { + 'panel_custom': { + 'name': 'todo-mvc', + 'js_url': '/local/bla.js', + 'sidebar_title': 'Sidebar Title', + 'sidebar_icon': 'mdi:iconicon', + 'url_path': 'nice_url', + 'config': { + 'hello': 'world', + }, + 'embed_iframe': True, + 'trust_external_script': True, + } + } + + result = await setup.async_setup_component( + hass, 'panel_custom', config + ) + assert result + + panels = hass.data.get(frontend.DATA_PANELS, []) + + assert panels + assert 'nice_url' in panels + + panel = panels['nice_url'] + + assert panel.config == { + 'hello': 'world', + '_panel_custom': { + 'js_url': '/local/bla.js', + 'name': 'todo-mvc', + 'embed_iframe': True, + 'trust_external': True, + } + } + assert panel.frontend_url_path == 'nice_url' + assert panel.sidebar_icon == 'mdi:iconicon' + assert panel.sidebar_title == 'Sidebar Title' diff --git a/tests/components/test_panel_iframe.py b/tests/components/test_panel_iframe.py index 91a07511787b3b..214eda04ad8561 100644 --- a/tests/components/test_panel_iframe.py +++ b/tests/components/test_panel_iframe.py @@ -1,6 +1,5 @@ """The tests for the panel_iframe component.""" import unittest -from unittest.mock import patch from homeassistant import setup from homeassistant.components import frontend @@ -33,8 +32,6 @@ def test_wrong_config(self): 'panel_iframe': conf }) - @patch.dict('hass_frontend_es5.FINGERPRINTS', - {'iframe': 'md5md5'}) def test_correct_config(self): """Test correct config.""" assert setup.setup_component( @@ -70,7 +67,6 @@ def test_correct_config(self): 'config': {'url': 'http://192.168.1.1'}, 'icon': 'mdi:network-wireless', 'title': 'Router', - 'url': '/frontend_es5/panels/ha-panel-iframe-md5md5.html', 'url_path': 'router' } @@ -79,7 +75,6 @@ def test_correct_config(self): 'config': {'url': 'https://www.wunderground.com/us/ca/san-diego'}, 'icon': 'mdi:weather', 'title': 'Weather', - 'url': '/frontend_es5/panels/ha-panel-iframe-md5md5.html', 'url_path': 'weather', } @@ -88,7 +83,6 @@ def test_correct_config(self): 'config': {'url': '/api'}, 'icon': 'mdi:weather', 'title': 'Api', - 'url': '/frontend_es5/panels/ha-panel-iframe-md5md5.html', 'url_path': 'api', } @@ -97,6 +91,5 @@ def test_correct_config(self): 'config': {'url': 'ftp://some/ftp'}, 'icon': 'mdi:weather', 'title': 'FTP', - 'url': '/frontend_es5/panels/ha-panel-iframe-md5md5.html', 'url_path': 'ftp', } diff --git a/tests/components/test_plant.py b/tests/components/test_plant.py index ee1372509d92d0..95167dd181b1cb 100644 --- a/tests/components/test_plant.py +++ b/tests/components/test_plant.py @@ -41,7 +41,7 @@ } -class _MockState(object): +class _MockState: def __init__(self, state=None): self.state = state diff --git a/tests/components/test_prometheus.py b/tests/components/test_prometheus.py index 6cc0e4fcadab63..49744421c726ec 100644 --- a/tests/components/test_prometheus.py +++ b/tests/components/test_prometheus.py @@ -8,11 +8,11 @@ @pytest.fixture def prometheus_client(loop, hass, aiohttp_client): - """Initialize a aiohttp_client with Prometheus component.""" + """Initialize an aiohttp_client with Prometheus component.""" assert loop.run_until_complete(async_setup_component( hass, prometheus.DOMAIN, - {}, + {prometheus.DOMAIN: {}}, )) return loop.run_until_complete(aiohttp_client(hass.http.app)) diff --git a/tests/components/test_rest_command.py b/tests/components/test_rest_command.py index 3ddcfae8c01b4e..097fb799d4028c 100644 --- a/tests/components/test_rest_command.py +++ b/tests/components/test_rest_command.py @@ -10,7 +10,7 @@ get_test_home_assistant, assert_setup_component) -class TestRestCommandSetup(object): +class TestRestCommandSetup: """Test the rest command component.""" def setup_method(self): @@ -47,7 +47,7 @@ def test_setup_component_test_service(self): assert self.hass.services.has_service(rc.DOMAIN, 'test_get') -class TestRestCommandComponent(object): +class TestRestCommandComponent: """Test the rest command component.""" def setup_method(self): diff --git a/tests/components/test_ring.py b/tests/components/test_ring.py index 3837ec130611e7..7b974686a4e132 100644 --- a/tests/components/test_ring.py +++ b/tests/components/test_ring.py @@ -42,6 +42,8 @@ def tearDown(self): # pylint: disable=invalid-name @requests_mock.Mocker() def test_setup(self, mock): """Test the setup.""" + mock.post('https://oauth.ring.com/oauth/token', + text=load_fixture('ring_oauth.json')) mock.post('https://api.ring.com/clients_api/session', text=load_fixture('ring_session.json')) response = ring.setup(self.hass, self.config) diff --git a/tests/components/test_script.py b/tests/components/test_script.py index fcb0047c135bb7..c4282cdfbaf201 100644 --- a/tests/components/test_script.py +++ b/tests/components/test_script.py @@ -199,8 +199,10 @@ def test_reload_service(self): } }] }}}): - script.reload(self.hass) - self.hass.block_till_done() + with patch('homeassistant.config.find_config_file', + return_value=''): + script.reload(self.hass) + self.hass.block_till_done() assert self.hass.states.get(ENTITY_ID) is None assert not self.hass.services.has_service(script.DOMAIN, 'test') diff --git a/tests/components/test_shell_command.py b/tests/components/test_shell_command.py index 6f993732c38e85..a1acffd62e591e 100644 --- a/tests/components/test_shell_command.py +++ b/tests/components/test_shell_command.py @@ -19,8 +19,7 @@ def mock_process_creator(error: bool = False) -> asyncio.coroutine: def communicate() -> Tuple[bytes, bytes]: """Mock a coroutine that runs a process when yielded. - Returns: - a tuple of (stdout, stderr). + Returns a tuple of (stdout, stderr). """ return b"I am stdout", b"I am stderr" @@ -149,3 +148,41 @@ def test_subprocess_error(self, mock_error, mock_call): self.assertEqual(1, mock_call.call_count) self.assertEqual(1, mock_error.call_count) self.assertFalse(os.path.isfile(path)) + + @patch('homeassistant.components.shell_command._LOGGER.debug') + def test_stdout_captured(self, mock_output): + """Test subprocess that has stdout.""" + test_phrase = "I have output" + self.assertTrue( + setup_component(self.hass, shell_command.DOMAIN, { + shell_command.DOMAIN: { + 'test_service': "echo {}".format(test_phrase) + } + })) + + self.hass.services.call('shell_command', 'test_service', + blocking=True) + + self.hass.block_till_done() + self.assertEqual(1, mock_output.call_count) + self.assertEqual(test_phrase.encode() + b'\n', + mock_output.call_args_list[0][0][-1]) + + @patch('homeassistant.components.shell_command._LOGGER.debug') + def test_stderr_captured(self, mock_output): + """Test subprocess that has stderr.""" + test_phrase = "I have error" + self.assertTrue( + setup_component(self.hass, shell_command.DOMAIN, { + shell_command.DOMAIN: { + 'test_service': ">&2 echo {}".format(test_phrase) + } + })) + + self.hass.services.call('shell_command', 'test_service', + blocking=True) + + self.hass.block_till_done() + self.assertEqual(1, mock_output.call_count) + self.assertEqual(test_phrase.encode() + b'\n', + mock_output.call_args_list[0][0][-1]) diff --git a/tests/components/test_snips.py b/tests/components/test_snips.py index 2342e897708e6c..baeda2c49a839b 100644 --- a/tests/components/test_snips.py +++ b/tests/components/test_snips.py @@ -5,6 +5,7 @@ from homeassistant.bootstrap import async_setup_component from homeassistant.components.mqtt import MQTT_PUBLISH_SCHEMA import homeassistant.components.snips as snips +from homeassistant.helpers.intent import (ServiceIntentHandler, async_register) from tests.common import (async_fire_mqtt_message, async_mock_intent, async_mock_service) @@ -118,10 +119,55 @@ async def test_snips_intent(hass, mqtt_mock): intent = intents[0] assert intent.platform == 'snips' assert intent.intent_type == 'Lights' - assert intent.slots == {'light_color': {'value': 'green'}} + assert intent.slots == {'light_color': {'value': 'green'}, + 'probability': {'value': 1}, + 'site_id': {'value': None}} assert intent.text_input == 'turn the lights green' +async def test_snips_service_intent(hass, mqtt_mock): + """Test ServiceIntentHandler via Snips.""" + hass.states.async_set('light.kitchen', 'off') + calls = async_mock_service(hass, 'light', 'turn_on') + result = await async_setup_component(hass, "snips", { + "snips": {}, + }) + assert result + payload = """ + { + "input": "turn the light on", + "intent": { + "intentName": "Lights", + "probability": 0.85 + }, + "siteId": "default", + "slots": [ + { + "slotName": "name", + "value": { + "kind": "Custom", + "value": "kitchen" + } + } + ] + } + """ + + async_register(hass, ServiceIntentHandler( + "Lights", "light", 'turn_on', "Turned {} on")) + + async_fire_mqtt_message(hass, 'hermes/intent/Lights', + payload) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].domain == 'light' + assert calls[0].service == 'turn_on' + assert calls[0].data['entity_id'] == 'light.kitchen' + assert 'probability' not in calls[0].data + assert 'site_id' not in calls[0].data + + async def test_snips_intent_with_duration(hass, mqtt_mock): """Test intent with Snips duration.""" result = await async_setup_component(hass, "snips", { @@ -169,7 +215,9 @@ async def test_snips_intent_with_duration(hass, mqtt_mock): intent = intents[0] assert intent.platform == 'snips' assert intent.intent_type == 'SetTimer' - assert intent.slots == {'timer_duration': {'value': 300}} + assert intent.slots == {'probability': {'value': 1}, + 'site_id': {'value': None}, + 'timer_duration': {'value': 300}} async def test_intent_speech_response(hass, mqtt_mock): @@ -318,11 +366,51 @@ async def test_snips_low_probability(hass, mqtt_mock, caplog): assert 'Intent below probaility threshold 0.49 < 0.5' in caplog.text +async def test_intent_special_slots(hass, mqtt_mock): + """Test intent special slot values via Snips.""" + calls = async_mock_service(hass, 'light', 'turn_on') + result = await async_setup_component(hass, "snips", { + "snips": {}, + }) + assert result + result = await async_setup_component(hass, "intent_script", { + "intent_script": { + "Lights": { + "action": { + "service": "light.turn_on", + "data_template": { + "probability": "{{ probability }}", + "site_id": "{{ site_id }}" + } + } + } + } + }) + assert result + payload = """ + { + "input": "turn the light on", + "intent": { + "intentName": "Lights", + "probability": 0.85 + }, + "siteId": "default", + "slots": [] + } + """ + async_fire_mqtt_message(hass, 'hermes/intent/Lights', payload) + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].domain == 'light' + assert calls[0].service == 'turn_on' + assert calls[0].data['probability'] == '0.85' + assert calls[0].data['site_id'] == 'default' + + async def test_snips_say(hass, caplog): """Test snips say with invalid config.""" - calls = async_mock_service(hass, 'snips', 'say', - snips.SERVICE_SCHEMA_SAY) - + calls = async_mock_service(hass, 'snips', 'say', snips.SERVICE_SCHEMA_SAY) data = {'text': 'Hello'} await hass.services.async_call('snips', 'say', data) await hass.async_block_till_done() diff --git a/tests/components/test_spaceapi.py b/tests/components/test_spaceapi.py new file mode 100644 index 00000000000000..e7e7d158a31ad8 --- /dev/null +++ b/tests/components/test_spaceapi.py @@ -0,0 +1,113 @@ +"""The tests for the Home Assistant SpaceAPI component.""" +# pylint: disable=protected-access +from unittest.mock import patch + +import pytest +from tests.common import mock_coro + +from homeassistant.components.spaceapi import ( + DOMAIN, SPACEAPI_VERSION, URL_API_SPACEAPI) +from homeassistant.setup import async_setup_component + +CONFIG = { + DOMAIN: { + 'space': 'Home', + 'logo': 'https://home-assistant.io/logo.png', + 'url': 'https://home-assistant.io', + 'location': {'address': 'In your Home'}, + 'contact': {'email': 'hello@home-assistant.io'}, + 'issue_report_channels': ['email'], + 'state': { + 'entity_id': 'test.test_door', + 'icon_open': 'https://home-assistant.io/open.png', + 'icon_closed': 'https://home-assistant.io/close.png', + }, + 'sensors': { + 'temperature': ['test.temp1', 'test.temp2'], + 'humidity': ['test.hum1'], + } + } +} + +SENSOR_OUTPUT = { + 'temperature': [ + { + 'location': 'Home', + 'name': 'temp1', + 'unit': '°C', + 'value': '25' + }, + { + 'location': 'Home', + 'name': 'temp2', + 'unit': '°C', + 'value': '23' + }, + ], + 'humidity': [ + { + 'location': 'Home', + 'name': 'hum1', + 'unit': '%', + 'value': '88' + }, + ] +} + + +@pytest.fixture +def mock_client(hass, aiohttp_client): + """Start the Home Assistant HTTP component.""" + with patch('homeassistant.components.spaceapi', + return_value=mock_coro(True)): + hass.loop.run_until_complete( + async_setup_component(hass, 'spaceapi', CONFIG)) + + hass.states.async_set('test.temp1', 25, + attributes={'unit_of_measurement': '°C'}) + hass.states.async_set('test.temp2', 23, + attributes={'unit_of_measurement': '°C'}) + hass.states.async_set('test.hum1', 88, + attributes={'unit_of_measurement': '%'}) + + return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + + +async def test_spaceapi_get(hass, mock_client): + """Test response after start-up Home Assistant.""" + resp = await mock_client.get(URL_API_SPACEAPI) + assert resp.status == 200 + + data = await resp.json() + + assert data['api'] == SPACEAPI_VERSION + assert data['space'] == 'Home' + assert data['contact']['email'] == 'hello@home-assistant.io' + assert data['location']['address'] == 'In your Home' + assert data['location']['latitude'] == 32.87336 + assert data['location']['longitude'] == -117.22743 + assert data['state']['open'] == 'null' + assert data['state']['icon']['open'] == \ + 'https://home-assistant.io/open.png' + assert data['state']['icon']['close'] == \ + 'https://home-assistant.io/close.png' + + +async def test_spaceapi_state_get(hass, mock_client): + """Test response if the state entity was set.""" + hass.states.async_set('test.test_door', True) + + resp = await mock_client.get(URL_API_SPACEAPI) + assert resp.status == 200 + + data = await resp.json() + assert data['state']['open'] == bool(1) + + +async def test_spaceapi_sensors_get(hass, mock_client): + """Test the response for the sensors.""" + resp = await mock_client.get(URL_API_SPACEAPI) + assert resp.status == 200 + + data = await resp.json() + assert data['sensors'] == SENSOR_OUTPUT diff --git a/tests/components/test_system_log.py b/tests/components/test_system_log.py index 59e99e5c1b5ca8..5d48fd881273cf 100644 --- a/tests/components/test_system_log.py +++ b/tests/components/test_system_log.py @@ -28,7 +28,7 @@ async def get_error_log(hass, aiohttp_client, expected_count): def _generate_and_log_exception(exception, log): try: raise Exception(exception) - except: # noqa: E722 # pylint: disable=bare-except + except: # noqa: E722 pylint: disable=bare-except _LOGGER.exception(log) diff --git a/tests/components/test_websocket_api.py b/tests/components/test_websocket_api.py index 0a130e507d4799..199a9d804f83f2 100644 --- a/tests/components/test_websocket_api.py +++ b/tests/components/test_websocket_api.py @@ -10,7 +10,7 @@ from homeassistant.components import websocket_api as wapi from homeassistant.setup import async_setup_component -from tests.common import mock_coro +from tests.common import mock_coro, async_mock_service API_PASSWORD = 'test1234' @@ -77,7 +77,7 @@ def test_auth_via_msg_incorrect_pass(no_auth_websocket_client): assert mock_process_wrong_login.called assert msg['type'] == wapi.TYPE_AUTH_INVALID - assert msg['message'] == 'Invalid password' + assert msg['message'] == 'Invalid access token or password' @asyncio.coroutine @@ -311,5 +311,231 @@ def test_unknown_command(websocket_client): 'type': 'unknown_command', }) - msg = yield from websocket_client.receive() - assert msg.type == WSMsgType.close + msg = yield from websocket_client.receive_json() + assert not msg['success'] + assert msg['error']['code'] == wapi.ERR_UNKNOWN_COMMAND + + +async def test_auth_active_with_token(hass, aiohttp_client, hass_access_token): + """Test authenticating with a token.""" + assert await async_setup_component(hass, 'websocket_api', { + 'http': { + 'api_password': API_PASSWORD + } + }) + + client = await aiohttp_client(hass.http.app) + + async with client.ws_connect(wapi.URL) as ws: + with patch('homeassistant.auth.AuthManager.active') as auth_active: + auth_active.return_value = True + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_REQUIRED + + await ws.send_json({ + 'type': wapi.TYPE_AUTH, + 'access_token': hass_access_token + }) + + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_OK + + +async def test_auth_active_user_inactive(hass, aiohttp_client, + hass_access_token): + """Test authenticating with a token.""" + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + refresh_token.user.is_active = False + assert await async_setup_component(hass, 'websocket_api', { + 'http': { + 'api_password': API_PASSWORD + } + }) + + client = await aiohttp_client(hass.http.app) + + async with client.ws_connect(wapi.URL) as ws: + with patch('homeassistant.auth.AuthManager.active') as auth_active: + auth_active.return_value = True + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_REQUIRED + + await ws.send_json({ + 'type': wapi.TYPE_AUTH, + 'access_token': hass_access_token + }) + + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_INVALID + + +async def test_auth_active_with_password_not_allow(hass, aiohttp_client): + """Test authenticating with a token.""" + assert await async_setup_component(hass, 'websocket_api', { + 'http': { + 'api_password': API_PASSWORD + } + }) + + client = await aiohttp_client(hass.http.app) + + async with client.ws_connect(wapi.URL) as ws: + with patch('homeassistant.auth.AuthManager.active', + return_value=True): + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_REQUIRED + + await ws.send_json({ + 'type': wapi.TYPE_AUTH, + 'api_password': API_PASSWORD + }) + + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_INVALID + + +async def test_auth_legacy_support_with_password(hass, aiohttp_client): + """Test authenticating with a token.""" + assert await async_setup_component(hass, 'websocket_api', { + 'http': { + 'api_password': API_PASSWORD + } + }) + + client = await aiohttp_client(hass.http.app) + + async with client.ws_connect(wapi.URL) as ws: + with patch('homeassistant.auth.AuthManager.active', + return_value=True),\ + patch('homeassistant.auth.AuthManager.support_legacy', + return_value=True): + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_REQUIRED + + await ws.send_json({ + 'type': wapi.TYPE_AUTH, + 'api_password': API_PASSWORD + }) + + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_OK + + +async def test_auth_with_invalid_token(hass, aiohttp_client): + """Test authenticating with a token.""" + assert await async_setup_component(hass, 'websocket_api', { + 'http': { + 'api_password': API_PASSWORD + } + }) + + client = await aiohttp_client(hass.http.app) + + async with client.ws_connect(wapi.URL) as ws: + with patch('homeassistant.auth.AuthManager.active') as auth_active: + auth_active.return_value = True + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_REQUIRED + + await ws.send_json({ + 'type': wapi.TYPE_AUTH, + 'access_token': 'incorrect' + }) + + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_INVALID + + +async def test_call_service_context_with_user(hass, aiohttp_client, + hass_access_token): + """Test that the user is set in the service call context.""" + assert await async_setup_component(hass, 'websocket_api', { + 'http': { + 'api_password': API_PASSWORD + } + }) + + calls = async_mock_service(hass, 'domain_test', 'test_service') + client = await aiohttp_client(hass.http.app) + + async with client.ws_connect(wapi.URL) as ws: + with patch('homeassistant.auth.AuthManager.active') as auth_active: + auth_active.return_value = True + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_REQUIRED + + await ws.send_json({ + 'type': wapi.TYPE_AUTH, + 'access_token': hass_access_token + }) + + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_OK + + await ws.send_json({ + 'id': 5, + 'type': wapi.TYPE_CALL_SERVICE, + 'domain': 'domain_test', + 'service': 'test_service', + 'service_data': { + 'hello': 'world' + } + }) + + msg = await ws.receive_json() + assert msg['success'] + + refresh_token = await hass.auth.async_validate_access_token( + hass_access_token) + + assert len(calls) == 1 + call = calls[0] + assert call.domain == 'domain_test' + assert call.service == 'test_service' + assert call.data == {'hello': 'world'} + assert call.context.user_id == refresh_token.user.id + + +async def test_call_service_context_no_user(hass, aiohttp_client): + """Test that connection without user sets context.""" + assert await async_setup_component(hass, 'websocket_api', { + 'http': { + 'api_password': API_PASSWORD + } + }) + + calls = async_mock_service(hass, 'domain_test', 'test_service') + client = await aiohttp_client(hass.http.app) + + async with client.ws_connect(wapi.URL) as ws: + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_REQUIRED + + await ws.send_json({ + 'type': wapi.TYPE_AUTH, + 'api_password': API_PASSWORD + }) + + auth_msg = await ws.receive_json() + assert auth_msg['type'] == wapi.TYPE_AUTH_OK + + await ws.send_json({ + 'id': 5, + 'type': wapi.TYPE_CALL_SERVICE, + 'domain': 'domain_test', + 'service': 'test_service', + 'service_data': { + 'hello': 'world' + } + }) + + msg = await ws.receive_json() + assert msg['success'] + + assert len(calls) == 1 + call = calls[0] + assert call.domain == 'domain_test' + assert call.service == 'test_service' + assert call.data == {'hello': 'world'} + assert call.context.user_id is None diff --git a/tests/components/tts/test_google.py b/tests/components/tts/test_google.py index 6a2d2c65035413..cf9a7b2db295d2 100644 --- a/tests/components/tts/test_google.py +++ b/tests/components/tts/test_google.py @@ -15,7 +15,7 @@ from .test_init import mutagen_mock # noqa -class TestTTSGooglePlatform(object): +class TestTTSGooglePlatform: """Test the Google speech component.""" def setup_method(self): diff --git a/tests/components/tts/test_init.py b/tests/components/tts/test_init.py index b6bfa430fd24f9..e8746ee762f6ae 100644 --- a/tests/components/tts/test_init.py +++ b/tests/components/tts/test_init.py @@ -29,7 +29,7 @@ def mutagen_mock(): yield -class TestTTS(object): +class TestTTS: """Test the Google speech component.""" def setup_method(self): diff --git a/tests/components/tts/test_marytts.py b/tests/components/tts/test_marytts.py index b55236c5e8eed1..7ec2ae39cd6103 100644 --- a/tests/components/tts/test_marytts.py +++ b/tests/components/tts/test_marytts.py @@ -14,7 +14,7 @@ from .test_init import mutagen_mock # noqa -class TestTTSMaryTTSPlatform(object): +class TestTTSMaryTTSPlatform: """Test the speech component.""" def setup_method(self): diff --git a/tests/components/tts/test_voicerss.py b/tests/components/tts/test_voicerss.py index 2abdc0e69ff10d..365cf1ff73ba32 100644 --- a/tests/components/tts/test_voicerss.py +++ b/tests/components/tts/test_voicerss.py @@ -14,7 +14,7 @@ from .test_init import mutagen_mock # noqa -class TestTTSVoiceRSSPlatform(object): +class TestTTSVoiceRSSPlatform: """Test the voicerss speech component.""" def setup_method(self): diff --git a/tests/components/tts/test_yandextts.py b/tests/components/tts/test_yandextts.py index 5b4ef4dcf5383d..82d203189287be 100644 --- a/tests/components/tts/test_yandextts.py +++ b/tests/components/tts/test_yandextts.py @@ -13,7 +13,7 @@ from .test_init import mutagen_mock # noqa -class TestTTSYandexPlatform(object): +class TestTTSYandexPlatform: """Test the speech component.""" def setup_method(self): diff --git a/tests/components/vacuum/test_demo.py b/tests/components/vacuum/test_demo.py index fadafdbc15e323..bd6f2ae543c01d 100644 --- a/tests/components/vacuum/test_demo.py +++ b/tests/components/vacuum/test_demo.py @@ -6,10 +6,12 @@ ATTR_BATTERY_LEVEL, ATTR_COMMAND, ATTR_ENTITY_ID, ATTR_FAN_SPEED, ATTR_FAN_SPEED_LIST, ATTR_PARAMS, ATTR_STATUS, DOMAIN, ENTITY_ID_ALL_VACUUMS, - SERVICE_SEND_COMMAND, SERVICE_SET_FAN_SPEED) + SERVICE_SEND_COMMAND, SERVICE_SET_FAN_SPEED, + STATE_DOCKED, STATE_CLEANING, STATE_PAUSED, STATE_IDLE, + STATE_RETURNING) from homeassistant.components.vacuum.demo import ( DEMO_VACUUM_BASIC, DEMO_VACUUM_COMPLETE, DEMO_VACUUM_MINIMAL, - DEMO_VACUUM_MOST, DEMO_VACUUM_NONE, FAN_SPEEDS) + DEMO_VACUUM_MOST, DEMO_VACUUM_NONE, DEMO_VACUUM_STATE, FAN_SPEEDS) from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, CONF_PLATFORM, STATE_OFF, STATE_ON) from homeassistant.setup import setup_component @@ -21,6 +23,7 @@ ENTITY_VACUUM_MINIMAL = '{}.{}'.format(DOMAIN, DEMO_VACUUM_MINIMAL).lower() ENTITY_VACUUM_MOST = '{}.{}'.format(DOMAIN, DEMO_VACUUM_MOST).lower() ENTITY_VACUUM_NONE = '{}.{}'.format(DOMAIN, DEMO_VACUUM_NONE).lower() +ENTITY_VACUUM_STATE = '{}.{}'.format(DOMAIN, DEMO_VACUUM_STATE).lower() class TestVacuumDemo(unittest.TestCase): @@ -79,6 +82,14 @@ def test_supported_features(self): self.assertEqual(None, state.attributes.get(ATTR_FAN_SPEED_LIST)) self.assertEqual(STATE_OFF, state.state) + state = self.hass.states.get(ENTITY_VACUUM_STATE) + self.assertEqual(13436, state.attributes.get(ATTR_SUPPORTED_FEATURES)) + self.assertEqual(STATE_DOCKED, state.state) + self.assertEqual(100, state.attributes.get(ATTR_BATTERY_LEVEL)) + self.assertEqual("medium", state.attributes.get(ATTR_FAN_SPEED)) + self.assertListEqual(FAN_SPEEDS, + state.attributes.get(ATTR_FAN_SPEED_LIST)) + def test_methods(self): """Test if methods call the services as expected.""" self.hass.states.set(ENTITY_VACUUM_BASIC, STATE_ON) @@ -147,6 +158,41 @@ def test_methods(self): self.assertIn("spot", state.attributes.get(ATTR_STATUS)) self.assertEqual(STATE_ON, state.state) + vacuum.start(self.hass, ENTITY_VACUUM_STATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_VACUUM_STATE) + self.assertEqual(STATE_CLEANING, state.state) + + vacuum.pause(self.hass, ENTITY_VACUUM_STATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_VACUUM_STATE) + self.assertEqual(STATE_PAUSED, state.state) + + vacuum.stop(self.hass, ENTITY_VACUUM_STATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_VACUUM_STATE) + self.assertEqual(STATE_IDLE, state.state) + + state = self.hass.states.get(ENTITY_VACUUM_STATE) + self.assertLess(state.attributes.get(ATTR_BATTERY_LEVEL), 100) + self.assertNotEqual(STATE_DOCKED, state.state) + + vacuum.return_to_base(self.hass, ENTITY_VACUUM_STATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_VACUUM_STATE) + self.assertEqual(STATE_RETURNING, state.state) + + vacuum.set_fan_speed(self.hass, FAN_SPEEDS[-1], + entity_id=ENTITY_VACUUM_STATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_VACUUM_STATE) + self.assertEqual(FAN_SPEEDS[-1], state.attributes.get(ATTR_FAN_SPEED)) + + vacuum.clean_spot(self.hass, entity_id=ENTITY_VACUUM_STATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_VACUUM_STATE) + self.assertEqual(STATE_CLEANING, state.state) + def test_unsupported_methods(self): """Test service calls for unsupported vacuums.""" self.hass.states.set(ENTITY_VACUUM_NONE, STATE_ON) @@ -201,6 +247,39 @@ def test_unsupported_methods(self): self.assertNotIn("spot", state.attributes.get(ATTR_STATUS)) self.assertEqual(STATE_OFF, state.state) + # VacuumDevice should not support start and pause methods. + self.hass.states.set(ENTITY_VACUUM_COMPLETE, STATE_ON) + self.hass.block_till_done() + self.assertTrue(vacuum.is_on(self.hass, ENTITY_VACUUM_COMPLETE)) + + vacuum.pause(self.hass, ENTITY_VACUUM_COMPLETE) + self.hass.block_till_done() + self.assertTrue(vacuum.is_on(self.hass, ENTITY_VACUUM_COMPLETE)) + + self.hass.states.set(ENTITY_VACUUM_COMPLETE, STATE_OFF) + self.hass.block_till_done() + self.assertFalse(vacuum.is_on(self.hass, ENTITY_VACUUM_COMPLETE)) + + vacuum.start(self.hass, ENTITY_VACUUM_COMPLETE) + self.hass.block_till_done() + self.assertFalse(vacuum.is_on(self.hass, ENTITY_VACUUM_COMPLETE)) + + # StateVacuumDevice does not support on/off + vacuum.turn_on(self.hass, entity_id=ENTITY_VACUUM_STATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_VACUUM_STATE) + self.assertNotEqual(STATE_CLEANING, state.state) + + vacuum.turn_off(self.hass, entity_id=ENTITY_VACUUM_STATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_VACUUM_STATE) + self.assertNotEqual(STATE_RETURNING, state.state) + + vacuum.toggle(self.hass, entity_id=ENTITY_VACUUM_STATE) + self.hass.block_till_done() + state = self.hass.states.get(ENTITY_VACUUM_STATE) + self.assertNotEqual(STATE_CLEANING, state.state) + def test_services(self): """Test vacuum services.""" # Test send_command @@ -241,9 +320,11 @@ def test_services(self): def test_set_fan_speed(self): """Test vacuum service to set the fan speed.""" group_vacuums = ','.join([ENTITY_VACUUM_BASIC, - ENTITY_VACUUM_COMPLETE]) + ENTITY_VACUUM_COMPLETE, + ENTITY_VACUUM_STATE]) old_state_basic = self.hass.states.get(ENTITY_VACUUM_BASIC) old_state_complete = self.hass.states.get(ENTITY_VACUUM_COMPLETE) + old_state_state = self.hass.states.get(ENTITY_VACUUM_STATE) vacuum.set_fan_speed( self.hass, FAN_SPEEDS[0], entity_id=group_vacuums) @@ -251,6 +332,7 @@ def test_set_fan_speed(self): self.hass.block_till_done() new_state_basic = self.hass.states.get(ENTITY_VACUUM_BASIC) new_state_complete = self.hass.states.get(ENTITY_VACUUM_COMPLETE) + new_state_state = self.hass.states.get(ENTITY_VACUUM_STATE) self.assertEqual(old_state_basic, new_state_basic) self.assertNotIn(ATTR_FAN_SPEED, new_state_basic.attributes) @@ -261,6 +343,12 @@ def test_set_fan_speed(self): self.assertEqual(FAN_SPEEDS[0], new_state_complete.attributes[ATTR_FAN_SPEED]) + self.assertNotEqual(old_state_state, new_state_state) + self.assertEqual(FAN_SPEEDS[1], + old_state_state.attributes[ATTR_FAN_SPEED]) + self.assertEqual(FAN_SPEEDS[0], + new_state_state.attributes[ATTR_FAN_SPEED]) + def test_send_command(self): """Test vacuum service to send a command.""" group_vacuums = ','.join([ENTITY_VACUUM_BASIC, diff --git a/tests/components/weather/test_darksky.py b/tests/components/weather/test_darksky.py index 7faa033e0a86c4..41687451cd6493 100644 --- a/tests/components/weather/test_darksky.py +++ b/tests/components/weather/test_darksky.py @@ -48,4 +48,4 @@ def test_setup(self, mock_req, mock_get_forecast): self.assertEqual(mock_get_forecast.call_count, 1) state = self.hass.states.get('weather.test') - self.assertEqual(state.state, 'Clear') + self.assertEqual(state.state, 'sunny') diff --git a/tests/components/weather/test_ipma.py b/tests/components/weather/test_ipma.py new file mode 100644 index 00000000000000..7df6166a2b6a18 --- /dev/null +++ b/tests/components/weather/test_ipma.py @@ -0,0 +1,85 @@ +"""The tests for the IPMA weather component.""" +import unittest +from unittest.mock import patch +from collections import namedtuple + +from homeassistant.components import weather +from homeassistant.components.weather import ( + ATTR_WEATHER_HUMIDITY, ATTR_WEATHER_PRESSURE, ATTR_WEATHER_TEMPERATURE, + ATTR_WEATHER_WIND_BEARING, ATTR_WEATHER_WIND_SPEED) +from homeassistant.util.unit_system import METRIC_SYSTEM +from homeassistant.setup import setup_component + +from tests.common import get_test_home_assistant, MockDependency + + +class MockStation(): + """Mock Station from pyipma.""" + + @classmethod + async def get(cls, websession, lat, lon): + """Mock Factory.""" + return MockStation() + + async def observation(self): + """Mock Observation.""" + Observation = namedtuple('Observation', ['temperature', 'humidity', + 'windspeed', 'winddirection', + 'precipitation', 'pressure', + 'description']) + + return Observation(18, 71.0, 3.94, 'NW', 0, 1000.0, '---') + + async def forecast(self): + """Mock Forecast.""" + Forecast = namedtuple('Forecast', ['precipitaProb', 'tMin', 'tMax', + 'predWindDir', 'idWeatherType', + 'classWindSpeed', 'longitude', + 'forecastDate', 'classPrecInt', + 'latitude', 'description']) + + return [Forecast(73.0, 13.7, 18.7, 'NW', 6, 2, -8.64, + '2018-05-31', 2, 40.61, + 'Aguaceiros, com vento Moderado de Noroeste')] + + @property + def local(self): + """Mock location.""" + return "HomeTown" + + +class TestIPMA(unittest.TestCase): + """Test the IPMA weather component.""" + + def setUp(self): + """Setup things to be run when tests are started.""" + self.hass = get_test_home_assistant() + self.hass.config.units = METRIC_SYSTEM + self.lat = self.hass.config.latitude = 40.00 + self.lon = self.hass.config.longitude = -8.00 + + def tearDown(self): + """Stop down everything that was started.""" + self.hass.stop() + + @MockDependency("pyipma") + @patch("pyipma.Station", new=MockStation) + def test_setup(self, mock_pyipma): + """Test for successfully setting up the IPMA platform.""" + self.assertTrue(setup_component(self.hass, weather.DOMAIN, { + 'weather': { + 'name': 'HomeTown', + 'platform': 'ipma', + } + })) + + state = self.hass.states.get('weather.hometown') + self.assertEqual(state.state, 'rainy') + + data = state.attributes + self.assertEqual(data.get(ATTR_WEATHER_TEMPERATURE), 18.0) + self.assertEqual(data.get(ATTR_WEATHER_HUMIDITY), 71) + self.assertEqual(data.get(ATTR_WEATHER_PRESSURE), 1000.0) + self.assertEqual(data.get(ATTR_WEATHER_WIND_SPEED), 3.94) + self.assertEqual(data.get(ATTR_WEATHER_WIND_BEARING), 'NW') + self.assertEqual(state.attributes.get('friendly_name'), 'HomeTown') diff --git a/tests/components/zone/test_init.py b/tests/components/zone/test_init.py index 1c698438f2c410..92dee05818dedb 100644 --- a/tests/components/zone/test_init.py +++ b/tests/components/zone/test_init.py @@ -17,7 +17,6 @@ async def test_setup_entry_successful(hass): zone.CONF_NAME: 'Test Zone', zone.CONF_LATITUDE: 1.1, zone.CONF_LONGITUDE: -2.2, - zone.CONF_RADIUS: 250, zone.CONF_RADIUS: True } hass.data[zone.DOMAIN] = {} @@ -59,7 +58,6 @@ def test_setup_no_zones_still_adds_home_zone(self): assert self.hass.config.latitude == state.attributes['latitude'] assert self.hass.config.longitude == state.attributes['longitude'] assert not state.attributes.get('passive', False) - assert 'test_home' in self.hass.data[zone.DOMAIN] def test_setup(self): """Test a successful setup.""" @@ -79,8 +77,6 @@ def test_setup(self): assert info['longitude'] == state.attributes['longitude'] assert info['radius'] == state.attributes['radius'] assert info['passive'] == state.attributes['passive'] - assert 'test_zone' in self.hass.data[zone.DOMAIN] - assert 'test_home' in self.hass.data[zone.DOMAIN] def test_setup_zone_skips_home_zone(self): """Test that zone named Home should override hass home zone.""" @@ -94,8 +90,17 @@ def test_setup_zone_skips_home_zone(self): assert len(self.hass.states.entity_ids('zone')) == 1 state = self.hass.states.get('zone.home') assert info['name'] == state.name - assert 'home' in self.hass.data[zone.DOMAIN] - assert 'test_home' not in self.hass.data[zone.DOMAIN] + + def test_setup_name_can_be_same_on_multiple_zones(self): + """Test that zone named Home should override hass home zone.""" + info = { + 'name': 'Test Zone', + 'latitude': 1.1, + 'longitude': -2.2, + } + assert setup.setup_component( + self.hass, zone.DOMAIN, {'zone': [info, info]}) + assert len(self.hass.states.entity_ids('zone')) == 3 def test_setup_registered_zone_skips_home_zone(self): """Test that config entry named home should override hass home zone.""" @@ -105,7 +110,6 @@ def test_setup_registered_zone_skips_home_zone(self): entry.add_to_hass(self.hass) assert setup.setup_component(self.hass, zone.DOMAIN, {'zone': None}) assert len(self.hass.states.entity_ids('zone')) == 0 - assert not self.hass.data[zone.DOMAIN] def test_setup_registered_zone_skips_configured_zone(self): """Test if config entry will override configured zone.""" @@ -123,8 +127,6 @@ def test_setup_registered_zone_skips_configured_zone(self): assert len(self.hass.states.entity_ids('zone')) == 1 state = self.hass.states.get('zone.test_zone') assert not state - assert 'test_zone' not in self.hass.data[zone.DOMAIN] - assert 'test_home' in self.hass.data[zone.DOMAIN] def test_active_zone_skips_passive_zones(self): """Test active and passive zones.""" diff --git a/tests/components/zwave/test_init.py b/tests/components/zwave/test_init.py index faa7357bd8af25..39abf6f588f72f 100644 --- a/tests/components/zwave/test_init.py +++ b/tests/components/zwave/test_init.py @@ -13,6 +13,7 @@ from homeassistant.components.zwave import ( const, CONFIG_SCHEMA, CONF_DEVICE_CONFIG_GLOB, DATA_NETWORK) from homeassistant.setup import setup_component +from tests.common import mock_registry import pytest @@ -162,10 +163,10 @@ def utcnow(): asyncio_sleep = asyncio.sleep @asyncio.coroutine - def sleep(duration, loop): + def sleep(duration, loop=None): if duration > 0: sleeps.append(duration) - yield from asyncio_sleep(0, loop=loop) + yield from asyncio_sleep(0) with patch('homeassistant.components.zwave.dt_util.utcnow', new=utcnow): with patch('asyncio.sleep', new=sleep): @@ -237,7 +238,8 @@ def mock_connect(receiver, signal, *args, **kwargs): assert len(mock_receivers) == 1 - node = MockNode(node_id=14, manufacturer_name=None) + node = MockNode( + node_id=14, manufacturer_name=None, name=None, is_ready=False) sleeps = [] @@ -246,10 +248,10 @@ def utcnow(): asyncio_sleep = asyncio.sleep - async def sleep(duration, loop): + async def sleep(duration, loop=None): if duration > 0: sleeps.append(duration) - await asyncio_sleep(0, loop=loop) + await asyncio_sleep(0) with patch('homeassistant.components.zwave.dt_util.utcnow', new=utcnow): with patch('asyncio.sleep', new=sleep): @@ -262,7 +264,7 @@ async def sleep(duration, loop): assert len(mock_logger.warning.mock_calls) == 1 assert mock_logger.warning.mock_calls[0][1][1:] == \ (14, const.NODE_READY_WAIT_SECS) - assert hass.states.get('zwave.mock_node').state is 'unknown' + assert hass.states.get('zwave.unknown_node_14').state is 'unknown' @asyncio.coroutine @@ -468,6 +470,7 @@ def setUp(self): """Initialize values for this testcase class.""" self.hass = get_test_home_assistant() self.hass.start() + self.registry = mock_registry(self.hass) setup_component(self.hass, 'zwave', {'zwave': {}}) self.hass.block_till_done() @@ -487,7 +490,7 @@ def setUp(self): const.DISC_OPTIONAL: True, }}} self.primary = MockValue( - command_class='mock_primary_class', node=self.node) + command_class='mock_primary_class', node=self.node, value_id=1000) self.secondary = MockValue( command_class='mock_secondary_class', node=self.node) self.duplicate_secondary = MockValue( @@ -521,6 +524,7 @@ def test_entity_discovery(self, discovery, get_platform): primary_value=self.primary, zwave_config=self.zwave_config, device_config=self.device_config, + registry=self.registry ) assert values.primary is self.primary @@ -592,6 +596,7 @@ def test_entity_existing_values(self, discovery, get_platform): primary_value=self.primary, zwave_config=self.zwave_config, device_config=self.device_config, + registry=self.registry ) self.hass.block_till_done() @@ -630,6 +635,7 @@ def test_node_schema_mismatch(self, discovery, get_platform): primary_value=self.primary, zwave_config=self.zwave_config, device_config=self.device_config, + registry=self.registry ) values._check_entity_ready() self.hass.block_till_done() @@ -639,7 +645,7 @@ def test_node_schema_mismatch(self, discovery, get_platform): @patch.object(zwave, 'get_platform') @patch.object(zwave, 'discovery') def test_entity_workaround_component(self, discovery, get_platform): - """Test ignore workaround.""" + """Test component workaround.""" discovery.async_load_platform.return_value = mock_coro() mock_platform = MagicMock() get_platform.return_value = mock_platform @@ -666,6 +672,7 @@ def test_entity_workaround_component(self, discovery, get_platform): primary_value=self.primary, zwave_config=self.zwave_config, device_config=self.device_config, + registry=self.registry ) values._check_entity_ready() self.hass.block_till_done() @@ -697,6 +704,7 @@ def test_entity_workaround_ignore(self, discovery, get_platform): primary_value=self.primary, zwave_config=self.zwave_config, device_config=self.device_config, + registry=self.registry ) values._check_entity_ready() self.hass.block_till_done() @@ -720,12 +728,42 @@ def test_entity_config_ignore(self, discovery, get_platform): primary_value=self.primary, zwave_config=self.zwave_config, device_config=self.device_config, + registry=self.registry ) values._check_entity_ready() self.hass.block_till_done() assert not discovery.async_load_platform.called + @patch.object(zwave, 'get_platform') + @patch.object(zwave, 'discovery') + def test_entity_config_ignore_with_registry(self, discovery, get_platform): + """Test ignore config. + + The case when the device is in entity registry. + """ + self.node.values = { + self.primary.value_id: self.primary, + self.secondary.value_id: self.secondary, + } + self.device_config = {'mock_component.registry_id': { + zwave.CONF_IGNORED: True + }} + self.registry.async_get_or_create( + 'mock_component', zwave.DOMAIN, '567-1000', + suggested_object_id='registry_id') + zwave.ZWaveDeviceEntityValues( + hass=self.hass, + schema=self.mock_schema, + primary_value=self.primary, + zwave_config=self.zwave_config, + device_config=self.device_config, + registry=self.registry + ) + self.hass.block_till_done() + + assert not discovery.async_load_platform.called + @patch.object(zwave, 'get_platform') @patch.object(zwave, 'discovery') def test_entity_platform_ignore(self, discovery, get_platform): @@ -743,6 +781,7 @@ def test_entity_platform_ignore(self, discovery, get_platform): primary_value=self.primary, zwave_config=self.zwave_config, device_config=self.device_config, + registry=self.registry ) self.hass.block_till_done() @@ -770,6 +809,7 @@ def test_config_polling_intensity(self, discovery, get_platform): primary_value=self.primary, zwave_config=self.zwave_config, device_config=self.device_config, + registry=self.registry ) values._check_entity_ready() self.hass.block_till_done() diff --git a/tests/components/zwave/test_node_entity.py b/tests/components/zwave/test_node_entity.py index f4d9b3ef0e8d54..b91245d5a12509 100644 --- a/tests/components/zwave/test_node_entity.py +++ b/tests/components/zwave/test_node_entity.py @@ -363,6 +363,7 @@ def test_unique_id(self): def test_unique_id_missing_data(self): """Test unique_id.""" self.node.manufacturer_name = None + self.node.name = None entity = node_entity.ZWaveNodeEntity(self.node, self.zwave_network) self.assertIsNone(entity.unique_id) diff --git a/tests/conftest.py b/tests/conftest.py index 73e69605eae5bc..28c47948666569 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -12,7 +12,8 @@ from homeassistant.util import location from tests.common import ( - async_test_home_assistant, INSTANCES, async_mock_mqtt_component, mock_coro) + async_test_home_assistant, INSTANCES, async_mock_mqtt_component, mock_coro, + mock_storage as mock_storage) from tests.test_util.aiohttp import mock_aiohttp_client from tests.mock.zwave import MockNetwork, MockOption @@ -20,11 +21,11 @@ import uvloop asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) -logging.basicConfig(level=logging.INFO) +logging.basicConfig(level=logging.DEBUG) logging.getLogger('sqlalchemy.engine').setLevel(logging.INFO) -def test_real(func): +def check_real(func): """Force a function to require a keyword _test_real to be passed in.""" @functools.wraps(func) def guard_func(*args, **kwargs): @@ -40,8 +41,8 @@ def guard_func(*args, **kwargs): # Guard a few functions that would make network connections -location.detect_location_info = test_real(location.detect_location_info) -location.elevation = test_real(location.elevation) +location.detect_location_info = check_real(location.detect_location_info) +location.elevation = check_real(location.elevation) util.get_local_ip = lambda: '127.0.0.1' @@ -59,7 +60,14 @@ def verify_cleanup(): @pytest.fixture -def hass(loop): +def hass_storage(): + """Fixture to mock storage.""" + with mock_storage() as stored_data: + yield stored_data + + +@pytest.fixture +def hass(loop, hass_storage): """Fixture to provide a test instance of HASS.""" hass = loop.run_until_complete(async_test_home_assistant(loop)) diff --git a/tests/fixtures/bom_weather.json b/tests/fixtures/bom_weather.json new file mode 100644 index 00000000000000..d40ea6fb21aade --- /dev/null +++ b/tests/fixtures/bom_weather.json @@ -0,0 +1,42 @@ +{ + "observations": { + "data": [ + { + "wmo": 94767, + "name": "Fake", + "history_product": "IDN00000", + "local_date_time_full": "20180422130000", + "apparent_t": 25.0, + "press": 1021.7, + "weather": "-" + }, + { + "wmo": 94767, + "name": "Fake", + "history_product": "IDN00000", + "local_date_time_full": "20180422130000", + "apparent_t": 22.0, + "press": 1019.7, + "weather": "-" + }, + { + "wmo": 94767, + "name": "Fake", + "history_product": "IDN00000", + "local_date_time_full": "20180422130000", + "apparent_t": 20.0, + "press": 1011.7, + "weather": "Fine" + }, + { + "wmo": 94767, + "name": "Fake", + "history_product": "IDN00000", + "local_date_time_full": "20180422130000", + "apparent_t": 18.0, + "press": 1010.0, + "weather": "-" + } + ] + } +} diff --git a/tests/fixtures/coinmarketcap.json b/tests/fixtures/coinmarketcap.json index 20f5e4fe91ea29..5a6b63c5da105d 100644 --- a/tests/fixtures/coinmarketcap.json +++ b/tests/fixtures/coinmarketcap.json @@ -1,21 +1,36 @@ -[ - { - "id": "ethereum", - "name": "Ethereum", - "symbol": "ETH", - "rank": "2", - "price_usd": "282.423", - "price_btc": "0.048844", - "24h_volume_usd": "407024000.0", - "market_cap_usd": "26908205315.0", - "available_supply": "95276253.0", - "total_supply": "95276253.0", - "percent_change_1h": "0.06", - "percent_change_24h": "-4.57", - "percent_change_7d": "-16.39", - "last_updated": "1508776751", - "price_eur": "240.473299695", - "24h_volume_eur": "346566690.16", - "market_cap_eur": "22911395039.0" +{ + "cached": false, + "data": { + "id": 1027, + "name": "Ethereum", + "symbol": "ETH", + "website_slug": "ethereum", + "rank": 2, + "circulating_supply": 99619842.0, + "total_supply": 99619842.0, + "max_supply": null, + "quotes": { + "USD": { + "price": 577.019, + "volume_24h": 2839960000.0, + "market_cap": 57482541899.0, + "percent_change_1h": -2.28, + "percent_change_24h": -14.88, + "percent_change_7d": -17.51 + }, + "EUR": { + "price": 493.454724572, + "volume_24h": 2428699712.48, + "market_cap": 49158380042.0, + "percent_change_1h": -2.28, + "percent_change_24h": -14.88, + "percent_change_7d": -17.51 + } + }, + "last_updated": 1527098658 + }, + "metadata": { + "timestamp": 1527098716, + "error": null } -] \ No newline at end of file +} \ No newline at end of file diff --git a/tests/fixtures/efergy_budget.json b/tests/fixtures/efergy_budget.json index 2b0a64fbae551d..73fc9b549b6ed4 100644 --- a/tests/fixtures/efergy_budget.json +++ b/tests/fixtures/efergy_budget.json @@ -1 +1,4 @@ -{"status":"ok", "monthly_budget":250.0000} \ No newline at end of file +{ + "status": "ok", + "monthly_budget": 250.0000 +} \ No newline at end of file diff --git a/tests/fixtures/efergy_cost.json b/tests/fixtures/efergy_cost.json index 8b2ccfff18a815..41150a30e87dcc 100644 --- a/tests/fixtures/efergy_cost.json +++ b/tests/fixtures/efergy_cost.json @@ -1 +1,5 @@ -{"sum":"5.27","duration":70320,"units":"GBP"} \ No newline at end of file +{ + "sum": "5.27", + "duration": 70320, + "units": "GBP" +} \ No newline at end of file diff --git a/tests/fixtures/efergy_energy.json b/tests/fixtures/efergy_energy.json index 4033ad074a622e..f1c1ce248beb48 100644 --- a/tests/fixtures/efergy_energy.json +++ b/tests/fixtures/efergy_energy.json @@ -1 +1,5 @@ -{"sum":"38.21","duration":70320,"units":"kWh"} \ No newline at end of file +{ + "sum": "38.21", + "duration": 70320, + "units": "kWh" +} \ No newline at end of file diff --git a/tests/fixtures/feedreader.xml b/tests/fixtures/feedreader.xml new file mode 100644 index 00000000000000..8c85a4975eea91 --- /dev/null +++ b/tests/fixtures/feedreader.xml @@ -0,0 +1,20 @@ + + + + RSS Sample + This is an example of an RSS feed + http://www.example.com/main.html + Mon, 30 Apr 2018 12:00:00 +1000 + Mon, 30 Apr 2018 15:00:00 +1000 + 1800 + + + Title 1 + Description 1 + http://www.example.com/link/1 + GUID 1 + Mon, 30 Apr 2018 15:10:00 +1000 + + + + diff --git a/tests/fixtures/feedreader1.xml b/tests/fixtures/feedreader1.xml new file mode 100644 index 00000000000000..ff856125779bdf --- /dev/null +++ b/tests/fixtures/feedreader1.xml @@ -0,0 +1,27 @@ + + + + RSS Sample + This is an example of an RSS feed + http://www.example.com/main.html + Mon, 30 Apr 2018 12:00:00 +1000 + Mon, 30 Apr 2018 15:00:00 +1000 + 1800 + + + Title 1 + Description 1 + http://www.example.com/link/1 + GUID 1 + Mon, 30 Apr 2018 15:10:00 +1000 + + + Title 2 + Description 2 + http://www.example.com/link/2 + GUID 2 + Mon, 30 Apr 2018 15:11:00 +1000 + + + + diff --git a/tests/fixtures/feedreader2.xml b/tests/fixtures/feedreader2.xml new file mode 100644 index 00000000000000..653a16e4561411 --- /dev/null +++ b/tests/fixtures/feedreader2.xml @@ -0,0 +1,97 @@ + + + + RSS Sample + This is an example of an RSS feed + http://www.example.com/main.html + Mon, 30 Apr 2018 12:00:00 +1000 + Mon, 30 Apr 2018 15:00:00 +1000 + 1800 + + + Title 1 + Mon, 30 Apr 2018 15:00:00 +1000 + + + Title 2 + Mon, 30 Apr 2018 15:01:00 +1000 + + + Title 3 + Mon, 30 Apr 2018 15:02:00 +1000 + + + Title 4 + Mon, 30 Apr 2018 15:03:00 +1000 + + + Title 5 + Mon, 30 Apr 2018 15:04:00 +1000 + + + Title 6 + Mon, 30 Apr 2018 15:05:00 +1000 + + + Title 7 + Mon, 30 Apr 2018 15:06:00 +1000 + + + Title 8 + Mon, 30 Apr 2018 15:07:00 +1000 + + + Title 9 + Mon, 30 Apr 2018 15:08:00 +1000 + + + Title 10 + Mon, 30 Apr 2018 15:09:00 +1000 + + + Title 11 + Mon, 30 Apr 2018 15:10:00 +1000 + + + Title 12 + Mon, 30 Apr 2018 15:11:00 +1000 + + + Title 13 + Mon, 30 Apr 2018 15:12:00 +1000 + + + Title 14 + Mon, 30 Apr 2018 15:13:00 +1000 + + + Title 15 + Mon, 30 Apr 2018 15:14:00 +1000 + + + Title 16 + Mon, 30 Apr 2018 15:15:00 +1000 + + + Title 17 + Mon, 30 Apr 2018 15:16:00 +1000 + + + Title 18 + Mon, 30 Apr 2018 15:17:00 +1000 + + + Title 19 + Mon, 30 Apr 2018 15:18:00 +1000 + + + Title 20 + Mon, 30 Apr 2018 15:19:00 +1000 + + + Title 21 + Mon, 30 Apr 2018 15:20:00 +1000 + + + + diff --git a/tests/fixtures/feedreader3.xml b/tests/fixtures/feedreader3.xml new file mode 100644 index 00000000000000..7b28e067cfed5f --- /dev/null +++ b/tests/fixtures/feedreader3.xml @@ -0,0 +1,26 @@ + + + + RSS Sample + This is an example of an RSS feed + http://www.example.com/main.html + Mon, 30 Apr 2018 12:00:00 +1000 + Mon, 30 Apr 2018 15:00:00 +1000 + 1800 + + + Title 1 + Description 1 + http://www.example.com/link/1 + GUID 1 + Mon, 30 Apr 2018 15:10:00 +1000 + + + Title 2 + Description 2 + http://www.example.com/link/2 + GUID 2 + + + + diff --git a/tests/fixtures/pushbullet_devices.json b/tests/fixtures/pushbullet_devices.json old mode 100755 new mode 100644 diff --git a/tests/fixtures/ring_oauth.json b/tests/fixtures/ring_oauth.json new file mode 100644 index 00000000000000..5e69ddde065272 --- /dev/null +++ b/tests/fixtures/ring_oauth.json @@ -0,0 +1,8 @@ +{ + "access_token": "eyJ0eWfvEQwqfJNKyQ9999", + "token_type": "bearer", + "expires_in": 3600, + "refresh_token": "67695a26bdefc1ac8999", + "scope": "client", + "created_at": 1529099870 +} diff --git a/tests/helpers/test_aiohttp_client.py b/tests/helpers/test_aiohttp_client.py index 28bb31c848204d..ccfe1b1aff9315 100644 --- a/tests/helpers/test_aiohttp_client.py +++ b/tests/helpers/test_aiohttp_client.py @@ -135,9 +135,8 @@ def test_get_clientsession_cleanup_without_ssl(self): @asyncio.coroutine def test_async_aiohttp_proxy_stream(aioclient_mock, camera_client): """Test that it fetches the given url.""" - aioclient_mock.get('http://example.com/mjpeg_stream', content=[ - b'Frame1', b'Frame2', b'Frame3' - ]) + aioclient_mock.get('http://example.com/mjpeg_stream', + content=b'Frame1Frame2Frame3') resp = yield from camera_client.get( '/api/camera_proxy_stream/camera.config_test') @@ -145,7 +144,7 @@ def test_async_aiohttp_proxy_stream(aioclient_mock, camera_client): assert resp.status == 200 assert aioclient_mock.call_count == 1 body = yield from resp.text() - assert body == 'Frame3Frame2Frame1' + assert body == 'Frame1Frame2Frame3' @asyncio.coroutine diff --git a/tests/helpers/test_config_entry_flow.py b/tests/helpers/test_config_entry_flow.py new file mode 100644 index 00000000000000..9eede7dff9b8d6 --- /dev/null +++ b/tests/helpers/test_config_entry_flow.py @@ -0,0 +1,138 @@ +"""Tests for the Config Entry Flow helper.""" +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries, data_entry_flow, loader +from homeassistant.helpers import config_entry_flow +from tests.common import MockConfigEntry, MockModule + + +@pytest.fixture +def flow_conf(hass): + """Register a handler.""" + handler_conf = { + 'discovered': False, + } + + async def has_discovered_devices(hass): + """Mock if we have discovered devices.""" + return handler_conf['discovered'] + + with patch.dict(config_entries.HANDLERS): + config_entry_flow.register_discovery_flow( + 'test', 'Test', has_discovered_devices) + yield handler_conf + + +async def test_single_entry_allowed(hass, flow_conf): + """Test only a single entry is allowed.""" + flow = config_entries.HANDLERS['test']() + flow.hass = hass + + MockConfigEntry(domain='test').add_to_hass(hass) + result = await flow.async_step_user() + + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'single_instance_allowed' + + +async def test_user_no_devices_found(hass, flow_conf): + """Test if no devices found.""" + flow = config_entries.HANDLERS['test']() + flow.hass = hass + + result = await flow.async_step_user() + + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'no_devices_found' + + +async def test_user_no_confirmation(hass, flow_conf): + """Test user requires no confirmation to setup.""" + flow = config_entries.HANDLERS['test']() + flow.hass = hass + flow_conf['discovered'] = True + + result = await flow.async_step_user() + + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + +async def test_discovery_single_instance(hass, flow_conf): + """Test we ask for confirmation via discovery.""" + flow = config_entries.HANDLERS['test']() + flow.hass = hass + + MockConfigEntry(domain='test').add_to_hass(hass) + result = await flow.async_step_discovery({}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'single_instance_allowed' + + +async def test_discovery_confirmation(hass, flow_conf): + """Test we ask for confirmation via discovery.""" + flow = config_entries.HANDLERS['test']() + flow.hass = hass + + result = await flow.async_step_discovery({}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'confirm' + + result = await flow.async_step_confirm({}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + +async def test_multiple_discoveries(hass, flow_conf): + """Test we only create one instance for multiple discoveries.""" + loader.set_component(hass, 'test', MockModule('test')) + + result = await hass.config_entries.flow.async_init( + 'test', context={'source': config_entries.SOURCE_DISCOVERY}, data={}) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + + # Second discovery + result = await hass.config_entries.flow.async_init( + 'test', context={'source': config_entries.SOURCE_DISCOVERY}, data={}) + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + + +async def test_user_init_trumps_discovery(hass, flow_conf): + """Test a user initialized one will finish and cancel discovered one.""" + loader.set_component(hass, 'test', MockModule('test')) + + # Discovery starts flow + result = await hass.config_entries.flow.async_init( + 'test', context={'source': config_entries.SOURCE_DISCOVERY}, data={}) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + + # User starts flow + result = await hass.config_entries.flow.async_init( + 'test', context={'source': config_entries.SOURCE_USER}, data={}) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + # Discovery flow has been aborted + assert len(hass.config_entries.flow.async_progress()) == 0 + + +async def test_import_no_confirmation(hass, flow_conf): + """Test import requires no confirmation to setup.""" + flow = config_entries.HANDLERS['test']() + flow.hass = hass + flow_conf['discovered'] = True + + result = await flow.async_step_import(None) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + + +async def test_import_single_instance(hass, flow_conf): + """Test import doesn't create second instance.""" + flow = config_entries.HANDLERS['test']() + flow.hass = hass + flow_conf['discovered'] = True + MockConfigEntry(domain='test').add_to_hass(hass) + + result = await flow.async_step_import(None) + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT diff --git a/tests/helpers/test_dispatcher.py b/tests/helpers/test_dispatcher.py index 066e7386c6e281..55e67def2bc6c0 100644 --- a/tests/helpers/test_dispatcher.py +++ b/tests/helpers/test_dispatcher.py @@ -8,7 +8,7 @@ from tests.common import get_test_home_assistant -class TestHelpersDispatcher(object): +class TestHelpersDispatcher: """Tests for discovery helper methods.""" def setup_method(self, method): diff --git a/tests/helpers/test_entity.py b/tests/helpers/test_entity.py index 4211e3da31bdbe..e24bec489f492f 100644 --- a/tests/helpers/test_entity.py +++ b/tests/helpers/test_entity.py @@ -71,7 +71,7 @@ def async_update_func(): assert len(async_update) == 1 -class TestHelpersEntity(object): +class TestHelpersEntity: """Test homeassistant.helpers.entity module.""" def setup_method(self, method): @@ -400,3 +400,15 @@ def test_async_remove_no_platform(hass): assert len(hass.states.async_entity_ids()) == 1 yield from ent.async_remove() assert len(hass.states.async_entity_ids()) == 0 + + +async def test_async_remove_runs_callbacks(hass): + """Test async_remove method when no platform set.""" + result = [] + + ent = entity.Entity() + ent.hass = hass + ent.entity_id = 'test.test' + ent.async_on_remove(lambda: result.append(1)) + await ent.async_remove() + assert len(result) == 1 diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 504f31cc9875c8..b4910723c8dfa8 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -346,7 +346,8 @@ async def test_setup_entry(hass): mock_setup_entry = Mock(return_value=mock_coro(True)) loader.set_component( hass, 'test_domain.entry_domain', - MockPlatform(async_setup_entry=mock_setup_entry)) + MockPlatform(async_setup_entry=mock_setup_entry, + scan_interval=timedelta(seconds=5))) component = EntityComponent(_LOGGER, DOMAIN, hass) entry = MockConfigEntry(domain='entry_domain') @@ -357,6 +358,9 @@ async def test_setup_entry(hass): assert p_hass is hass assert p_entry is entry + assert component._platforms[entry.entry_id].scan_interval == \ + timedelta(seconds=5) + async def test_setup_entry_platform_not_exist(hass): """Test setup entry fails if platform doesnt exist.""" diff --git a/tests/helpers/test_entity_platform.py b/tests/helpers/test_entity_platform.py index 4e09f9576f2c61..b52405aa8beae0 100644 --- a/tests/helpers/test_entity_platform.py +++ b/tests/helpers/test_entity_platform.py @@ -5,6 +5,8 @@ from unittest.mock import patch, Mock, MagicMock from datetime import timedelta +import pytest + from homeassistant.exceptions import PlatformNotReady import homeassistant.loader as loader from homeassistant.helpers.entity import generate_entity_id @@ -16,7 +18,7 @@ from tests.common import ( get_test_home_assistant, MockPlatform, fire_time_changed, mock_registry, - MockEntity, MockEntityPlatform, MockConfigEntry, mock_coro) + MockEntity, MockEntityPlatform, MockConfigEntry) _LOGGER = logging.getLogger(__name__) DOMAIN = "test_domain" @@ -487,7 +489,7 @@ def test_registry_respect_entity_disabled(hass): assert hass.states.async_entity_ids() == [] -async def test_entity_registry_updates(hass): +async def test_entity_registry_updates_name(hass): """Test that updates on the entity registry update platform entities.""" registry = mock_registry(hass, { 'test_domain.world': entity_registry.RegistryEntry( @@ -516,11 +518,19 @@ async def test_entity_registry_updates(hass): async def test_setup_entry(hass): """Test we can setup an entry.""" - async_setup_entry = Mock(return_value=mock_coro(True)) + registry = mock_registry(hass) + + async def async_setup_entry(hass, config_entry, async_add_devices): + """Mock setup entry method.""" + async_add_devices([ + MockEntity(name='test1', unique_id='unique') + ]) + return True + platform = MockPlatform( async_setup_entry=async_setup_entry ) - config_entry = MockConfigEntry() + config_entry = MockConfigEntry(entry_id='super-mock-id') entity_platform = MockEntityPlatform( hass, platform_name=config_entry.domain, @@ -528,10 +538,13 @@ async def test_setup_entry(hass): ) assert await entity_platform.async_setup_entry(config_entry) - + await hass.async_block_till_done() full_name = '{}.{}'.format(entity_platform.domain, config_entry.domain) assert full_name in hass.config.components - assert len(async_setup_entry.mock_calls) == 1 + assert len(hass.states.async_entity_ids()) == 1 + assert len(registry.entities) == 1 + assert registry.entities['test_domain.test1'].config_entry_id == \ + 'super-mock-id' async def test_setup_entry_platform_not_ready(hass, caplog): @@ -581,3 +594,85 @@ async def test_reset_cancels_retry_setup(hass): assert len(mock_call_later.return_value.mock_calls) == 1 assert ent_platform._async_cancel_retry_setup is None + + +@asyncio.coroutine +def test_not_fails_with_adding_empty_entities_(hass): + """Test for not fails on empty entities list.""" + component = EntityComponent(_LOGGER, DOMAIN, hass) + + yield from component.async_add_entities([]) + + assert len(hass.states.async_entity_ids()) == 0 + + +async def test_entity_registry_updates_entity_id(hass): + """Test that updates on the entity registry update platform entities.""" + registry = mock_registry(hass, { + 'test_domain.world': entity_registry.RegistryEntry( + entity_id='test_domain.world', + unique_id='1234', + # Using component.async_add_entities is equal to platform "domain" + platform='test_platform', + name='Some name' + ) + }) + platform = MockEntityPlatform(hass) + entity = MockEntity(unique_id='1234') + await platform.async_add_entities([entity]) + + state = hass.states.get('test_domain.world') + assert state is not None + assert state.name == 'Some name' + + registry.async_update_entity('test_domain.world', + new_entity_id='test_domain.planet') + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert hass.states.get('test_domain.world') is None + assert hass.states.get('test_domain.planet') is not None + + +async def test_entity_registry_updates_invalid_entity_id(hass): + """Test that we can't update to an invalid entity id.""" + registry = mock_registry(hass, { + 'test_domain.world': entity_registry.RegistryEntry( + entity_id='test_domain.world', + unique_id='1234', + # Using component.async_add_entities is equal to platform "domain" + platform='test_platform', + name='Some name' + ), + 'test_domain.existing': entity_registry.RegistryEntry( + entity_id='test_domain.existing', + unique_id='5678', + platform='test_platform', + ), + }) + platform = MockEntityPlatform(hass) + entity = MockEntity(unique_id='1234') + await platform.async_add_entities([entity]) + + state = hass.states.get('test_domain.world') + assert state is not None + assert state.name == 'Some name' + + with pytest.raises(ValueError): + registry.async_update_entity('test_domain.world', + new_entity_id='test_domain.existing') + + with pytest.raises(ValueError): + registry.async_update_entity('test_domain.world', + new_entity_id='invalid_entity_id') + + with pytest.raises(ValueError): + registry.async_update_entity('test_domain.world', + new_entity_id='diff_domain.world') + + await hass.async_block_till_done() + await hass.async_block_till_done() + + assert hass.states.get('test_domain.world') is not None + assert hass.states.get('invalid_entity_id') is None + assert hass.states.get('diff_domain.world') is None diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index cb8703d1fe6da1..5a9efd5c041f7c 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -86,7 +86,8 @@ def test_save_timer_reset_on_subsequent_save(hass, registry): def test_loading_saving_data(hass, registry): """Test that we load/save data correctly.""" orig_entry1 = registry.async_get_or_create('light', 'hue', '1234') - orig_entry2 = registry.async_get_or_create('light', 'hue', '5678') + orig_entry2 = registry.async_get_or_create( + 'light', 'hue', '5678', config_entry_id='mock-id') assert len(registry.entities) == 2 @@ -106,7 +107,8 @@ def test_loading_saving_data(hass, registry): # Ensure same order assert list(registry.entities) == list(registry2.entities) new_entry1 = registry.async_get_or_create('light', 'hue', '1234') - new_entry2 = registry.async_get_or_create('light', 'hue', '5678') + new_entry2 = registry.async_get_or_create('light', 'hue', '5678', + config_entry_id='mock-id') assert orig_entry1 == new_entry1 assert orig_entry2 == new_entry2 @@ -180,3 +182,23 @@ def test_loading_extra_values(hass): assert entry_disabled_hass.disabled_by == entity_registry.DISABLED_HASS assert entry_disabled_user.disabled assert entry_disabled_user.disabled_by == entity_registry.DISABLED_USER + + +@asyncio.coroutine +def test_async_get_entity_id(registry): + """Test that entity_id is returned.""" + entry = registry.async_get_or_create('light', 'hue', '1234') + assert entry.entity_id == 'light.hue_1234' + assert registry.async_get_entity_id( + 'light', 'hue', '1234') == 'light.hue_1234' + assert registry.async_get_entity_id('light', 'hue', '123') is None + + +async def test_updating_config_entry_id(registry): + """Test that we update config entry id in registry.""" + entry = registry.async_get_or_create( + 'light', 'hue', '5678', config_entry_id='mock-id-1') + entry2 = registry.async_get_or_create( + 'light', 'hue', '5678', config_entry_id='mock-id-2') + assert entry.entity_id == entry2.entity_id + assert entry2.config_entry_id == 'mock-id-2' diff --git a/tests/helpers/test_intent.py b/tests/helpers/test_intent.py index a8d37a249bc92c..707129ae531743 100644 --- a/tests/helpers/test_intent.py +++ b/tests/helpers/test_intent.py @@ -1,6 +1,18 @@ """Tests for the intent helpers.""" + +import unittest +import voluptuous as vol + from homeassistant.core import State -from homeassistant.helpers import intent +from homeassistant.helpers import (intent, config_validation as cv) + + +class MockIntentHandler(intent.IntentHandler): + """Provide a mock intent handler.""" + + def __init__(self, slot_schema): + """Initialize the mock handler.""" + self.slot_schema = slot_schema def test_async_match_state(): @@ -10,3 +22,25 @@ def test_async_match_state(): state = intent.async_match_state(None, 'kitch', [state1, state2]) assert state is state1 + + +class TestIntentHandler(unittest.TestCase): + """Test the Home Assistant event helpers.""" + + def test_async_validate_slots(self): + """Test async_validate_slots of IntentHandler.""" + handler1 = MockIntentHandler({ + vol.Required('name'): cv.string, + }) + + self.assertRaises(vol.error.MultipleInvalid, + handler1.async_validate_slots, {}) + self.assertRaises(vol.error.MultipleInvalid, + handler1.async_validate_slots, {'name': 1}) + self.assertRaises(vol.error.MultipleInvalid, + handler1.async_validate_slots, {'name': 'kitchen'}) + handler1.async_validate_slots({'name': {'value': 'kitchen'}}) + handler1.async_validate_slots({ + 'name': {'value': 'kitchen'}, + 'probability': {'value': '0.5'} + }) diff --git a/tests/helpers/test_storage.py b/tests/helpers/test_storage.py new file mode 100644 index 00000000000000..f414eaec97c844 --- /dev/null +++ b/tests/helpers/test_storage.py @@ -0,0 +1,179 @@ +"""Tests for the storage helper.""" +import asyncio +from datetime import timedelta +from unittest.mock import patch + +import pytest + +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.helpers import storage +from homeassistant.util import dt + +from tests.common import async_fire_time_changed, mock_coro + + +MOCK_VERSION = 1 +MOCK_KEY = 'storage-test' +MOCK_DATA = {'hello': 'world'} + + +@pytest.fixture +def store(hass): + """Fixture of a store that prevents writing on HASS stop.""" + yield storage.Store(hass, MOCK_VERSION, MOCK_KEY) + + +async def test_loading(hass, store): + """Test we can save and load data.""" + await store.async_save(MOCK_DATA) + data = await store.async_load() + assert data == MOCK_DATA + + +async def test_loading_non_existing(hass, store): + """Test we can save and load data.""" + with patch('homeassistant.util.json.open', side_effect=FileNotFoundError): + data = await store.async_load() + assert data is None + + +async def test_loading_parallel(hass, store, hass_storage, caplog): + """Test we can save and load data.""" + hass_storage[store.key] = { + 'version': MOCK_VERSION, + 'data': MOCK_DATA, + } + + results = await asyncio.gather( + store.async_load(), + store.async_load() + ) + + assert results[0] is MOCK_DATA + assert results[1] is MOCK_DATA + assert caplog.text.count('Loading data for {}'.format(store.key)) + + +async def test_saving_with_delay(hass, store, hass_storage): + """Test saving data after a delay.""" + await store.async_save(MOCK_DATA, delay=1) + assert store.key not in hass_storage + + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() + assert hass_storage[store.key] == { + 'version': MOCK_VERSION, + 'key': MOCK_KEY, + 'data': MOCK_DATA, + } + + +async def test_saving_on_stop(hass, hass_storage): + """Test delayed saves trigger when we quit Home Assistant.""" + store = storage.Store(hass, MOCK_VERSION, MOCK_KEY) + await store.async_save(MOCK_DATA, delay=1) + assert store.key not in hass_storage + + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() + assert hass_storage[store.key] == { + 'version': MOCK_VERSION, + 'key': MOCK_KEY, + 'data': MOCK_DATA, + } + + +async def test_loading_while_delay(hass, store, hass_storage): + """Test we load new data even if not written yet.""" + await store.async_save({'delay': 'no'}) + assert hass_storage[store.key] == { + 'version': MOCK_VERSION, + 'key': MOCK_KEY, + 'data': {'delay': 'no'}, + } + + await store.async_save({'delay': 'yes'}, delay=1) + assert hass_storage[store.key] == { + 'version': MOCK_VERSION, + 'key': MOCK_KEY, + 'data': {'delay': 'no'}, + } + + data = await store.async_load() + assert data == {'delay': 'yes'} + + +async def test_writing_while_writing_delay(hass, store, hass_storage): + """Test a write while a write with delay is active.""" + await store.async_save({'delay': 'yes'}, delay=1) + assert store.key not in hass_storage + await store.async_save({'delay': 'no'}) + assert hass_storage[store.key] == { + 'version': MOCK_VERSION, + 'key': MOCK_KEY, + 'data': {'delay': 'no'}, + } + + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) + await hass.async_block_till_done() + assert hass_storage[store.key] == { + 'version': MOCK_VERSION, + 'key': MOCK_KEY, + 'data': {'delay': 'no'}, + } + + data = await store.async_load() + assert data == {'delay': 'no'} + + +async def test_migrator_no_existing_config(hass, store, hass_storage): + """Test migrator with no existing config.""" + with patch('os.path.isfile', return_value=False), \ + patch.object(store, 'async_load', + return_value=mock_coro({'cur': 'config'})): + data = await storage.async_migrator( + hass, 'old-path', store) + + assert data == {'cur': 'config'} + assert store.key not in hass_storage + + +async def test_migrator_existing_config(hass, store, hass_storage): + """Test migrating existing config.""" + with patch('os.path.isfile', return_value=True), \ + patch('os.remove') as mock_remove, \ + patch('homeassistant.util.json.load_json', + return_value={'old': 'config'}): + data = await storage.async_migrator( + hass, 'old-path', store) + + assert len(mock_remove.mock_calls) == 1 + assert data == {'old': 'config'} + assert hass_storage[store.key] == { + 'key': MOCK_KEY, + 'version': MOCK_VERSION, + 'data': data, + } + + +async def test_migrator_transforming_config(hass, store, hass_storage): + """Test migrating config to new format.""" + async def old_conf_migrate_func(old_config): + """Migrate old config to new format.""" + return {'new': old_config['old']} + + with patch('os.path.isfile', return_value=True), \ + patch('os.remove') as mock_remove, \ + patch('homeassistant.util.json.load_json', + return_value={'old': 'config'}): + data = await storage.async_migrator( + hass, 'old-path', store, + old_conf_migrate_func=old_conf_migrate_func) + + assert len(mock_remove.mock_calls) == 1 + assert data == {'new': 'config'} + assert hass_storage[store.key] == { + 'key': MOCK_KEY, + 'version': MOCK_VERSION, + 'data': data, + } diff --git a/tests/mock/zwave.py b/tests/mock/zwave.py index 67bfb590c3f75c..59d97ddb6218e6 100644 --- a/tests/mock/zwave.py +++ b/tests/mock/zwave.py @@ -178,6 +178,7 @@ def __init__(self, *, MockValue._mock_value_id += 1 value_id = MockValue._mock_value_id self.value_id = value_id + self.object_id = value_id for attr_name in kwargs: setattr(self, attr_name, kwargs[attr_name]) diff --git a/tests/scripts/test_auth.py b/tests/scripts/test_auth.py new file mode 100644 index 00000000000000..f6c027150dd8ff --- /dev/null +++ b/tests/scripts/test_auth.py @@ -0,0 +1,126 @@ +"""Test the auth script to manage local users.""" +from unittest.mock import Mock, patch + +import pytest + +from homeassistant.scripts import auth as script_auth +from homeassistant.auth.providers import homeassistant as hass_auth + +from tests.common import register_auth_provider + + +@pytest.fixture +def provider(hass): + """Home Assistant auth provider.""" + provider = hass.loop.run_until_complete(register_auth_provider(hass, { + 'type': 'homeassistant', + })) + hass.loop.run_until_complete(provider.async_initialize()) + return provider + + +async def test_list_user(hass, provider, capsys): + """Test we can list users.""" + data = provider.data + data.add_auth('test-user', 'test-pass') + data.add_auth('second-user', 'second-pass') + + await script_auth.list_users(hass, provider, None) + + captured = capsys.readouterr() + + assert captured.out == '\n'.join([ + 'test-user', + 'second-user', + '', + 'Total users: 2', + '' + ]) + + +async def test_add_user(hass, provider, capsys, hass_storage): + """Test we can add a user.""" + data = provider.data + await script_auth.add_user( + hass, provider, Mock(username='paulus', password='test-pass')) + + assert len(hass_storage[hass_auth.STORAGE_KEY]['data']['users']) == 1 + + captured = capsys.readouterr() + assert captured.out == 'Auth created\n' + + assert len(data.users) == 1 + data.validate_login('paulus', 'test-pass') + + +async def test_validate_login(hass, provider, capsys): + """Test we can validate a user login.""" + data = provider.data + data.add_auth('test-user', 'test-pass') + + await script_auth.validate_login( + hass, provider, Mock(username='test-user', password='test-pass')) + captured = capsys.readouterr() + assert captured.out == 'Auth valid\n' + + await script_auth.validate_login( + hass, provider, Mock(username='test-user', password='invalid-pass')) + captured = capsys.readouterr() + assert captured.out == 'Auth invalid\n' + + await script_auth.validate_login( + hass, provider, Mock(username='invalid-user', password='test-pass')) + captured = capsys.readouterr() + assert captured.out == 'Auth invalid\n' + + +async def test_change_password(hass, provider, capsys, hass_storage): + """Test we can change a password.""" + data = provider.data + data.add_auth('test-user', 'test-pass') + + await script_auth.change_password( + hass, provider, Mock(username='test-user', new_password='new-pass')) + + assert len(hass_storage[hass_auth.STORAGE_KEY]['data']['users']) == 1 + captured = capsys.readouterr() + assert captured.out == 'Password changed\n' + data.validate_login('test-user', 'new-pass') + with pytest.raises(hass_auth.InvalidAuth): + data.validate_login('test-user', 'test-pass') + + +async def test_change_password_invalid_user(hass, provider, capsys, + hass_storage): + """Test changing password of non-existing user.""" + data = provider.data + data.add_auth('test-user', 'test-pass') + + await script_auth.change_password( + hass, provider, Mock(username='invalid-user', new_password='new-pass')) + + assert hass_auth.STORAGE_KEY not in hass_storage + captured = capsys.readouterr() + assert captured.out == 'User not found\n' + data.validate_login('test-user', 'test-pass') + with pytest.raises(hass_auth.InvalidAuth): + data.validate_login('invalid-user', 'new-pass') + + +def test_parsing_args(loop): + """Test we parse args correctly.""" + called = False + + async def mock_func(hass, provider, args2): + """Mock function to be called.""" + nonlocal called + called = True + assert provider.hass.config.config_dir == '/somewhere/config' + assert args2 is args + + args = Mock(config='/somewhere/config', func=mock_func) + + with patch('argparse.ArgumentParser.parse_args', return_value=args): + script_auth.run(None) + + assert called, 'Mock function did not get called' diff --git a/tests/scripts/test_check_config.py b/tests/scripts/test_check_config.py index 8dfc5db90e0bd4..532197b407227b 100644 --- a/tests/scripts/test_check_config.py +++ b/tests/scripts/test_check_config.py @@ -160,15 +160,15 @@ def test_secrets(self, isfile_patch): 'server_host': '0.0.0.0', 'server_port': 8123, 'trusted_networks': [], - 'use_x_forwarded_for': False} + 'ssl_profile': 'modern', + } assert res['secret_cache'] == {secrets_path: {'http_pw': 'abc123'}} assert res['secrets'] == {'http_pw': 'abc123'} assert normalize_yaml_files(res) == [ '.../configuration.yaml', '.../secrets.yaml'] @patch('os.path.isfile', return_value=True) - def test_package_invalid(self, isfile_patch): \ - # pylint: disable=no-self-use,invalid-name + def test_package_invalid(self, isfile_patch): """Test a valid platform setup.""" files = { YAML_CONFIG_FILE: BASE_CONFIG + ( @@ -189,8 +189,7 @@ def test_package_invalid(self, isfile_patch): \ assert res['secrets'] == {} assert len(res['yaml_files']) == 1 - def test_bootstrap_error(self): \ - # pylint: disable=no-self-use,invalid-name + def test_bootstrap_error(self): """Test a valid platform setup.""" files = { YAML_CONFIG_FILE: BASE_CONFIG + 'automation: !include no.yaml', diff --git a/tests/test_auth.py b/tests/test_auth.py deleted file mode 100644 index 4bbf218fd23ca7..00000000000000 --- a/tests/test_auth.py +++ /dev/null @@ -1,159 +0,0 @@ -"""Tests for the Home Assistant auth module.""" -from unittest.mock import Mock - -import pytest - -from homeassistant import auth, data_entry_flow -from tests.common import MockUser, ensure_auth_manager_loaded - - -@pytest.fixture -def mock_hass(): - """Hass mock with minimum amount of data set to make it work with auth.""" - hass = Mock() - hass.config.skip_pip = True - return hass - - -async def test_auth_manager_from_config_validates_config_and_id(mock_hass): - """Test get auth providers.""" - manager = await auth.auth_manager_from_config(mock_hass, [{ - 'name': 'Test Name', - 'type': 'insecure_example', - 'users': [], - }, { - 'name': 'Invalid config because no users', - 'type': 'insecure_example', - 'id': 'invalid_config', - }, { - 'name': 'Test Name 2', - 'type': 'insecure_example', - 'id': 'another', - 'users': [], - }, { - 'name': 'Wrong because duplicate ID', - 'type': 'insecure_example', - 'id': 'another', - 'users': [], - }]) - - providers = [{ - 'name': provider.name, - 'id': provider.id, - 'type': provider.type, - } for provider in manager.async_auth_providers] - assert providers == [{ - 'name': 'Test Name', - 'type': 'insecure_example', - 'id': None, - }, { - 'name': 'Test Name 2', - 'type': 'insecure_example', - 'id': 'another', - }] - - -async def test_create_new_user(mock_hass): - """Test creating new user.""" - manager = await auth.auth_manager_from_config(mock_hass, [{ - 'type': 'insecure_example', - 'users': [{ - 'username': 'test-user', - 'password': 'test-pass', - 'name': 'Test Name' - }] - }]) - - step = await manager.login_flow.async_init(('insecure_example', None)) - assert step['type'] == data_entry_flow.RESULT_TYPE_FORM - - step = await manager.login_flow.async_configure(step['flow_id'], { - 'username': 'test-user', - 'password': 'test-pass', - }) - assert step['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - credentials = step['result'] - user = await manager.async_get_or_create_user(credentials) - assert user is not None - assert user.is_owner is True - assert user.name == 'Test Name' - - -async def test_login_as_existing_user(mock_hass): - """Test login as existing user.""" - manager = await auth.auth_manager_from_config(mock_hass, [{ - 'type': 'insecure_example', - 'users': [{ - 'username': 'test-user', - 'password': 'test-pass', - 'name': 'Test Name' - }] - }]) - ensure_auth_manager_loaded(manager) - - # Add fake user with credentials for example auth provider. - user = MockUser( - id='mock-user', - is_owner=False, - is_active=False, - name='Paulus', - ).add_to_auth_manager(manager) - user.credentials.append(auth.Credentials( - id='mock-id', - auth_provider_type='insecure_example', - auth_provider_id=None, - data={'username': 'test-user'}, - is_new=False, - )) - - step = await manager.login_flow.async_init(('insecure_example', None)) - assert step['type'] == data_entry_flow.RESULT_TYPE_FORM - - step = await manager.login_flow.async_configure(step['flow_id'], { - 'username': 'test-user', - 'password': 'test-pass', - }) - assert step['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - credentials = step['result'] - - user = await manager.async_get_or_create_user(credentials) - assert user is not None - assert user.id == 'mock-user' - assert user.is_owner is False - assert user.is_active is False - assert user.name == 'Paulus' - - -async def test_linking_user_to_two_auth_providers(mock_hass): - """Test linking user to two auth providers.""" - manager = await auth.auth_manager_from_config(mock_hass, [{ - 'type': 'insecure_example', - 'users': [{ - 'username': 'test-user', - 'password': 'test-pass', - }] - }, { - 'type': 'insecure_example', - 'id': 'another-provider', - 'users': [{ - 'username': 'another-user', - 'password': 'another-password', - }] - }]) - - step = await manager.login_flow.async_init(('insecure_example', None)) - step = await manager.login_flow.async_configure(step['flow_id'], { - 'username': 'test-user', - 'password': 'test-pass', - }) - user = await manager.async_get_or_create_user(step['result']) - assert user is not None - - step = await manager.login_flow.async_init(('insecure_example', - 'another-provider')) - step = await manager.login_flow.async_configure(step['flow_id'], { - 'username': 'another-user', - 'password': 'another-password', - }) - await manager.async_link_user(user, step['result']) - assert len(user.credentials) == 2 diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 3e4d47397799a7..4f258bc2b099d4 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -9,7 +9,7 @@ from homeassistant import bootstrap import homeassistant.util.dt as dt_util -from tests.common import patch_yaml_files, get_test_config_dir +from tests.common import patch_yaml_files, get_test_config_dir, mock_coro ORIG_TIMEZONE = dt_util.DEFAULT_TIME_ZONE VERSION_PATH = os.path.join(get_test_config_dir(), config_util.VERSION_FILE) @@ -52,3 +52,56 @@ def test_home_assistant_core_config_validation(hass): } }, hass) assert result is None + + +def test_from_config_dict_not_mount_deps_folder(loop): + """Test that we do not mount the deps folder inside from_config_dict.""" + with patch('homeassistant.bootstrap.is_virtual_env', return_value=False), \ + patch('homeassistant.core.HomeAssistant', + return_value=Mock(loop=loop)), \ + patch('homeassistant.bootstrap.async_mount_local_lib_path', + return_value=mock_coro()) as mock_mount, \ + patch('homeassistant.bootstrap.async_from_config_dict', + return_value=mock_coro()): + + bootstrap.from_config_dict({}, config_dir='.') + assert len(mock_mount.mock_calls) == 1 + + with patch('homeassistant.bootstrap.is_virtual_env', return_value=True), \ + patch('homeassistant.core.HomeAssistant', + return_value=Mock(loop=loop)), \ + patch('homeassistant.bootstrap.async_mount_local_lib_path', + return_value=mock_coro()) as mock_mount, \ + patch('homeassistant.bootstrap.async_from_config_dict', + return_value=mock_coro()): + + bootstrap.from_config_dict({}, config_dir='.') + assert len(mock_mount.mock_calls) == 0 + + +async def test_async_from_config_file_not_mount_deps_folder(loop): + """Test that we not mount the deps folder inside async_from_config_file.""" + hass = Mock( + async_add_executor_job=Mock(side_effect=lambda *args: mock_coro())) + + with patch('homeassistant.bootstrap.is_virtual_env', return_value=False), \ + patch('homeassistant.bootstrap.async_enable_logging', + return_value=mock_coro()), \ + patch('homeassistant.bootstrap.async_mount_local_lib_path', + return_value=mock_coro()) as mock_mount, \ + patch('homeassistant.bootstrap.async_from_config_dict', + return_value=mock_coro()): + + await bootstrap.async_from_config_file('mock-path', hass) + assert len(mock_mount.mock_calls) == 1 + + with patch('homeassistant.bootstrap.is_virtual_env', return_value=True), \ + patch('homeassistant.bootstrap.async_enable_logging', + return_value=mock_coro()), \ + patch('homeassistant.bootstrap.async_mount_local_lib_path', + return_value=mock_coro()) as mock_mount, \ + patch('homeassistant.bootstrap.async_from_config_dict', + return_value=mock_coro()): + + await bootstrap.async_from_config_file('mock-path', hass) + assert len(mock_mount.mock_calls) == 0 diff --git a/tests/test_config.py b/tests/test_config.py index 4b1115c3814c71..435d3a00ec2146 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -7,7 +7,7 @@ from collections import OrderedDict import pytest -from voluptuous import MultipleInvalid +from voluptuous import MultipleInvalid, Invalid from homeassistant.core import DOMAIN, HomeAssistantError, Config import homeassistant.config as config_util @@ -15,7 +15,8 @@ ATTR_FRIENDLY_NAME, ATTR_HIDDEN, ATTR_ASSUMED_STATE, CONF_LATITUDE, CONF_LONGITUDE, CONF_UNIT_SYSTEM, CONF_NAME, CONF_TIME_ZONE, CONF_ELEVATION, CONF_CUSTOMIZE, __version__, - CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL, CONF_TEMPERATURE_UNIT) + CONF_UNIT_SYSTEM_METRIC, CONF_UNIT_SYSTEM_IMPERIAL, CONF_TEMPERATURE_UNIT, + CONF_AUTH_PROVIDERS) from homeassistant.util import location as location_util, dt as dt_util from homeassistant.util.yaml import SECRET_YAML from homeassistant.util.async_ import run_coroutine_threadsafe @@ -589,7 +590,7 @@ def test_merge(merge_log_err, hass): assert len(config['input_boolean']) == 2 assert len(config['input_select']) == 1 assert len(config['light']) == 3 - assert config['wake_on_lan'] is None + assert isinstance(config['wake_on_lan'], OrderedDict) def test_merge_try_falsy(merge_log_err, hass): @@ -654,21 +655,89 @@ def test_merge_type_mismatch(merge_log_err, hass): assert len(config['light']) == 2 -def test_merge_once_only(merge_log_err, hass): - """Test if we have a merge for a comp that may occur only once.""" - packages = { - 'pack_2': { - 'mqtt': {}, - 'api': {}, # No config schema - }, +def test_merge_once_only_keys(merge_log_err, hass): + """Test if we have a merge for a comp that may occur only once. Keys.""" + packages = {'pack_2': {'api': None}} + config = { + config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages}, + 'api': None, + } + config_util.merge_packages_config(hass, config, packages) + assert config['api'] == OrderedDict() + + packages = {'pack_2': {'api': { + 'key_3': 3, + }}} + config = { + config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages}, + 'api': { + 'key_1': 1, + 'key_2': 2, + } } + config_util.merge_packages_config(hass, config, packages) + assert config['api'] == {'key_1': 1, 'key_2': 2, 'key_3': 3, } + + # Duplicate keys error + packages = {'pack_2': {'api': { + 'key': 2, + }}} config = { config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages}, - 'mqtt': {}, 'api': {} + 'api': {'key': 1, } } config_util.merge_packages_config(hass, config, packages) assert merge_log_err.call_count == 1 - assert len(config) == 3 + + +def test_merge_once_only_lists(hass): + """Test if we have a merge for a comp that may occur only once. Lists.""" + packages = {'pack_2': {'api': { + 'list_1': ['item_2', 'item_3'], + 'list_2': ['item_1'], + 'list_3': [], + }}} + config = { + config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages}, + 'api': { + 'list_1': ['item_1'], + } + } + config_util.merge_packages_config(hass, config, packages) + assert config['api'] == { + 'list_1': ['item_1', 'item_2', 'item_3'], + 'list_2': ['item_1'], + } + + +def test_merge_once_only_dictionaries(hass): + """Test if we have a merge for a comp that may occur only once. Dicts.""" + packages = {'pack_2': {'api': { + 'dict_1': { + 'key_2': 2, + 'dict_1.1': {'key_1.2': 1.2, }, + }, + 'dict_2': {'key_1': 1, }, + 'dict_3': {}, + }}} + config = { + config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages}, + 'api': { + 'dict_1': { + 'key_1': 1, + 'dict_1.1': {'key_1.1': 1.1, } + }, + } + } + config_util.merge_packages_config(hass, config, packages) + assert config['api'] == { + 'dict_1': { + 'key_1': 1, + 'key_2': 2, + 'dict_1.1': {'key_1.1': 1.1, 'key_1.2': 1.2, }, + }, + 'dict_2': {'key_1': 1, }, + } def test_merge_id_schema(hass): @@ -695,7 +764,7 @@ def test_merge_duplicate_keys(merge_log_err, hass): } config = { config_util.CONF_CORE: {config_util.CONF_PACKAGES: packages}, - 'input_select': {'ib1': None}, + 'input_select': {'ib1': 1}, } config_util.merge_packages_config(hass, config, packages) @@ -722,3 +791,42 @@ def test_merge_customize(hass): assert hass.data[config_util.DATA_CUSTOMIZE].get('b.b') == \ {'friendly_name': 'BB'} + + +async def test_auth_provider_config(hass): + """Test loading auth provider config onto hass object.""" + core_config = { + 'latitude': 60, + 'longitude': 50, + 'elevation': 25, + 'name': 'Huis', + CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL, + 'time_zone': 'GMT', + CONF_AUTH_PROVIDERS: [ + {'type': 'homeassistant'}, + {'type': 'legacy_api_password'}, + ] + } + if hasattr(hass, 'auth'): + del hass.auth + await config_util.async_process_ha_core_config(hass, core_config) + + assert len(hass.auth.auth_providers) == 2 + assert hass.auth.active is True + + +async def test_disallowed_auth_provider_config(hass): + """Test loading insecure example auth provider is disallowed.""" + core_config = { + 'latitude': 60, + 'longitude': 50, + 'elevation': 25, + 'name': 'Huis', + CONF_UNIT_SYSTEM: CONF_UNIT_SYSTEM_IMPERIAL, + 'time_zone': 'GMT', + CONF_AUTH_PROVIDERS: [ + {'type': 'insecure_example'}, + ] + } + with pytest.raises(Invalid): + await config_util.async_process_ha_core_config(hass, core_config) diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 1518706db55bd3..1f6fd8756e6521 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -1,13 +1,16 @@ """Test the config manager.""" import asyncio -from unittest.mock import MagicMock, patch, mock_open +from datetime import timedelta +from unittest.mock import MagicMock, patch import pytest from homeassistant import config_entries, loader, data_entry_flow from homeassistant.setup import async_setup_component +from homeassistant.util import dt -from tests.common import MockModule, mock_coro, MockConfigEntry +from tests.common import ( + MockModule, mock_coro, MockConfigEntry, async_fire_time_changed) @pytest.fixture @@ -15,6 +18,7 @@ def manager(hass): """Fixture of a loaded config manager.""" manager = config_entries.ConfigEntries(hass, {}) manager._entries = [] + manager._store._async_ensure_stop_listener = lambda: None hass.config_entries = manager return manager @@ -104,7 +108,7 @@ class TestFlow(data_entry_flow.FlowHandler): VERSION = 1 @asyncio.coroutine - def async_step_init(self, user_input=None): + def async_step_user(self, user_input=None): return self.async_create_entry( title='title', data={ @@ -112,7 +116,8 @@ def async_step_init(self, user_input=None): }) with patch.dict(config_entries.HANDLERS, {'comp': TestFlow, 'beer': 5}): - yield from manager.flow.async_init('comp') + yield from manager.flow.async_init( + 'comp', context={'source': config_entries.SOURCE_USER}) yield from hass.async_block_till_done() assert len(mock_setup_entry.mock_calls) == 1 @@ -148,16 +153,17 @@ def test_domains_gets_uniques(manager): assert manager.async_domains() == ['test', 'test2', 'test3'] -@asyncio.coroutine -def test_saving_and_loading(hass): +async def test_saving_and_loading(hass): """Test that we're saving and loading correctly.""" - loader.set_component(hass, 'test', MockModule('test')) + loader.set_component( + hass, 'test', + MockModule('test', async_setup_entry=lambda *args: mock_coro(True))) class TestFlow(data_entry_flow.FlowHandler): VERSION = 5 @asyncio.coroutine - def async_step_init(self, user_input=None): + def async_step_user(self, user_input=None): return self.async_create_entry( title='Test Title', data={ @@ -166,13 +172,14 @@ def async_step_init(self, user_input=None): ) with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): - yield from hass.config_entries.flow.async_init('test') + await hass.config_entries.flow.async_init( + 'test', context={'source': config_entries.SOURCE_USER}) class Test2Flow(data_entry_flow.FlowHandler): VERSION = 3 @asyncio.coroutine - def async_step_init(self, user_input=None): + def async_step_user(self, user_input=None): return self.async_create_entry( title='Test 2 Title', data={ @@ -180,28 +187,19 @@ def async_step_init(self, user_input=None): } ) - json_path = 'homeassistant.util.json.open' - with patch('homeassistant.config_entries.HANDLERS.get', - return_value=Test2Flow), \ - patch.object(config_entries, 'SAVE_DELAY', 0): - yield from hass.config_entries.flow.async_init('test') - - with patch(json_path, mock_open(), create=True) as mock_write: - # To trigger the call_later - yield from asyncio.sleep(0, loop=hass.loop) - # To execute the save - yield from hass.async_block_till_done() + return_value=Test2Flow): + await hass.config_entries.flow.async_init( + 'test', context={'source': config_entries.SOURCE_USER}) - # Mock open calls are: open file, context enter, write, context leave - written = mock_write.mock_calls[2][1][0] + # To trigger the call_later + async_fire_time_changed(hass, dt.utcnow() + timedelta(seconds=1)) + # To execute the save + await hass.async_block_till_done() # Now load written data in new config manager manager = config_entries.ConfigEntries(hass, {}) - - with patch('os.path.isfile', return_value=True), \ - patch(json_path, mock_open(read_data=written), create=True): - yield from manager.async_load() + await manager.async_load() # Ensure same order for orig, loaded in zip(hass.config_entries.async_entries(), @@ -271,7 +269,7 @@ async def async_step_discovery(self, user_input=None): with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): result = await hass.config_entries.flow.async_init( - 'test', source=data_entry_flow.SOURCE_DISCOVERY) + 'test', context={'source': config_entries.SOURCE_DISCOVERY}) await hass.async_block_till_done() state = hass.states.get('persistent_notification.config_entry_discovery') @@ -284,3 +282,33 @@ async def async_step_discovery(self, user_input=None): await hass.async_block_till_done() state = hass.states.get('persistent_notification.config_entry_discovery') assert state is None + + +async def test_discovery_notification_not_created(hass): + """Test that we not create a notification when discovery is aborted.""" + loader.set_component(hass, 'test', MockModule('test')) + await async_setup_component(hass, 'persistent_notification', {}) + + class TestFlow(data_entry_flow.FlowHandler): + VERSION = 5 + + async def async_step_discovery(self, user_input=None): + return self.async_abort(reason='test') + + with patch.dict(config_entries.HANDLERS, {'test': TestFlow}): + await hass.config_entries.flow.async_init( + 'test', context={'source': config_entries.SOURCE_DISCOVERY}) + + await hass.async_block_till_done() + state = hass.states.get('persistent_notification.config_entry_discovery') + assert state is None + + +async def test_loading_default_config(hass): + """Test loading the default config.""" + manager = config_entries.ConfigEntries(hass, {}) + + with patch('homeassistant.util.json.open', side_effect=FileNotFoundError): + await manager.async_load() + + assert len(manager.async_entries()) == 0 diff --git a/tests/test_core.py b/tests/test_core.py index 1fcd9416f36843..f23bed6bc8a028 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -67,6 +67,18 @@ def test_async_add_job_add_threaded_job_to_pool(mock_iscoro): assert len(hass.loop.run_in_executor.mock_calls) == 1 +@patch('asyncio.iscoroutine', return_value=True) +def test_async_create_task_schedule_coroutine(mock_iscoro): + """Test that we schedule coroutines and add jobs to the job pool.""" + hass = MagicMock() + job = MagicMock() + + ha.HomeAssistant.async_create_task(hass, job) + assert len(hass.loop.call_soon.mock_calls) == 0 + assert len(hass.loop.create_task.mock_calls) == 1 + assert len(hass.add_job.mock_calls) == 0 + + def test_async_run_job_calls_callback(): """Test that the callback annotation is respected.""" hass = MagicMock() @@ -234,8 +246,9 @@ def test_eq(self): """Test events.""" now = dt_util.utcnow() data = {'some': 'attr'} + context = ha.Context() event1, event2 = [ - ha.Event('some_type', data, time_fired=now) + ha.Event('some_type', data, time_fired=now, context=context) for _ in range(2) ] @@ -265,6 +278,10 @@ def test_as_dict(self): 'data': data, 'origin': 'LOCAL', 'time_fired': now, + 'context': { + 'id': event.context.id, + 'user_id': event.context.user_id, + }, } self.assertEqual(expected, event.as_dict()) @@ -375,7 +392,7 @@ def event_handler(event): self.assertEqual(1, len(runs)) def test_thread_event_listener(self): - """Test a event listener listeners.""" + """Test thread event listener.""" thread_calls = [] def thread_listener(event): @@ -387,7 +404,7 @@ def thread_listener(event): assert len(thread_calls) == 1 def test_callback_event_listener(self): - """Test a event listener listeners.""" + """Test callback event listener.""" callback_calls = [] @ha.callback @@ -400,7 +417,7 @@ def callback_listener(event): assert len(callback_calls) == 1 def test_coroutine_event_listener(self): - """Test a event listener listeners.""" + """Test coroutine event listener.""" coroutine_calls = [] @asyncio.coroutine @@ -586,18 +603,16 @@ def callback(event): self.assertEqual(1, len(events)) -class TestServiceCall(unittest.TestCase): - """Test ServiceCall class.""" - - def test_repr(self): - """Test repr method.""" - self.assertEqual( - "", - str(ha.ServiceCall('homeassistant', 'start'))) +def test_service_call_repr(): + """Test ServiceCall repr.""" + call = ha.ServiceCall('homeassistant', 'start') + assert str(call) == \ + "".format(call.context.id) - self.assertEqual( - "", - str(ha.ServiceCall('homeassistant', 'start', {"fast": "yes"}))) + call2 = ha.ServiceCall('homeassistant', 'start', {'fast': 'yes'}) + assert str(call2) == \ + "".format( + call2.context.id) class TestServiceRegistry(unittest.TestCase): diff --git a/tests/test_data_entry_flow.py b/tests/test_data_entry_flow.py index 6d3e41436c5b01..c5d5bbb50bfa03 100644 --- a/tests/test_data_entry_flow.py +++ b/tests/test_data_entry_flow.py @@ -12,16 +12,24 @@ def manager(): handlers = Registry() entries = [] - async def async_create_flow(handler_name, *, source, data): + async def async_create_flow(handler_name, *, context, data): handler = handlers.get(handler_name) if handler is None: raise data_entry_flow.UnknownHandler - return handler() + flow = handler() + flow.init_step = context.get('init_step', 'init') \ + if context is not None else 'init' + flow.source = context.get('source') \ + if context is not None else 'user_input' + return flow - async def async_add_entry(result): - entries.append(result) + async def async_add_entry(context, result): + if (result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY): + result['source'] = context.get('source') \ + if context is not None else 'user' + entries.append(result) manager = data_entry_flow.FlowManager( None, async_create_flow, async_add_entry) @@ -56,12 +64,12 @@ async def test_configure_two_steps(manager): class TestFlow(data_entry_flow.FlowHandler): VERSION = 1 - async def async_step_init(self, user_input=None): + async def async_step_first(self, user_input=None): if user_input is not None: self.init_data = user_input return await self.async_step_second() return self.async_show_form( - step_id='init', + step_id='first', data_schema=vol.Schema([str]) ) @@ -76,7 +84,7 @@ async def async_step_second(self, user_input=None): data_schema=vol.Schema([str]) ) - form = await manager.async_init('test') + form = await manager.async_init('test', context={'init_step': 'first'}) with pytest.raises(vol.Invalid): form = await manager.async_configure( @@ -162,7 +170,7 @@ async def async_step_init(self, user_input=None): assert entry['handler'] == 'test' assert entry['title'] == 'Test Title' assert entry['data'] == 'Test Data' - assert entry['source'] == data_entry_flow.SOURCE_USER + assert entry['source'] == 'user' async def test_discovery_init_flow(manager): @@ -171,7 +179,7 @@ async def test_discovery_init_flow(manager): class TestFlow(data_entry_flow.FlowHandler): VERSION = 5 - async def async_step_discovery(self, info): + async def async_step_init(self, info): return self.async_create_entry(title=info['id'], data=info) data = { @@ -180,7 +188,7 @@ async def async_step_discovery(self, info): } await manager.async_init( - 'test', source=data_entry_flow.SOURCE_DISCOVERY, data=data) + 'test', context={'source': 'discovery'}, data=data) assert len(manager.async_progress()) == 0 assert len(manager.mock_created_entries) == 1 @@ -189,4 +197,4 @@ async def async_step_discovery(self, info): assert entry['handler'] == 'test' assert entry['title'] == 'hello' assert entry['data'] == data - assert entry['source'] == data_entry_flow.SOURCE_DISCOVERY + assert entry['source'] == 'discovery' diff --git a/tests/test_loader.py b/tests/test_loader.py index c97e94a7ce10f1..d87201fb61bdce 100644 --- a/tests/test_loader.py +++ b/tests/test_loader.py @@ -124,3 +124,13 @@ async def test_custom_component_name(hass): # Test custom components is mounted from custom_components.test_package import TEST assert TEST == 5 + + +async def test_log_warning_custom_component(hass, caplog): + """Test that we log a warning when loading a custom component.""" + loader.get_component(hass, 'test_standalone') + assert \ + 'You are using a custom component for test_standalone' in caplog.text + + loader.get_component(hass, 'light.test') + assert 'You are using a custom component for light.test' in caplog.text diff --git a/tests/test_util/aiohttp.py b/tests/test_util/aiohttp.py index e67d5de50d1bcc..813eb84707c3c3 100644 --- a/tests/test_util/aiohttp.py +++ b/tests/test_util/aiohttp.py @@ -7,10 +7,22 @@ from urllib.parse import parse_qs from aiohttp import ClientSession +from aiohttp.streams import StreamReader from yarl import URL from aiohttp.client_exceptions import ClientResponseError +retype = type(re.compile('')) + + +def mock_stream(data): + """Mock a stream with data.""" + protocol = mock.Mock(_reading_paused=False) + stream = StreamReader(protocol) + stream.feed_data(data) + stream.feed_eof() + return stream + class AiohttpClientMocker: """Mock Aiohttp client requests.""" @@ -40,10 +52,10 @@ def request(self, method, url, *, if content is None: content = b'' - if not isinstance(url, re._pattern_type): + if not isinstance(url, retype): url = URL(url) if params: - url = url.with_query(params) + url = url.with_query(params) self._mocks.append(AiohttpClientMockResponse( method, url, status, content, cookies, exc, headers)) @@ -128,25 +140,13 @@ def __init__(self, method, url, status, response, cookies=None, exc=None, cookie.value = data self._cookies[name] = cookie - if isinstance(response, list): - self.content = mock.MagicMock() - - @asyncio.coroutine - def read(*argc, **kwargs): - """Read content stream mock.""" - if self.response: - return self.response.pop() - return None - - self.content.read = read - def match_request(self, method, url, params=None): """Test if response answers request.""" if method.lower() != self.method.lower(): return False # regular expression matching - if isinstance(self._url, re._pattern_type): + if isinstance(self._url, retype): return self._url.search(str(url)) is not None if (self._url.scheme != url.scheme or self._url.host != url.host or @@ -175,6 +175,11 @@ def cookies(self): """Return dict of cookies.""" return self._cookies + @property + def content(self): + """Return content.""" + return mock_stream(self.response) + @asyncio.coroutine def read(self): """Return mock response.""" diff --git a/tests/util/test_init.py b/tests/util/test_init.py index 60b0e68ca59b15..1f43c5a4b49b9d 100644 --- a/tests/util/test_init.py +++ b/tests/util/test_init.py @@ -221,7 +221,7 @@ def test_throttle2(): def test_throttle_per_instance(self): """Test that the throttle method is done per instance of a class.""" - class Tester(object): + class Tester: """A tester class for the throttle.""" @util.Throttle(timedelta(seconds=1)) @@ -234,7 +234,7 @@ def hello(self): def test_throttle_on_method(self): """Test that throttle works when wrapping a method.""" - class Tester(object): + class Tester: """A tester class for the throttle.""" def hello(self): @@ -249,7 +249,7 @@ def hello(self): def test_throttle_on_two_method(self): """Test that throttle works when wrapping two methods.""" - class Tester(object): + class Tester: """A test class for the throttle.""" @util.Throttle(timedelta(seconds=1)) diff --git a/tests/util/test_package.py b/tests/util/test_package.py index 33db052f45acae..ab9f9f0ad2c5e3 100644 --- a/tests/util/test_package.py +++ b/tests/util/test_package.py @@ -201,20 +201,8 @@ def test_check_package_zip(): assert not package.check_package_exists(TEST_ZIP_REQ) -def test_get_user_site(deps_dir, lib_dir, mock_popen, mock_env_copy): - """Test get user site directory.""" - env = mock_env_copy() - env['PYTHONUSERBASE'] = os.path.abspath(deps_dir) - args = [sys.executable, '-m', 'site', '--user-site'] - ret = package.get_user_site(deps_dir) - assert mock_popen.call_count == 1 - assert mock_popen.call_args == call( - args, stdin=PIPE, stdout=PIPE, stderr=PIPE, env=env) - assert ret == lib_dir - - @asyncio.coroutine -def test_async_get_user_site(hass, mock_env_copy): +def test_async_get_user_site(mock_env_copy): """Test async get user site directory.""" deps_dir = '/deps_dir' env = mock_env_copy() @@ -222,10 +210,10 @@ def test_async_get_user_site(hass, mock_env_copy): args = [sys.executable, '-m', 'site', '--user-site'] with patch('homeassistant.util.package.asyncio.create_subprocess_exec', return_value=mock_async_subprocess()) as popen_mock: - ret = yield from package.async_get_user_site(deps_dir, hass.loop) + ret = yield from package.async_get_user_site(deps_dir) assert popen_mock.call_count == 1 assert popen_mock.call_args == call( - *args, loop=hass.loop, stdin=asyncio.subprocess.PIPE, + *args, stdin=asyncio.subprocess.PIPE, stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.DEVNULL, env=env) assert ret == os.path.join(deps_dir, 'lib_dir') diff --git a/tests/util/test_yaml.py b/tests/util/test_yaml.py index 734f4b548b91f4..d08915b348bba3 100644 --- a/tests/util/test_yaml.py +++ b/tests/util/test_yaml.py @@ -411,6 +411,22 @@ def test_bad_logger_value(self, mock_error): assert mock_error.call_count == 1, \ "Expected an error about logger: value" + def test_secrets_are_not_dict(self): + """Did secrets handle non-dict file.""" + FILES[self._secret_path] = ( + '- http_pw: pwhttp\n' + ' comp1_un: un1\n' + ' comp1_pw: pw1\n') + yaml.clear_secret_cache() + with self.assertRaises(HomeAssistantError): + load_yaml(self._yaml_path, + 'http:\n' + ' api_password: !secret http_pw\n' + 'component:\n' + ' username: !secret comp1_un\n' + ' password: !secret comp1_pw\n' + '') + def test_representing_yaml_loaded_data(): """Test we can represent YAML loaded data.""" diff --git a/tox.ini b/tox.ini index 86acefe9b3f1e7..d6ef1981bef000 100644 --- a/tox.ini +++ b/tox.ini @@ -1,5 +1,5 @@ [tox] -envlist = py35, py36, lint, requirements, typing +envlist = py35, py36, py37, py38, lint, pylint, typing, cov skip_missing_interpreters = True [testenv] @@ -12,7 +12,23 @@ setenv = whitelist_externals = /usr/bin/env install_command = /usr/bin/env LANG=C.UTF-8 pip install {opts} {packages} commands = - py.test --timeout=9 --duration=10 --cov --cov-report= {posargs} + pytest --timeout=9 --duration=10 {posargs} +deps = + -r{toxinidir}/requirements_test_all.txt + -c{toxinidir}/homeassistant/package_constraints.txt + +[testenv:cov] +basepython = {env:PYTHON3_PATH:python3} +setenv = + PYTHONPATH = {toxinidir}:{toxinidir}/homeassistant +; both temper-python and XBee modules have utf8 in their README files +; which get read in from setup.py. If we don't force our locale to a +; utf8 one, tox's env is reset. And the install of these 2 packages +; fail. +whitelist_externals = /usr/bin/env +install_command = /usr/bin/env LANG=C.UTF-8 pip install {opts} {packages} +commands = + pytest --timeout=9 --duration=10 --cov --cov-report= {posargs} deps = -r{toxinidir}/requirements_test_all.txt -c{toxinidir}/homeassistant/package_constraints.txt @@ -25,7 +41,7 @@ deps = -r{toxinidir}/requirements_test.txt -c{toxinidir}/homeassistant/package_constraints.txt commands = - pylint homeassistant + pylint {posargs} homeassistant [testenv:lint] basepython = {env:PYTHON3_PATH:python3} @@ -38,7 +54,8 @@ commands = [testenv:typing] basepython = {env:PYTHON3_PATH:python3} +whitelist_externals=/bin/bash deps = -r{toxinidir}/requirements_test.txt commands = - mypy --ignore-missing-imports --follow-imports=skip homeassistant + /bin/bash -c 'mypy homeassistant/*.py homeassistant/util/' diff --git a/virtualization/Docker/Dockerfile.dev b/virtualization/Docker/Dockerfile.dev index 06676140702249..790727030314dc 100644 --- a/virtualization/Docker/Dockerfile.dev +++ b/virtualization/Docker/Dockerfile.dev @@ -10,9 +10,9 @@ LABEL maintainer="Paulus Schoutsen " #ENV INSTALL_OPENALPR no #ENV INSTALL_FFMPEG no #ENV INSTALL_LIBCEC no -#ENV INSTALL_PHANTOMJS no #ENV INSTALL_COAP no #ENV INSTALL_SSOCR no +#ENV INSTALL_IPERF3 no VOLUME /config diff --git a/virtualization/Docker/scripts/ffmpeg b/virtualization/Docker/scripts/ffmpeg deleted file mode 100755 index 81b9ce694f9a2c..00000000000000 --- a/virtualization/Docker/scripts/ffmpeg +++ /dev/null @@ -1,16 +0,0 @@ -#!/bin/bash -# Sets up ffmpeg. - -# Stop on errors -set -e - -PACKAGES=( - ffmpeg -) - -# Add jessie-backports -echo "Adding jessie-backports" -echo "deb http://deb.debian.org/debian jessie-backports main" >> /etc/apt/sources.list -apt-get update - -apt-get install -y --no-install-recommends -t jessie-backports ${PACKAGES[@]} \ No newline at end of file diff --git a/virtualization/Docker/scripts/phantomjs b/virtualization/Docker/scripts/phantomjs deleted file mode 100755 index 7700b08f293538..00000000000000 --- a/virtualization/Docker/scripts/phantomjs +++ /dev/null @@ -1,15 +0,0 @@ -#!/bin/bash -# Sets up phantomjs. - -# Stop on errors -set -e - -PHANTOMJS_VERSION="2.1.1" - -cd /usr/src/app/ -mkdir -p build && cd build - -curl -LSO https://bitbucket.org/ariya/phantomjs/downloads/phantomjs-$PHANTOMJS_VERSION-linux-x86_64.tar.bz2 -tar -xjf phantomjs-$PHANTOMJS_VERSION-linux-x86_64.tar.bz2 -mv phantomjs-$PHANTOMJS_VERSION-linux-x86_64/bin/phantomjs /usr/bin/phantomjs -/usr/bin/phantomjs -v \ No newline at end of file diff --git a/virtualization/Docker/setup_docker_prereqs b/virtualization/Docker/setup_docker_prereqs index 302dfba2e1d7c5..65acf92b855b4a 100755 --- a/virtualization/Docker/setup_docker_prereqs +++ b/virtualization/Docker/setup_docker_prereqs @@ -6,9 +6,7 @@ set -e INSTALL_TELLSTICK="${INSTALL_TELLSTICK:-yes}" INSTALL_OPENALPR="${INSTALL_OPENALPR:-yes}" -INSTALL_FFMPEG="${INSTALL_FFMPEG:-yes}" INSTALL_LIBCEC="${INSTALL_LIBCEC:-yes}" -INSTALL_PHANTOMJS="${INSTALL_PHANTOMJS:-yes}" INSTALL_SSOCR="${INSTALL_SSOCR:-yes}" # Required debian packages for running hass or components @@ -22,11 +20,15 @@ PACKAGES=( # homeassistant.components.device_tracker.bluetooth_tracker bluetooth libglib2.0-dev libbluetooth-dev # homeassistant.components.device_tracker.owntracks - libsodium13 + libsodium18 # homeassistant.components.zwave libudev-dev # homeassistant.components.homekit_controller libmpc-dev libmpfr-dev libgmp-dev + # homeassistant.components.ffmpeg + ffmpeg + # homeassistant.components.sensor.iperf3 + iperf3 ) # Required debian packages for building dependencies @@ -40,6 +42,10 @@ PACKAGES_DEV=( apt-get update apt-get install -y --no-install-recommends ${PACKAGES[@]} ${PACKAGES_DEV[@]} +# This is a list of scripts that install additional dependencies. If you only +# need to install a package from the official debian repository, just add it +# to the list above. Only create a script if you need compiling, manually +# downloading or a 3rd party repository. if [ "$INSTALL_TELLSTICK" == "yes" ]; then virtualization/Docker/scripts/tellstick fi @@ -48,18 +54,10 @@ if [ "$INSTALL_OPENALPR" == "yes" ]; then virtualization/Docker/scripts/openalpr fi -if [ "$INSTALL_FFMPEG" == "yes" ]; then - virtualization/Docker/scripts/ffmpeg -fi - if [ "$INSTALL_LIBCEC" == "yes" ]; then virtualization/Docker/scripts/libcec fi -if [ "$INSTALL_PHANTOMJS" == "yes" ]; then - virtualization/Docker/scripts/phantomjs -fi - if [ "$INSTALL_SSOCR" == "yes" ]; then virtualization/Docker/scripts/ssocr fi