From 4ca7273c5847d388ab8077f8f69a8385ff280221 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 27 Feb 2019 17:51:09 -0700 Subject: [PATCH 001/291] Upgrade pyopenuv to 1.0.9 (#21513) --- homeassistant/components/openuv/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index 52383366c4d..5533beb2fae 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -15,7 +15,7 @@ from homeassistant.helpers.entity import Entity from .config_flow import configured_instances from .const import DOMAIN -REQUIREMENTS = ['pyopenuv==1.0.4'] +REQUIREMENTS = ['pyopenuv==1.0.9'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index e4336a03ffe..d2913906a80 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1177,7 +1177,7 @@ pynut2==2.1.2 pynx584==0.4 # homeassistant.components.openuv -pyopenuv==1.0.4 +pyopenuv==1.0.9 # homeassistant.components.light.opple pyoppleio==1.0.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index deed85b20ff..102d464b97d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -206,7 +206,7 @@ pymonoprice==0.3 pynx584==0.4 # homeassistant.components.openuv -pyopenuv==1.0.4 +pyopenuv==1.0.9 # homeassistant.auth.mfa_modules.notify # homeassistant.auth.mfa_modules.totp From 1369b0b58385a9c58c220455a44a3ef1d26d5897 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 27 Feb 2019 17:51:36 -0700 Subject: [PATCH 002/291] Upgrade pypollencom to 2.2.3 (#21517) --- homeassistant/components/sensor/pollen.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/pollen.py b/homeassistant/components/sensor/pollen.py index d553dd8730f..28e4f823222 100644 --- a/homeassistant/components/sensor/pollen.py +++ b/homeassistant/components/sensor/pollen.py @@ -13,7 +13,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['numpy==1.16.1', 'pypollencom==2.2.2'] +REQUIREMENTS = ['numpy==1.16.1', 'pypollencom==2.2.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index d2913906a80..8e9d9514bb3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1210,7 +1210,7 @@ pypjlink2==1.2.0 pypoint==1.1.1 # homeassistant.components.sensor.pollen -pypollencom==2.2.2 +pypollencom==2.2.3 # homeassistant.components.ps4 pyps4-homeassistant==0.3.0 From fd32910185a68e52082b876aeeb8907aa0020c46 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 27 Feb 2019 17:52:05 -0700 Subject: [PATCH 003/291] Upgrade pytile to 2.0.6 (#21516) --- homeassistant/components/device_tracker/tile.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/device_tracker/tile.py b/homeassistant/components/device_tracker/tile.py index 81d8a6867c6..6da520280e2 100644 --- a/homeassistant/components/device_tracker/tile.py +++ b/homeassistant/components/device_tracker/tile.py @@ -18,7 +18,7 @@ from homeassistant.util import slugify from homeassistant.util.json import load_json, save_json _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pytile==2.0.5'] +REQUIREMENTS = ['pytile==2.0.6'] CLIENT_UUID_CONFIG_FILE = '.tile.conf' DEVICE_TYPES = ['PHONE', 'TILE'] diff --git a/requirements_all.txt b/requirements_all.txt index 8e9d9514bb3..31b8b6d58b2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1405,7 +1405,7 @@ pythonegardia==1.0.39 pythonwhois==2.4.3 # homeassistant.components.device_tracker.tile -pytile==2.0.5 +pytile==2.0.6 # homeassistant.components.climate.touchline pytouchline==0.7 From c1365de86110684573941dd254fdd073b967117a Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 27 Feb 2019 17:52:31 -0700 Subject: [PATCH 004/291] Upgraded py17track to 2.2.2 (#21515) --- homeassistant/components/sensor/seventeentrack.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/seventeentrack.py b/homeassistant/components/sensor/seventeentrack.py index c77b934fbad..6fb4884989b 100644 --- a/homeassistant/components/sensor/seventeentrack.py +++ b/homeassistant/components/sensor/seventeentrack.py @@ -17,7 +17,7 @@ from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle, slugify -REQUIREMENTS = ['py17track==2.1.1'] +REQUIREMENTS = ['py17track==2.2.2'] _LOGGER = logging.getLogger(__name__) ATTR_DESTINATION_COUNTRY = 'destination_country' diff --git a/requirements_all.txt b/requirements_all.txt index 31b8b6d58b2..088fbd35ff5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -890,7 +890,7 @@ py-melissa-climate==2.0.0 py-synology==0.2.0 # homeassistant.components.sensor.seventeentrack -py17track==2.1.1 +py17track==2.2.2 # homeassistant.components.hdmi_cec pyCEC==0.4.13 From aad15776c020c124e175a3dea1b78546fd0233df Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 27 Feb 2019 17:52:50 -0700 Subject: [PATCH 005/291] Upgrade pyflunearyou to 1.0.2 (#21514) --- homeassistant/components/sensor/flunearyou.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/flunearyou.py b/homeassistant/components/sensor/flunearyou.py index a1a306f36e0..e534461211f 100644 --- a/homeassistant/components/sensor/flunearyou.py +++ b/homeassistant/components/sensor/flunearyou.py @@ -18,7 +18,7 @@ from homeassistant.helpers import aiohttp_client from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['pyflunearyou==1.0.1'] +REQUIREMENTS = ['pyflunearyou==1.0.2'] _LOGGER = logging.getLogger(__name__) ATTR_CITY = 'city' diff --git a/requirements_all.txt b/requirements_all.txt index 088fbd35ff5..370ea38a542 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1033,7 +1033,7 @@ pyflexit==0.3 pyflic-homeassistant==0.4.dev0 # homeassistant.components.sensor.flunearyou -pyflunearyou==1.0.1 +pyflunearyou==1.0.2 # homeassistant.components.light.futurenow pyfnip==0.2 From 548d7bbedacfe740a2d06031cf13e10c789954fe Mon Sep 17 00:00:00 2001 From: Adam Dullage Date: Thu, 28 Feb 2019 04:23:21 +0000 Subject: [PATCH 006/291] Bump starlingbank version to 3.1 (#21501) * Bump starlingbank version to 3.1 Resolves Python 3.5 compatibility issue. * Remove syntax error. --- homeassistant/components/sensor/starlingbank.py | 4 ++-- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/starlingbank.py b/homeassistant/components/sensor/starlingbank.py index 9cb57670740..e325e5e1a57 100644 --- a/homeassistant/components/sensor/starlingbank.py +++ b/homeassistant/components/sensor/starlingbank.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_ACCESS_TOKEN, CONF_NAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['starlingbank==3.0'] +REQUIREMENTS = ['starlingbank==3.1'] _LOGGER = logging.getLogger(__name__) @@ -44,7 +44,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the Sterling Bank sensor platform.""" - from starlingbank import StarlingAccount # pylint: disable=syntax-error + from starlingbank import StarlingAccount sensors = [] for account in config[CONF_ACCOUNTS]: diff --git a/requirements_all.txt b/requirements_all.txt index 370ea38a542..a238347252c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1622,7 +1622,7 @@ sqlalchemy==1.2.18 srpenergy==1.0.5 # homeassistant.components.sensor.starlingbank -starlingbank==3.0 +starlingbank==3.1 # homeassistant.components.statsd statsd==3.2.1 From 229d19bb20f4fc318c784517c667b92ca99f38fd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 27 Feb 2019 21:35:14 -0800 Subject: [PATCH 007/291] Fix lint (#21520) --- homeassistant/components/person/__init__.py | 21 +++++++++++--------- homeassistant/components/sensor/airvisual.py | 4 ++-- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index c4af4a699cd..622ca0608ac 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -2,6 +2,7 @@ from collections import OrderedDict from itertools import chain import logging +from typing import Optional import uuid import voluptuous as vol @@ -13,7 +14,7 @@ from homeassistant.const import ( ATTR_ID, ATTR_LATITUDE, ATTR_LONGITUDE, ATTR_GPS_ACCURACY, CONF_ID, CONF_NAME, EVENT_HOMEASSISTANT_START, STATE_UNKNOWN, STATE_UNAVAILABLE, STATE_HOME, STATE_NOT_HOME) -from homeassistant.core import callback, Event +from homeassistant.core import callback, Event, State from homeassistant.auth import EVENT_USER_REMOVED import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_component import EntityComponent @@ -377,11 +378,6 @@ class Person(RestoreEntity): """Handle the device tracker state changes.""" self._update_state() - def _get_latest(self, prev, curr): - return curr \ - if prev is None or curr.last_updated > prev.last_updated \ - else prev - @callback def _update_state(self): """Update the state.""" @@ -393,11 +389,11 @@ class Person(RestoreEntity): continue if state.attributes.get(ATTR_SOURCE_TYPE) == SOURCE_TYPE_GPS: - latest_gps = self._get_latest(latest_gps, state) + latest_gps = _get_latest(latest_gps, state) elif state.state == STATE_HOME: - latest_home = self._get_latest(latest_home, state) + latest_home = _get_latest(latest_home, state) elif state.state == STATE_NOT_HOME: - latest_not_home = self._get_latest(latest_not_home, state) + latest_not_home = _get_latest(latest_not_home, state) if latest_home: latest = latest_home @@ -508,3 +504,10 @@ async def ws_delete_person(hass: HomeAssistantType, manager = hass.data[DOMAIN] # type: PersonManager await manager.async_delete_person(msg['person_id']) connection.send_result(msg['id']) + + +def _get_latest(prev: Optional[State], curr: State): + """Get latest state.""" + if prev is None or curr.last_updated > prev.last_updated: + return curr + return prev diff --git a/homeassistant/components/sensor/airvisual.py b/homeassistant/components/sensor/airvisual.py index 46457a17ebb..e13fb924041 100644 --- a/homeassistant/components/sensor/airvisual.py +++ b/homeassistant/components/sensor/airvisual.py @@ -141,7 +141,7 @@ async def async_setup_platform( "Using city, state, and country: %s, %s, %s", city, state, country) location_id = ','.join((city, state, country)) data = AirVisualData( - Client(config[CONF_API_KEY], websession), + Client(websession, api_key=config[CONF_API_KEY]), city=city, state=state, country=country, @@ -152,7 +152,7 @@ async def async_setup_platform( "Using latitude and longitude: %s, %s", latitude, longitude) location_id = ','.join((str(latitude), str(longitude))) data = AirVisualData( - Client(config[CONF_API_KEY], websession), + Client(websession, api_key=config[CONF_API_KEY]), latitude=latitude, longitude=longitude, show_on_map=config[CONF_SHOW_ON_MAP], From 6f2def06be47b3eee65b3f3e8eea8ace05091885 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 28 Feb 2019 16:29:14 +0530 Subject: [PATCH 008/291] Upgrade opensensemap-api to 0.1.5 (#21524) --- homeassistant/components/air_quality/opensensemap.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/air_quality/opensensemap.py b/homeassistant/components/air_quality/opensensemap.py index 8462e40be5b..5407f65a1d8 100644 --- a/homeassistant/components/air_quality/opensensemap.py +++ b/homeassistant/components/air_quality/opensensemap.py @@ -11,7 +11,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle -REQUIREMENTS = ['opensensemap-api==0.1.4'] +REQUIREMENTS = ['opensensemap-api==0.1.5'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index a238347252c..1b6971455e6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -773,7 +773,7 @@ openevsewifi==0.4 openhomedevice==0.4.2 # homeassistant.components.air_quality.opensensemap -opensensemap-api==0.1.4 +opensensemap-api==0.1.5 # homeassistant.components.device_tracker.luci openwrt-luci-rpc==1.0.5 From 27a780dcc922df54da42002fd77fc7b9d16331a0 Mon Sep 17 00:00:00 2001 From: Jeff Irion Date: Thu, 28 Feb 2019 03:29:56 -0800 Subject: [PATCH 009/291] Register 'firetv.adb_command' service (#21419) * Register 'media_player.firetv_adb_cmd' service * Wrap the 'firetv_adb_cmd' service with 'adb_decorator' * Address reviewer comments * Move firetv to its own platform * Move 'adb_command' service description * Rename DOMAIN to FIRETV_DOMAIN * Import KEYS in __init__ method * Change 'self.KEYS' to 'self.keys' * Update firetv in .coveragerc * 'homeassistant.components.media_player.firetv' -> 'homeassistant.components.firetv' * 'homeassistant.components.firetv' -> 'homeassistant.components.firetv.media_player' --- .coveragerc | 4 +- homeassistant/components/firetv/__init__.py | 6 ++ .../firetv.py => firetv/media_player.py} | 63 ++++++++++++++++--- homeassistant/components/firetv/services.yaml | 11 ++++ requirements_all.txt | 2 +- 5 files changed, 75 insertions(+), 11 deletions(-) create mode 100644 homeassistant/components/firetv/__init__.py rename homeassistant/components/{media_player/firetv.py => firetv/media_player.py} (80%) create mode 100644 homeassistant/components/firetv/services.yaml diff --git a/.coveragerc b/.coveragerc index f8829939682..03dab64e32c 100644 --- a/.coveragerc +++ b/.coveragerc @@ -168,6 +168,7 @@ omit = homeassistant/components/fan/wemo.py homeassistant/components/fastdotcom/* homeassistant/components/fibaro/* + homeassistant/components/firetv/* homeassistant/components/folder_watcher/* homeassistant/components/foursquare/* homeassistant/components/freebox/* @@ -280,7 +281,6 @@ omit = 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 @@ -696,4 +696,4 @@ exclude_lines = # Don't complain if tests don't hit defensive assertion code: raise AssertionError - raise NotImplementedError \ No newline at end of file + raise NotImplementedError diff --git a/homeassistant/components/firetv/__init__.py b/homeassistant/components/firetv/__init__.py new file mode 100644 index 00000000000..68f55631332 --- /dev/null +++ b/homeassistant/components/firetv/__init__.py @@ -0,0 +1,6 @@ +""" +Support for functionality to interact with FireTV devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.firetv/ +""" diff --git a/homeassistant/components/media_player/firetv.py b/homeassistant/components/firetv/media_player.py similarity index 80% rename from homeassistant/components/media_player/firetv.py rename to homeassistant/components/firetv/media_player.py index fb7df736e51..880e1c918a9 100644 --- a/homeassistant/components/media_player/firetv.py +++ b/homeassistant/components/firetv/media_player.py @@ -11,14 +11,15 @@ import voluptuous as vol from homeassistant.components.media_player import ( MediaPlayerDevice, PLATFORM_SCHEMA) from homeassistant.components.media_player.const import ( - SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, - SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_STOP, - SUPPORT_TURN_OFF, SUPPORT_TURN_ON) + SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK, + SUPPORT_SELECT_SOURCE, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON) from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_PORT, STATE_IDLE, STATE_OFF, STATE_PAUSED, - STATE_PLAYING, STATE_STANDBY) + ATTR_COMMAND, ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_PORT, STATE_IDLE, + STATE_OFF, STATE_PAUSED, STATE_PLAYING, STATE_STANDBY) import homeassistant.helpers.config_validation as cv +FIRETV_DOMAIN = 'firetv' + REQUIREMENTS = ['firetv==1.0.9'] _LOGGER = logging.getLogger(__name__) @@ -37,6 +38,13 @@ DEFAULT_PORT = 5555 DEFAULT_ADB_SERVER_PORT = 5037 DEFAULT_GET_SOURCES = True +SERVICE_ADB_COMMAND = 'adb_command' + +SERVICE_ADB_COMMAND_SCHEMA = vol.Schema({ + vol.Required(ATTR_ENTITY_ID): cv.entity_ids, + vol.Required(ATTR_COMMAND): cv.string, +}) + def has_adb_files(value): """Check that ADB key files exist.""" @@ -69,6 +77,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the FireTV platform.""" from firetv import FireTV + hass.data.setdefault(FIRETV_DOMAIN, {}) + host = '{0}:{1}'.format(config[CONF_HOST], config[CONF_PORT]) if CONF_ADB_SERVER_IP not in config: @@ -93,9 +103,35 @@ def setup_platform(hass, config, add_entities, discovery_info=None): name = config[CONF_NAME] get_sources = config[CONF_GET_SOURCES] - device = FireTVDevice(ftv, name, get_sources) - add_entities([device]) - _LOGGER.debug("Setup Fire TV at %s%s", host, adb_log) + if host in hass.data[FIRETV_DOMAIN]: + _LOGGER.warning("Platform already setup on %s, skipping", host) + else: + device = FireTVDevice(ftv, name, get_sources) + add_entities([device]) + _LOGGER.debug("Setup Fire TV at %s%s", host, adb_log) + hass.data[FIRETV_DOMAIN][host] = device + + if hass.services.has_service(FIRETV_DOMAIN, SERVICE_ADB_COMMAND): + return + + def service_adb_command(service): + """Dispatch service calls to target entities.""" + cmd = service.data.get(ATTR_COMMAND) + entity_id = service.data.get(ATTR_ENTITY_ID) + target_devices = [dev for dev in hass.data[FIRETV_DOMAIN].values() + if dev.entity_id in entity_id] + + for target_device in target_devices: + output = target_device.adb_command(cmd) + + # log the output if there is any + if output: + _LOGGER.info("Output of command '%s' from '%s': %s", + cmd, target_device.entity_id, repr(output)) + + hass.services.register(FIRETV_DOMAIN, SERVICE_ADB_COMMAND, + service_adb_command, + schema=SERVICE_ADB_COMMAND_SCHEMA) def adb_decorator(override_available=False): @@ -127,6 +163,9 @@ class FireTVDevice(MediaPlayerDevice): def __init__(self, ftv, name, get_sources): """Initialize the FireTV device.""" + from firetv import KEYS + self.keys = KEYS + self.firetv = ftv self._name = name @@ -276,3 +315,11 @@ class FireTVDevice(MediaPlayerDevice): self.firetv.launch_app(source) else: self.firetv.stop_app(source[1:].lstrip()) + + @adb_decorator() + def adb_command(self, cmd): + """Send an ADB command to a Fire TV device.""" + key = self.keys.get(cmd) + if key: + return self.firetv.adb_shell('input keyevent {}'.format(key)) + return self.firetv.adb_shell(cmd) diff --git a/homeassistant/components/firetv/services.yaml b/homeassistant/components/firetv/services.yaml new file mode 100644 index 00000000000..78019547641 --- /dev/null +++ b/homeassistant/components/firetv/services.yaml @@ -0,0 +1,11 @@ +# Describes the format for available Fire TV services + +adb_command: + description: Send an ADB command to a Fire TV device. + fields: + entity_id: + description: Name(s) of Fire TV entities. + example: 'media_player.fire_tv_living_room' + command: + description: Either a key command or an ADB shell command. + example: 'HOME' diff --git a/requirements_all.txt b/requirements_all.txt index 1b6971455e6..b1e3c570702 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -419,7 +419,7 @@ fiblary3==0.1.7 # homeassistant.components.sensor.fints fints==1.0.1 -# homeassistant.components.media_player.firetv +# homeassistant.components.firetv.media_player firetv==1.0.9 # homeassistant.components.sensor.fitbit From bfc6f51b25f6e633b2b92f402dbaa3eb32b68dbe Mon Sep 17 00:00:00 2001 From: koolsb <14332595+koolsb@users.noreply.github.com> Date: Thu, 28 Feb 2019 05:45:17 -0600 Subject: [PATCH 010/291] Add arm night for alarm decoder (#21488) --- homeassistant/components/alarmdecoder/alarm_control_panel.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/alarmdecoder/alarm_control_panel.py b/homeassistant/components/alarmdecoder/alarm_control_panel.py index 986907622b1..cf26e42b056 100644 --- a/homeassistant/components/alarmdecoder/alarm_control_panel.py +++ b/homeassistant/components/alarmdecoder/alarm_control_panel.py @@ -131,6 +131,11 @@ class AlarmDecoderAlarmPanel(alarm.AlarmControlPanel): if code: self.hass.data[DATA_AD].send("{!s}3".format(code)) + def alarm_arm_night(self, code=None): + """Send arm night command.""" + if code: + self.hass.data[DATA_AD].send("{!s}33".format(code)) + def alarm_toggle_chime(self, code=None): """Send toggle chime command.""" if code: From 342ddbfe8ccc6679205c9c7805c1c38b6be04871 Mon Sep 17 00:00:00 2001 From: Victor Vostrikov <1998617+gorynychzmey@users.noreply.github.com> Date: Thu, 28 Feb 2019 13:05:39 +0100 Subject: [PATCH 011/291] Updated variable name for readability (#21528) --- homeassistant/components/person/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 622ca0608ac..e6f83b80ba4 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -381,7 +381,7 @@ class Person(RestoreEntity): @callback def _update_state(self): """Update the state.""" - latest_home = latest_not_home = latest_gps = latest = None + latest_non_gps_home = latest_not_home = latest_gps = latest = None for entity_id in self._config.get(CONF_DEVICE_TRACKERS, []): state = self.hass.states.get(entity_id) @@ -391,12 +391,12 @@ class Person(RestoreEntity): if state.attributes.get(ATTR_SOURCE_TYPE) == SOURCE_TYPE_GPS: latest_gps = _get_latest(latest_gps, state) elif state.state == STATE_HOME: - latest_home = _get_latest(latest_home, state) + latest_non_gps_home = _get_latest(latest_non_gps_home, state) elif state.state == STATE_NOT_HOME: latest_not_home = _get_latest(latest_not_home, state) - if latest_home: - latest = latest_home + if latest_non_gps_home: + latest = latest_non_gps_home elif latest_gps: latest = latest_gps else: From 3e8e998078be75ae793152ea3ec38f2b50b3ea22 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 28 Feb 2019 17:46:21 +0530 Subject: [PATCH 012/291] Upgrade numpy to 1.16.2 (#21525) --- homeassistant/components/binary_sensor/trend.py | 2 +- homeassistant/components/image_processing/opencv.py | 9 ++------- homeassistant/components/image_processing/tensorflow.py | 4 ++-- homeassistant/components/sensor/pollen.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 8 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/binary_sensor/trend.py b/homeassistant/components/binary_sensor/trend.py index 0d4e9631650..fe3d10ab72c 100644 --- a/homeassistant/components/binary_sensor/trend.py +++ b/homeassistant/components/binary_sensor/trend.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity import generate_entity_id from homeassistant.helpers.event import async_track_state_change from homeassistant.util import utcnow -REQUIREMENTS = ['numpy==1.16.1'] +REQUIREMENTS = ['numpy==1.16.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/image_processing/opencv.py b/homeassistant/components/image_processing/opencv.py index 7cb5184b116..10173cdb725 100644 --- a/homeassistant/components/image_processing/opencv.py +++ b/homeassistant/components/image_processing/opencv.py @@ -1,9 +1,4 @@ -""" -Component that performs OpenCV classification on images. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/image_processing.opencv/ -""" +"""Support for OpenCV classification on images.""" from datetime import timedelta import logging @@ -16,7 +11,7 @@ from homeassistant.components.image_processing import ( from homeassistant.core import split_entity_id import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['numpy==1.16.1'] +REQUIREMENTS = ['numpy==1.16.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/image_processing/tensorflow.py b/homeassistant/components/image_processing/tensorflow.py index f0e8f5182fc..4e4a80a525e 100644 --- a/homeassistant/components/image_processing/tensorflow.py +++ b/homeassistant/components/image_processing/tensorflow.py @@ -12,7 +12,7 @@ from homeassistant.core import split_entity_id from homeassistant.helpers import template import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['numpy==1.16.1', 'pillow==5.4.1', 'protobuf==3.6.1'] +REQUIREMENTS = ['numpy==1.16.2', 'pillow==5.4.1', 'protobuf==3.6.1'] _LOGGER = logging.getLogger(__name__) @@ -111,7 +111,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): "No OpenCV library found. TensorFlow will process image with " "PIL at reduced resolution") - # setup tensorflow graph, session, and label map to pass to processor + # Set up Tensorflow graph, session, and label map to pass to processor # pylint: disable=no-member detection_graph = tf.Graph() with detection_graph.as_default(): diff --git a/homeassistant/components/sensor/pollen.py b/homeassistant/components/sensor/pollen.py index 28e4f823222..08fe45a22a6 100644 --- a/homeassistant/components/sensor/pollen.py +++ b/homeassistant/components/sensor/pollen.py @@ -13,7 +13,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['numpy==1.16.1', 'pypollencom==2.2.3'] +REQUIREMENTS = ['numpy==1.16.2', 'pypollencom==2.2.3'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index b1e3c570702..c6568676e32 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -752,7 +752,7 @@ nuheat==0.3.0 # homeassistant.components.image_processing.opencv # homeassistant.components.image_processing.tensorflow # homeassistant.components.sensor.pollen -numpy==1.16.1 +numpy==1.16.2 # homeassistant.components.google oauth2client==4.0.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 102d464b97d..26d77409fca 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -151,7 +151,7 @@ mficlient==0.3.0 # homeassistant.components.image_processing.opencv # homeassistant.components.image_processing.tensorflow # homeassistant.components.sensor.pollen -numpy==1.16.1 +numpy==1.16.2 # homeassistant.components.mqtt # homeassistant.components.shiftr From b0dd6e40932037f23ba6e3f4b53a036cbe10d3d3 Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Thu, 28 Feb 2019 17:46:38 +0530 Subject: [PATCH 013/291] Upgrade python-mystrom to 0.5.0 (#21523) --- homeassistant/components/light/mystrom.py | 9 ++------- homeassistant/components/switch/mystrom.py | 9 ++------- requirements_all.txt | 2 +- 3 files changed, 5 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/light/mystrom.py b/homeassistant/components/light/mystrom.py index 8060bef0fa8..f9b8dcd203b 100644 --- a/homeassistant/components/light/mystrom.py +++ b/homeassistant/components/light/mystrom.py @@ -1,9 +1,4 @@ -""" -Support for myStrom Wifi bulbs. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/light.mystrom/ -""" +"""Support for myStrom Wifi bulbs.""" import logging import voluptuous as vol @@ -15,7 +10,7 @@ from homeassistant.components.light import ( ATTR_HS_COLOR) from homeassistant.const import CONF_HOST, CONF_MAC, CONF_NAME -REQUIREMENTS = ['python-mystrom==0.4.4'] +REQUIREMENTS = ['python-mystrom==0.5.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/switch/mystrom.py b/homeassistant/components/switch/mystrom.py index a0058cb925c..a25517eea91 100644 --- a/homeassistant/components/switch/mystrom.py +++ b/homeassistant/components/switch/mystrom.py @@ -1,9 +1,4 @@ -""" -Support for myStrom switches. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/switch.mystrom/ -""" +"""Support for myStrom switches.""" import logging import voluptuous as vol @@ -12,7 +7,7 @@ from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import (CONF_NAME, CONF_HOST) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['python-mystrom==0.4.4'] +REQUIREMENTS = ['python-mystrom==0.5.0'] DEFAULT_NAME = 'myStrom Switch' diff --git a/requirements_all.txt b/requirements_all.txt index c6568676e32..703be36352d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1345,7 +1345,7 @@ python-mpd2==1.0.0 # homeassistant.components.light.mystrom # homeassistant.components.switch.mystrom -python-mystrom==0.4.4 +python-mystrom==0.5.0 # homeassistant.components.nest python-nest==4.1.0 From c3d47386491dd812072e26d4f7e7bf203a5e5a36 Mon Sep 17 00:00:00 2001 From: Marco M Date: Thu, 28 Feb 2019 17:44:23 +0100 Subject: [PATCH 014/291] Mqtt alarm added value_template and code_arm_required (#19558) * Added value_template config for parsing json value from state topic Added arm_code_required to avoid code enter when arming * Renamed config parameter to code_arm_required * Fix for discovery update compatibility * Fixed lint error * Added test --- .../components/mqtt/alarm_control_panel.py | 8 ++- .../mqtt/test_alarm_control_panel.py | 51 +++++++++++++++---- 2 files changed, 48 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 8e1b62414b7..3602defd02a 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -28,6 +28,7 @@ from homeassistant.helpers.typing import ConfigType, HomeAssistantType _LOGGER = logging.getLogger(__name__) +CONF_CODE_ARM_REQUIRED = 'code_arm_required' CONF_PAYLOAD_DISARM = 'payload_disarm' CONF_PAYLOAD_ARM_HOME = 'payload_arm_home' CONF_PAYLOAD_ARM_AWAY = 'payload_arm_away' @@ -52,6 +53,7 @@ PLATFORM_SCHEMA = mqtt.MQTT_BASE_PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PAYLOAD_DISARM, default=DEFAULT_DISARM): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_DEVICE): mqtt.MQTT_ENTITY_DEVICE_INFO_SCHEMA, + vol.Optional(CONF_CODE_ARM_REQUIRED, default=True): cv.boolean, }).extend(mqtt.MQTT_AVAILABILITY_SCHEMA.schema).extend( mqtt.MQTT_JSON_ATTRS_SCHEMA.schema) @@ -197,7 +199,8 @@ class MqttAlarm(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, This method is a coroutine. """ - if not self._validate_code(code, 'arming home'): + code_required = self._config.get(CONF_CODE_ARM_REQUIRED) + if code_required and not self._validate_code(code, 'arming home'): return mqtt.async_publish( self.hass, self._config.get(CONF_COMMAND_TOPIC), @@ -210,7 +213,8 @@ class MqttAlarm(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, This method is a coroutine. """ - if not self._validate_code(code, 'arming away'): + code_required = self._config.get(CONF_CODE_ARM_REQUIRED) + if code_required and not self._validate_code(code, 'arming away'): return mqtt.async_publish( self.hass, self._config.get(CONF_COMMAND_TOPIC), diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index 81c993ed311..4db66774b6e 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -114,15 +114,19 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): self.mock_publish.async_publish.assert_called_once_with( 'alarm/command', 'ARM_HOME', 0, False) - def test_arm_home_not_publishes_mqtt_with_invalid_code(self): - """Test not publishing of MQTT messages with invalid code.""" + def test_arm_home_not_publishes_mqtt_with_invalid_code_when_req(self): + """Test not publishing of MQTT messages with invalid. + + When code_arm_required = True + """ assert setup_component(self.hass, alarm_control_panel.DOMAIN, { alarm_control_panel.DOMAIN: { 'platform': 'mqtt', 'name': 'test', 'state_topic': 'alarm/state', 'command_topic': 'alarm/command', - 'code': '1234' + 'code': '1234', + 'code_arm_required': True } }) @@ -147,15 +151,19 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): self.mock_publish.async_publish.assert_called_once_with( 'alarm/command', 'ARM_AWAY', 0, False) - def test_arm_away_not_publishes_mqtt_with_invalid_code(self): - """Test not publishing of MQTT messages with invalid code.""" + def test_arm_away_not_publishes_mqtt_with_invalid_code_when_req(self): + """Test not publishing of MQTT messages with invalid code. + + When code_arm_required = True + """ assert setup_component(self.hass, alarm_control_panel.DOMAIN, { alarm_control_panel.DOMAIN: { 'platform': 'mqtt', 'name': 'test', 'state_topic': 'alarm/state', 'command_topic': 'alarm/command', - 'code': '1234' + 'code': '1234', + 'code_arm_required': True } }) @@ -164,6 +172,27 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): self.hass.block_till_done() assert call_count == self.mock_publish.call_count + def test_arm_away_publishes_mqtt_when_code_not_req(self): + """Test publishing of MQTT messages. + + When code_arm_required = False + """ + assert setup_component(self.hass, alarm_control_panel.DOMAIN, { + alarm_control_panel.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'alarm/state', + 'command_topic': 'alarm/command', + 'code': '1234', + 'code_arm_required': False + } + }) + + common.alarm_arm_away(self.hass) + self.hass.block_till_done() + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/command', 'ARM_AWAY', 0, False) + def test_arm_night_publishes_mqtt(self): """Test publishing of MQTT messages while armed.""" assert setup_component(self.hass, alarm_control_panel.DOMAIN, { @@ -180,15 +209,19 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): self.mock_publish.async_publish.assert_called_once_with( 'alarm/command', 'ARM_NIGHT', 0, False) - def test_arm_night_not_publishes_mqtt_with_invalid_code(self): - """Test not publishing of MQTT messages with invalid code.""" + def test_arm_night_not_publishes_mqtt_with_invalid_code_when_req(self): + """Test not publishing of MQTT messages with invalid code. + + When code_arm_required = True + """ assert setup_component(self.hass, alarm_control_panel.DOMAIN, { alarm_control_panel.DOMAIN: { 'platform': 'mqtt', 'name': 'test', 'state_topic': 'alarm/state', 'command_topic': 'alarm/command', - 'code': '1234' + 'code': '1234', + 'code_arm_required': True } }) From c340083ba50452ec5ed96a7465fffaa753799b0d Mon Sep 17 00:00:00 2001 From: emontnemery Date: Thu, 28 Feb 2019 18:26:54 +0100 Subject: [PATCH 015/291] Add missing retain option to mqtt.climate configuration schema (#21536) --- homeassistant/components/mqtt/climate.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 957e1aadfb7..7be47185322 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -93,6 +93,7 @@ TEMPLATE_KEYS = ( SCHEMA_BASE = CLIMATE_PLATFORM_SCHEMA.extend(MQTT_BASE_PLATFORM_SCHEMA.schema) PLATFORM_SCHEMA = SCHEMA_BASE.extend({ + vol.Optional(CONF_RETAIN, default=mqtt.DEFAULT_RETAIN): cv.boolean, vol.Optional(CONF_POWER_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Optional(CONF_MODE_COMMAND_TOPIC): mqtt.valid_publish_topic, vol.Optional(CONF_TEMPERATURE_COMMAND_TOPIC): mqtt.valid_publish_topic, From e14c8c788eacffab30d53083614604d870eee83e Mon Sep 17 00:00:00 2001 From: Ben Randall Date: Thu, 28 Feb 2019 09:27:40 -0800 Subject: [PATCH 016/291] Add PLATFORM_SCHEMA_BASE to telegram_bot component (#21155) --- homeassistant/components/telegram_bot/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/telegram_bot/__init__.py b/homeassistant/components/telegram_bot/__init__.py index c55b27e97a6..78d45535c48 100644 --- a/homeassistant/components/telegram_bot/__init__.py +++ b/homeassistant/components/telegram_bot/__init__.py @@ -84,6 +84,8 @@ PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PROXY_PARAMS): dict, }) +PLATFORM_SCHEMA_BASE = cv.PLATFORM_SCHEMA_BASE.extend(PLATFORM_SCHEMA.schema) + BASE_SERVICE_SCHEMA = vol.Schema({ vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [vol.Coerce(int)]), vol.Optional(ATTR_PARSER): cv.string, From 4f4a8a61d2baf6b8f80e32a7953e5a1de22e52ff Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 28 Feb 2019 10:00:17 -0800 Subject: [PATCH 017/291] Only use a single store instance (#21521) --- homeassistant/components/frontend/storage.py | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/frontend/storage.py b/homeassistant/components/frontend/storage.py index f01abc79e8e..17aae14c820 100644 --- a/homeassistant/components/frontend/storage.py +++ b/homeassistant/components/frontend/storage.py @@ -11,7 +11,7 @@ STORAGE_KEY_USER_DATA = 'frontend.user_data_{}' async def async_setup_frontend_storage(hass): """Set up frontend storage.""" - hass.data[DATA_STORAGE] = {} + hass.data[DATA_STORAGE] = ({}, {}) hass.components.websocket_api.async_register_command( websocket_set_user_data ) @@ -25,12 +25,16 @@ def with_store(orig_func): @wraps(orig_func) async def with_store_func(hass, connection, msg): """Provide user specific data and store to function.""" - store = hass.helpers.storage.Store( - STORAGE_VERSION_USER_DATA, - STORAGE_KEY_USER_DATA.format(connection.user.id) - ) - data = hass.data[DATA_STORAGE] + stores, data = hass.data[DATA_STORAGE] user_id = connection.user.id + store = stores.get(user_id) + + if store is None: + store = stores[user_id] = hass.helpers.storage.Store( + STORAGE_VERSION_USER_DATA, + STORAGE_KEY_USER_DATA.format(connection.user.id) + ) + if user_id not in data: data[user_id] = await store.async_load() or {} From 5ce4fe65b29048c2356725d4d20d1daa641d192f Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Thu, 28 Feb 2019 10:01:10 -0800 Subject: [PATCH 018/291] Allow skip-pip applied to HA core (#21527) --- homeassistant/bootstrap.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index ca01610bcf9..eef36b026e1 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -85,6 +85,11 @@ async def async_from_config_dict(config: Dict[str, Any], async_enable_logging(hass, verbose, log_rotate_days, log_file, log_no_color) + hass.config.skip_pip = skip_pip + if skip_pip: + _LOGGER.warning("Skipping pip installation of required modules. " + "This may cause issues") + core_config = config.get(core.DOMAIN, {}) has_api_password = bool(config.get('http', {}).get('api_password')) trusted_networks = config.get('http', {}).get('trusted_networks') @@ -104,11 +109,6 @@ async def async_from_config_dict(config: Dict[str, Any], await hass.async_add_executor_job( conf_util.process_ha_config_upgrade, hass) - hass.config.skip_pip = skip_pip - if skip_pip: - _LOGGER.warning("Skipping pip installation of required modules. " - "This may cause issues") - # Make a copy because we are mutating it. config = OrderedDict(config) From 82bdd9568d1e3f64f4ab13c97371cbb5bde7bb4f Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Thu, 28 Feb 2019 13:04:35 -0500 Subject: [PATCH 019/291] Add direct binding for remotes and lights for ZHA (#21498) * cluster matching and binding apis implement binding callback fix loop fix loops * review comments * use any because it is clearer --- homeassistant/components/zha/api.py | 126 +++++++++++++++++++ homeassistant/components/zha/core/const.py | 1 + homeassistant/components/zha/core/device.py | 12 ++ homeassistant/components/zha/core/gateway.py | 10 +- homeassistant/components/zha/core/helpers.py | 48 ++++++- 5 files changed, 192 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index f0739f9a073..7ef42ef7da7 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -5,6 +5,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/zha/ """ +import asyncio import logging import voluptuous as vol @@ -14,6 +15,7 @@ from .core.const import ( DOMAIN, ATTR_CLUSTER_ID, ATTR_CLUSTER_TYPE, ATTR_ATTRIBUTE, ATTR_VALUE, ATTR_MANUFACTURER, ATTR_COMMAND, ATTR_COMMAND_TYPE, ATTR_ARGS, IN, OUT, CLIENT_COMMANDS, SERVER_COMMANDS, SERVER, NAME, ATTR_ENDPOINT_ID) +from .core.helpers import get_matched_clusters, async_is_bindable_target _LOGGER = logging.getLogger(__name__) @@ -26,11 +28,18 @@ DEVICE_INFO = 'device_info' ATTR_DURATION = 'duration' ATTR_IEEE_ADDRESS = 'ieee_address' ATTR_IEEE = 'ieee' +ATTR_SOURCE_IEEE = 'source_ieee' +ATTR_TARGET_IEEE = 'target_ieee' +BIND_REQUEST = 0x0021 +UNBIND_REQUEST = 0x0022 SERVICE_PERMIT = 'permit' SERVICE_REMOVE = 'remove' SERVICE_SET_ZIGBEE_CLUSTER_ATTRIBUTE = 'set_zigbee_cluster_attribute' SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND = 'issue_zigbee_cluster_command' +SERVICE_DIRECT_ZIGBEE_BIND = 'issue_direct_zigbee_bind' +SERVICE_DIRECT_ZIGBEE_UNBIND = 'issue_direct_zigbee_unbind' +SERVICE_ZIGBEE_BIND = 'service_zigbee_bind' IEEE_SERVICE = 'ieee_based_service' SERVICE_SCHEMAS = { @@ -110,6 +119,26 @@ SCHEMA_WS_CLUSTER_COMMANDS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required(ATTR_CLUSTER_TYPE): str }) +WS_BIND_DEVICE = 'zha/devices/bind' +SCHEMA_WS_BIND_DEVICE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required(TYPE): WS_BIND_DEVICE, + vol.Required(ATTR_SOURCE_IEEE): str, + vol.Required(ATTR_TARGET_IEEE): str +}) + +WS_UNBIND_DEVICE = 'zha/devices/unbind' +SCHEMA_WS_UNBIND_DEVICE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required(TYPE): WS_UNBIND_DEVICE, + vol.Required(ATTR_SOURCE_IEEE): str, + vol.Required(ATTR_TARGET_IEEE): str +}) + +WS_BINDABLE_DEVICES = 'zha/devices/bindable' +SCHEMA_WS_BINDABLE_DEVICES = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ + vol.Required(TYPE): WS_BINDABLE_DEVICES, + vol.Required(ATTR_IEEE): str +}) + def async_load_api(hass, application_controller, zha_gateway): """Set up the web socket API.""" @@ -244,6 +273,103 @@ def async_load_api(hass, application_controller, zha_gateway): SCHEMA_WS_RECONFIGURE_NODE ) + @websocket_api.async_response + async def websocket_get_bindable_devices(hass, connection, msg): + """Directly bind devices.""" + source_ieee = msg[ATTR_IEEE] + source_device = zha_gateway.get_device(source_ieee) + devices = [ + { + **device.device_info + } for device in zha_gateway.devices.values() if + async_is_bindable_target(source_device, device) + ] + + _LOGGER.debug("Get bindable devices: %s %s", + "{}: [{}]".format(ATTR_SOURCE_IEEE, source_ieee), + "{}: [{}]".format('bindable devices:', devices) + ) + + connection.send_message(websocket_api.result_message( + msg[ID], + devices + )) + + hass.components.websocket_api.async_register_command( + WS_BINDABLE_DEVICES, websocket_get_bindable_devices, + SCHEMA_WS_BINDABLE_DEVICES + ) + + @websocket_api.async_response + async def websocket_bind_devices(hass, connection, msg): + """Directly bind devices.""" + source_ieee = msg[ATTR_SOURCE_IEEE] + target_ieee = msg[ATTR_TARGET_IEEE] + await async_binding_operation( + source_ieee, target_ieee, BIND_REQUEST) + _LOGGER.info("Issue bind devices: %s %s", + "{}: [{}]".format(ATTR_SOURCE_IEEE, source_ieee), + "{}: [{}]".format(ATTR_TARGET_IEEE, target_ieee) + ) + + hass.components.websocket_api.async_register_command( + WS_BIND_DEVICE, websocket_bind_devices, + SCHEMA_WS_BIND_DEVICE + ) + + @websocket_api.async_response + async def websocket_unbind_devices(hass, connection, msg): + """Remove a direct binding between devices.""" + source_ieee = msg[ATTR_SOURCE_IEEE] + target_ieee = msg[ATTR_TARGET_IEEE] + await async_binding_operation( + source_ieee, target_ieee, UNBIND_REQUEST) + _LOGGER.info("Issue unbind devices: %s %s", + "{}: [{}]".format(ATTR_SOURCE_IEEE, source_ieee), + "{}: [{}]".format(ATTR_TARGET_IEEE, target_ieee) + ) + + hass.components.websocket_api.async_register_command( + WS_UNBIND_DEVICE, websocket_unbind_devices, + SCHEMA_WS_UNBIND_DEVICE + ) + + async def async_binding_operation(source_ieee, target_ieee, + operation): + """Create or remove a direct zigbee binding between 2 devices.""" + from zigpy.zdo import types as zdo_types + source_device = zha_gateway.get_device(source_ieee) + target_device = zha_gateway.get_device(target_ieee) + + clusters_to_bind = await get_matched_clusters(source_device, + target_device) + + bind_tasks = [] + for cluster_pair in clusters_to_bind: + destination_address = zdo_types.MultiAddress() + destination_address.addrmode = 3 + destination_address.ieee = target_device.ieee + destination_address.endpoint = \ + cluster_pair.target_cluster.endpoint.endpoint_id + + zdo = cluster_pair.source_cluster.endpoint.device.zdo + + _LOGGER.debug("processing binding operation for: %s %s %s", + "{}: [{}]".format(ATTR_SOURCE_IEEE, source_ieee), + "{}: [{}]".format(ATTR_TARGET_IEEE, target_ieee), + "{}: {}".format( + 'cluster', + cluster_pair.source_cluster.cluster_id) + ) + bind_tasks.append(zdo.request( + operation, + source_device.ieee, + cluster_pair.source_cluster.endpoint.endpoint_id, + cluster_pair.source_cluster.cluster_id, + destination_address + )) + await asyncio.gather(*bind_tasks) + @websocket_api.async_response async def websocket_device_clusters(hass, connection, msg): """Return a list of device clusters.""" diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index d1001682c7b..3c8adb09748 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -114,6 +114,7 @@ CUSTOM_CLUSTER_MAPPINGS = {} COMPONENT_CLUSTERS = {} EVENT_RELAY_CLUSTERS = [] NO_SENSOR_CLUSTERS = [] +BINDABLE_CLUSTERS = [] REPORT_CONFIG_MAX_INT = 900 REPORT_CONFIG_MAX_INT_BATTERY_SAVE = 10800 diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 102c9bed2d3..182a08357b6 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -242,6 +242,18 @@ class ZHADevice: if ep_id != 0 } + @callback + def async_get_zha_clusters(self): + """Get zigbee home automation clusters for this device.""" + from zigpy.profiles.zha import PROFILE_ID + return { + ep_id: { + IN: endpoint.in_clusters, + OUT: endpoint.out_clusters + } for (ep_id, endpoint) in self._zigpy_device.endpoints.items() + if ep_id != 0 and endpoint.profile_id == PROFILE_ID + } + @callback def async_get_cluster(self, endpoint_id, cluster_id, cluster_type=IN): """Get zigbee cluster from this entity.""" diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 563543fa4bd..35a79311253 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -18,11 +18,11 @@ from .const import ( ZHA_DISCOVERY_NEW, DEVICE_CLASS, SINGLE_INPUT_CLUSTER_DEVICE_CLASS, SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS, COMPONENT_CLUSTERS, HUMIDITY, TEMPERATURE, ILLUMINANCE, PRESSURE, METERING, ELECTRICAL_MEASUREMENT, - GENERIC, SENSOR_TYPE, EVENT_RELAY_CLUSTERS, UNKNOWN, - OPENING, ZONE, OCCUPANCY, CLUSTER_REPORT_CONFIGS, REPORT_CONFIG_IMMEDIATE, + GENERIC, SENSOR_TYPE, EVENT_RELAY_CLUSTERS, UNKNOWN, OPENING, ZONE, + OCCUPANCY, CLUSTER_REPORT_CONFIGS, REPORT_CONFIG_IMMEDIATE, REPORT_CONFIG_ASAP, REPORT_CONFIG_DEFAULT, REPORT_CONFIG_MIN_INT, - REPORT_CONFIG_MAX_INT, REPORT_CONFIG_OP, SIGNAL_REMOVE, NO_SENSOR_CLUSTERS, - POWER_CONFIGURATION_CHANNEL) + REPORT_CONFIG_MAX_INT, REPORT_CONFIG_OP, SIGNAL_REMOVE, + NO_SENSOR_CLUSTERS, POWER_CONFIGURATION_CHANNEL, BINDABLE_CLUSTERS) from .device import ZHADevice, DeviceStatus from ..device_entity import ZhaDeviceEntity from .channels import ( @@ -450,6 +450,8 @@ def establish_device_mappings(): NO_SENSOR_CLUSTERS.append(zcl.clusters.general.Basic.cluster_id) NO_SENSOR_CLUSTERS.append( zcl.clusters.general.PowerConfiguration.cluster_id) + BINDABLE_CLUSTERS.append(zcl.clusters.general.LevelControl.cluster_id) + BINDABLE_CLUSTERS.append(zcl.clusters.general.OnOff.cluster_id) DEVICE_CLASS[zha.PROFILE_ID].update({ zha.DeviceType.ON_OFF_SWITCH: 'binary_sensor', diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index 643e44ada1b..d6e9cc32338 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -5,14 +5,19 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/zha/ """ import asyncio +import collections import logging from concurrent.futures import TimeoutError as Timeout +from homeassistant.core import callback from .const import ( DEFAULT_BAUDRATE, REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT, - REPORT_CONFIG_RPT_CHANGE, RadioType) + REPORT_CONFIG_RPT_CHANGE, RadioType, IN, OUT, BINDABLE_CLUSTERS) _LOGGER = logging.getLogger(__name__) +ClusterPair = collections.namedtuple( + 'ClusterPair', 'source_cluster target_cluster') + async def safe_read(cluster, attributes, allow_cache=True, only_cache=False, manufacturer=None): @@ -157,3 +162,44 @@ def get_attr_id_by_name(cluster, attr_name): """Get the attribute id for a cluster attribute by its name.""" return next((attrid for attrid, (attrname, datatype) in cluster.attributes.items() if attr_name == attrname), None) + + +async def get_matched_clusters(source_zha_device, target_zha_device): + """Get matched input/output cluster pairs for 2 devices.""" + source_clusters = source_zha_device.async_get_zha_clusters() + target_clusters = target_zha_device.async_get_zha_clusters() + clusters_to_bind = [] + + for endpoint_id in source_clusters: + for cluster_id in source_clusters[endpoint_id][OUT]: + if cluster_id not in BINDABLE_CLUSTERS: + continue + for t_endpoint_id in target_clusters: + if cluster_id in target_clusters[t_endpoint_id][IN]: + cluster_pair = ClusterPair( + source_cluster=source_clusters[ + endpoint_id][OUT][cluster_id], + target_cluster=target_clusters[ + t_endpoint_id][IN][cluster_id] + ) + clusters_to_bind.append(cluster_pair) + return clusters_to_bind + + +@callback +def async_is_bindable_target(source_zha_device, target_zha_device): + """Determine if target is bindable to source.""" + source_clusters = source_zha_device.async_get_zha_clusters() + target_clusters = target_zha_device.async_get_zha_clusters() + + bindables = set(BINDABLE_CLUSTERS) + for endpoint_id in source_clusters: + for t_endpoint_id in target_clusters: + matches = set( + source_clusters[endpoint_id][OUT].keys() + ).intersection( + target_clusters[t_endpoint_id][IN].keys() + ) + if any(bindable in bindables for bindable in matches): + return True + return False From 84b84559a4f157f3f30ccbe6de5e4347b8d54789 Mon Sep 17 00:00:00 2001 From: cpopp Date: Thu, 28 Feb 2019 12:09:04 -0600 Subject: [PATCH 020/291] Add support for homekit controller sensors (#21535) Adds support for homekit devices with temperature, humidity, and light level characteristics (such as the iHome iSS50) --- .../components/homekit_controller/__init__.py | 3 + .../components/homekit_controller/sensor.py | 153 ++++++++++++++++++ tests/components/homekit_controller/common.py | 7 +- .../homekit_controller/test_sensor.py | 79 +++++++++ 4 files changed, 240 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/homekit_controller/sensor.py create mode 100644 tests/components/homekit_controller/test_sensor.py diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index eb748a3d883..e4bfdc24ffb 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -25,6 +25,9 @@ HOMEKIT_ACCESSORY_DISPATCH = { 'window-covering': 'cover', 'lock-mechanism': 'lock', 'motion': 'binary_sensor', + 'humidity': 'sensor', + 'light': 'sensor', + 'temperature': 'sensor' } HOMEKIT_IGNORE = [ diff --git a/homeassistant/components/homekit_controller/sensor.py b/homeassistant/components/homekit_controller/sensor.py new file mode 100644 index 00000000000..5af0016eb16 --- /dev/null +++ b/homeassistant/components/homekit_controller/sensor.py @@ -0,0 +1,153 @@ +"""Support for Homekit sensors.""" +from homeassistant.components.homekit_controller import ( + KNOWN_ACCESSORIES, HomeKitEntity) +from homeassistant.const import TEMP_CELSIUS + +DEPENDENCIES = ['homekit_controller'] + +HUMIDITY_ICON = 'mdi-water-percent' +TEMP_C_ICON = "mdi-temperature-celsius" +BRIGHTNESS_ICON = "mdi-brightness-6" + +UNIT_PERCENT = "%" +UNIT_LUX = "lux" + + +def setup_platform(hass, config, add_entities, discovery_info=None): + """Set up Homekit sensor support.""" + if discovery_info is not None: + accessory = hass.data[KNOWN_ACCESSORIES][discovery_info['serial']] + devtype = discovery_info['device-type'] + + if devtype == 'humidity': + add_entities( + [HomeKitHumiditySensor(accessory, discovery_info)], True) + elif devtype == 'temperature': + add_entities( + [HomeKitTemperatureSensor(accessory, discovery_info)], True) + elif devtype == 'light': + add_entities( + [HomeKitLightSensor(accessory, discovery_info)], True) + + +class HomeKitHumiditySensor(HomeKitEntity): + """Representation of a Homekit humidity sensor.""" + + def __init__(self, *args): + """Initialise the entity.""" + super().__init__(*args) + self._state = None + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + # pylint: disable=import-error + from homekit.model.characteristics import CharacteristicsTypes + + return [ + CharacteristicsTypes.RELATIVE_HUMIDITY_CURRENT + ] + + @property + def name(self): + """Return the name of the device.""" + return "{} {}".format(super().name, "Humidity") + + @property + def icon(self): + """Return the sensor icon.""" + return HUMIDITY_ICON + + @property + def unit_of_measurement(self): + """Return units for the sensor.""" + return UNIT_PERCENT + + def _update_relative_humidity_current(self, value): + self._state = value + + @property + def state(self): + """Return the current humidity.""" + return self._state + + +class HomeKitTemperatureSensor(HomeKitEntity): + """Representation of a Homekit temperature sensor.""" + + def __init__(self, *args): + """Initialise the entity.""" + super().__init__(*args) + self._state = None + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + # pylint: disable=import-error + from homekit.model.characteristics import CharacteristicsTypes + + return [ + CharacteristicsTypes.TEMPERATURE_CURRENT + ] + + @property + def name(self): + """Return the name of the device.""" + return "{} {}".format(super().name, "Temperature") + + @property + def icon(self): + """Return the sensor icon.""" + return TEMP_C_ICON + + @property + def unit_of_measurement(self): + """Return units for the sensor.""" + return TEMP_CELSIUS + + def _update_temperature_current(self, value): + self._state = value + + @property + def state(self): + """Return the current temperature in Celsius.""" + return self._state + + +class HomeKitLightSensor(HomeKitEntity): + """Representation of a Homekit light level sensor.""" + + def __init__(self, *args): + """Initialise the entity.""" + super().__init__(*args) + self._state = None + + def get_characteristic_types(self): + """Define the homekit characteristics the entity is tracking.""" + # pylint: disable=import-error + from homekit.model.characteristics import CharacteristicsTypes + + return [ + CharacteristicsTypes.LIGHT_LEVEL_CURRENT + ] + + @property + def name(self): + """Return the name of the device.""" + return "{} {}".format(super().name, "Light Level") + + @property + def icon(self): + """Return the sensor icon.""" + return BRIGHTNESS_ICON + + @property + def unit_of_measurement(self): + """Return units for the sensor.""" + return UNIT_LUX + + def _update_light_level_current(self, value): + self._state = value + + @property + def state(self): + """Return the current light level in lux.""" + return self._state diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index d543cf51749..9409e3affad 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -134,10 +134,12 @@ class FakeService(AbstractService): return char -async def setup_test_component(hass, services, capitalize=False): +async def setup_test_component(hass, services, capitalize=False, suffix=None): """Load a fake homekit accessory based on a homekit accessory model. If capitalize is True, property names will be in upper case. + + If suffix is set, entityId will include the suffix """ domain = None for service in services: @@ -174,4 +176,5 @@ async def setup_test_component(hass, services, capitalize=False): fire_service_discovered(hass, SERVICE_HOMEKIT, discovery_info) await hass.async_block_till_done() - return Helper(hass, '.'.join((domain, 'testdevice')), pairing, accessory) + entity = 'testdevice' if suffix is None else 'testdevice_{}'.format(suffix) + return Helper(hass, '.'.join((domain, entity)), pairing, accessory) diff --git a/tests/components/homekit_controller/test_sensor.py b/tests/components/homekit_controller/test_sensor.py new file mode 100644 index 00000000000..c4311926636 --- /dev/null +++ b/tests/components/homekit_controller/test_sensor.py @@ -0,0 +1,79 @@ +"""Basic checks for HomeKit sensor.""" +from tests.components.homekit_controller.common import ( + FakeService, setup_test_component) + +TEMPERATURE = ('temperature', 'temperature.current') +HUMIDITY = ('humidity', 'relative-humidity.current') +LIGHT_LEVEL = ('light', 'light-level.current') + + +def create_temperature_sensor_service(): + """Define temperature characteristics.""" + service = FakeService('public.hap.service.sensor.temperature') + + cur_state = service.add_characteristic('temperature.current') + cur_state.value = 0 + + return service + + +def create_humidity_sensor_service(): + """Define humidity characteristics.""" + service = FakeService('public.hap.service.sensor.humidity') + + cur_state = service.add_characteristic('relative-humidity.current') + cur_state.value = 0 + + return service + + +def create_light_level_sensor_service(): + """Define light level characteristics.""" + service = FakeService('public.hap.service.sensor.light') + + cur_state = service.add_characteristic('light-level.current') + cur_state.value = 0 + + return service + + +async def test_temperature_sensor_read_state(hass, utcnow): + """Test reading the state of a HomeKit temperature sensor accessory.""" + sensor = create_temperature_sensor_service() + helper = await setup_test_component(hass, [sensor], suffix="temperature") + + helper.characteristics[TEMPERATURE].value = 10 + state = await helper.poll_and_get_state() + assert state.state == '10' + + helper.characteristics[TEMPERATURE].value = 20 + state = await helper.poll_and_get_state() + assert state.state == '20' + + +async def test_humidity_sensor_read_state(hass, utcnow): + """Test reading the state of a HomeKit humidity sensor accessory.""" + sensor = create_humidity_sensor_service() + helper = await setup_test_component(hass, [sensor], suffix="humidity") + + helper.characteristics[HUMIDITY].value = 10 + state = await helper.poll_and_get_state() + assert state.state == '10' + + helper.characteristics[HUMIDITY].value = 20 + state = await helper.poll_and_get_state() + assert state.state == '20' + + +async def test_light_level_sensor_read_state(hass, utcnow): + """Test reading the state of a HomeKit temperature sensor accessory.""" + sensor = create_light_level_sensor_service() + helper = await setup_test_component(hass, [sensor], suffix="light_level") + + helper.characteristics[LIGHT_LEVEL].value = 10 + state = await helper.poll_and_get_state() + assert state.state == '10' + + helper.characteristics[LIGHT_LEVEL].value = 20 + state = await helper.poll_and_get_state() + assert state.state == '20' From b18b1cffff8ef5bdcc791999a73e012353937c2e Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Thu, 28 Feb 2019 10:10:21 -0800 Subject: [PATCH 021/291] Fix warning (#21538) --- homeassistant/components/http/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index f57068081a5..4928ae2ab17 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -55,6 +55,9 @@ NO_LOGIN_ATTEMPT_THRESHOLD = -1 def trusted_networks_deprecated(value): """Warn user trusted_networks config is deprecated.""" + if not value: + return value + _LOGGER.warning( "Configuring trusted_networks via the http component has been" " deprecated. Use the trusted networks auth provider instead." From 193cab4f6229fb9b0860c5545fdbc736b9347466 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Thu, 28 Feb 2019 19:25:31 +0100 Subject: [PATCH 022/291] Improve new Sonos snapshot/restore (#21509) * Fine-tune new Sonos snapshot/restore * Move into class --- .../components/sonos/media_player.py | 145 ++++++++++-------- tests/components/sonos/test_media_player.py | 4 +- 2 files changed, 86 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 4a02cf2676f..e0f881f723d 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -197,9 +197,11 @@ def _setup_platform(hass, config, add_entities, discovery_info): with hass.data[DATA_SONOS].topology_lock: if service.service == SERVICE_SNAPSHOT: - snapshot(entities, service.data[ATTR_WITH_GROUP]) + SonosEntity.snapshot_multi( + entities, service.data[ATTR_WITH_GROUP]) elif service.service == SERVICE_RESTORE: - restore(entities, service.data[ATTR_WITH_GROUP]) + SonosEntity.restore_multi( + entities, service.data[ATTR_WITH_GROUP]) elif service.service == SERVICE_JOIN: master = [e for e in hass.data[DATA_SONOS].entities if e.entity_id == service.data[ATTR_MASTER]] @@ -357,6 +359,7 @@ class SonosEntity(MediaPlayerDevice): self._favorites = None self._soco_snapshot = None self._snapshot_group = None + self._restore_pending = False self._set_basic_information() @@ -724,6 +727,9 @@ class SonosEntity(MediaPlayerDevice): pass if self.unique_id == coordinator_uid: + if self._restore_pending: + self.restore() + sonos_group = [] for uid in (coordinator_uid, *slave_uids): entity = _get_entity_from_soco_uid(self.hass, uid) @@ -974,6 +980,82 @@ class SonosEntity(MediaPlayerDevice): self.soco.unjoin() self._coordinator = None + @soco_error() + def snapshot(self, with_group): + """Snapshot the state of a player.""" + from pysonos.snapshot import Snapshot + + self._soco_snapshot = Snapshot(self.soco) + self._soco_snapshot.snapshot() + if with_group: + self._snapshot_group = self._sonos_group.copy() + else: + self._snapshot_group = None + + @soco_error() + def restore(self): + """Restore a snapshotted state to a player.""" + from pysonos.exceptions import SoCoException + + try: + # pylint: disable=protected-access + self.soco._zgs_cache.clear() + self._soco_snapshot.restore() + except (TypeError, AttributeError, SoCoException) as ex: + # Can happen if restoring a coordinator onto a current slave + _LOGGER.warning("Error on restore %s: %s", self.entity_id, ex) + + self._soco_snapshot = None + self._snapshot_group = None + self._restore_pending = False + + @staticmethod + def snapshot_multi(entities, with_group): + """Snapshot all the entities and optionally their groups.""" + # pylint: disable=protected-access + # Find all affected players + entities = set(entities) + if with_group: + for entity in list(entities): + entities.update(entity._sonos_group) + + for entity in entities: + entity.snapshot(with_group) + + @staticmethod + def restore_multi(entities, with_group): + """Restore snapshots for all the entities.""" + # pylint: disable=protected-access + # Find all affected players + entities = set(e for e in entities if e._soco_snapshot) + if with_group: + for entity in [e for e in entities if e._snapshot_group]: + entities.update(entity._snapshot_group) + + # Pause all current coordinators + for entity in (e for e in entities if e.is_coordinator): + if entity.state == STATE_PLAYING: + entity.media_pause() + + # Bring back the original group topology + if with_group: + for entity in (e for e in entities if e._snapshot_group): + if entity._snapshot_group[0] == entity: + entity.join(entity._snapshot_group) + + # Restore slaves + for entity in (e for e in entities if not e.is_coordinator): + entity.restore() + + # Restore coordinators (or delay if moving from slave) + for entity in (e for e in entities if e.is_coordinator): + if entity._sonos_group[0] == entity: + # Was already coordinator + entity.restore() + else: + # Await coordinator role + entity._restore_pending = True + @soco_error() @soco_coordinator def set_sleep_timer(self, sleep_time): @@ -1033,62 +1115,3 @@ class SonosEntity(MediaPlayerDevice): attributes[ATTR_SPEECH_ENHANCE] = self._speech_enhance return attributes - - -@soco_error() -def snapshot(entities, with_group): - """Snapshot all the entities and optionally their groups.""" - # pylint: disable=protected-access - from pysonos.snapshot import Snapshot - - # Find all affected players - entities = set(entities) - if with_group: - for entity in list(entities): - entities.update(entity._sonos_group) - - for entity in entities: - entity._soco_snapshot = Snapshot(entity.soco) - entity._soco_snapshot.snapshot() - if with_group: - entity._snapshot_group = entity._sonos_group.copy() - else: - entity._snapshot_group = None - - -@soco_error() -def restore(entities, with_group): - """Restore snapshots for all the entities.""" - # pylint: disable=protected-access - from pysonos.exceptions import SoCoException - - # Find all affected players - entities = set(e for e in entities if e._soco_snapshot) - if with_group: - for entity in [e for e in entities if e._snapshot_group]: - entities.update(entity._snapshot_group) - - # Pause all current coordinators - for entity in (e for e in entities if e.is_coordinator): - if entity.state == STATE_PLAYING: - entity.media_pause() - - # Bring back the original group topology and clear pysonos cache - if with_group: - for entity in (e for e in entities if e._snapshot_group): - if entity._snapshot_group[0] == entity: - entity.join(entity._snapshot_group) - entity.soco._zgs_cache.clear() - - # Restore slaves, then coordinators - slaves = [e for e in entities if not e.is_coordinator] - coordinators = [e for e in entities if e.is_coordinator] - for entity in slaves + coordinators: - try: - entity._soco_snapshot.restore() - except (TypeError, AttributeError, SoCoException) as ex: - # Can happen if restoring a coordinator onto a current slave - _LOGGER.warning("Error on restore %s: %s", entity.entity_id, ex) - - entity._soco_snapshot = None - entity._snapshot_group = None diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 55743c4f843..798c92eddad 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -328,7 +328,7 @@ class TestSonosMediaPlayer(unittest.TestCase): snapshotMock.return_value = True entity.soco.group = mock.MagicMock() entity.soco.group.members = [e.soco for e in entities] - sonos.snapshot(entities, True) + sonos.SonosEntity.snapshot_multi(entities, True) assert snapshotMock.call_count == 1 assert snapshotMock.call_args == mock.call() @@ -350,6 +350,6 @@ class TestSonosMediaPlayer(unittest.TestCase): entity._snapshot_group = mock.MagicMock() entity._snapshot_group.members = [e.soco for e in entities] entity._soco_snapshot = Snapshot(entity.soco) - sonos.restore(entities, True) + sonos.SonosEntity.restore_multi(entities, True) assert restoreMock.call_count == 1 assert restoreMock.call_args == mock.call() From 81dd2acf3b0b0cfad090ef020a789b2909c9286f Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Thu, 28 Feb 2019 22:16:51 +0000 Subject: [PATCH 023/291] Update CODEOWNERS (#21545) add myself to camera.push --- CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/CODEOWNERS b/CODEOWNERS index ac8f98a11b0..d15fd85cb52 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -51,6 +51,7 @@ homeassistant/components/alarm_control_panel/manual_mqtt.py @colinodell homeassistant/components/binary_sensor/hikvision.py @mezz64 homeassistant/components/binary_sensor/threshold.py @fabaff homeassistant/components/binary_sensor/uptimerobot.py @ludeeus +homeassistant/components/camera/push.py @dgomes homeassistant/components/camera/yi.py @bachya homeassistant/components/climate/coolmaster.py @OnFreund homeassistant/components/climate/ephember.py @ttroy50 From 8ebe5c61e83d17da968a633f6419fd69ed5b1567 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 28 Feb 2019 15:17:10 -0700 Subject: [PATCH 024/291] Fix incorrect pyairvisual call (#21542) --- homeassistant/components/sensor/airvisual.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/airvisual.py b/homeassistant/components/sensor/airvisual.py index e13fb924041..b9e7a3315e3 100644 --- a/homeassistant/components/sensor/airvisual.py +++ b/homeassistant/components/sensor/airvisual.py @@ -278,11 +278,11 @@ class AirVisualData: try: if self.city and self.state and self.country: - resp = await self._client.data.city( + resp = await self._client.api.city( self.city, self.state, self.country) self.longitude, self.latitude = resp['location']['coordinates'] else: - resp = await self._client.data.nearest_city( + resp = await self._client.api.nearest_city( self.latitude, self.longitude) _LOGGER.debug("New data retrieved: %s", resp) From 40d7fbcda45d823ff7a3f3095fa2cd5a04adade1 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Thu, 28 Feb 2019 23:17:53 +0100 Subject: [PATCH 025/291] Clean up gpslogger tests (#21543) --- tests/components/gpslogger/test_init.py | 59 +++++++++++++++---------- 1 file changed, 35 insertions(+), 24 deletions(-) diff --git a/tests/components/gpslogger/test_init.py b/tests/components/gpslogger/test_init.py index db1ae655c25..577da5f33e6 100644 --- a/tests/components/gpslogger/test_init.py +++ b/tests/components/gpslogger/test_init.py @@ -1,22 +1,23 @@ """The tests the for GPSLogger device tracker platform.""" -from unittest.mock import patch, Mock +from unittest.mock import Mock, patch import pytest -from homeassistant.helpers.dispatcher import DATA_DISPATCHER from homeassistant import data_entry_flow -from homeassistant.components import zone, gpslogger -from homeassistant.components.device_tracker import \ - DOMAIN as DEVICE_TRACKER_DOMAIN +from homeassistant.components import gpslogger, zone +from homeassistant.components.device_tracker import ( + DOMAIN as DEVICE_TRACKER_DOMAIN) from homeassistant.components.gpslogger import DOMAIN, TRACKER_UPDATE -from homeassistant.const import HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, \ - STATE_HOME, STATE_NOT_HOME, CONF_WEBHOOK_ID +from homeassistant.const import ( + HTTP_OK, HTTP_UNPROCESSABLE_ENTITY, STATE_HOME, STATE_NOT_HOME) +from homeassistant.helpers.dispatcher import DATA_DISPATCHER from homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry HOME_LATITUDE = 37.239622 HOME_LONGITUDE = -115.815811 +# pylint: disable=redefined-outer-name + @pytest.fixture(autouse=True) def mock_dev_track(mock_device_tracker_conf): @@ -158,29 +159,39 @@ async def test_enter_with_attrs(hass, gpslogger_client, webhook_id): assert req.status == HTTP_OK state = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, data['device'])) - assert STATE_NOT_HOME == state.state - assert 10.5 == state.attributes['gps_accuracy'] - assert 10.0 == state.attributes['battery'] - assert 100.0 == state.attributes['speed'] - assert 105.32 == state.attributes['direction'] - assert 102.0 == state.attributes['altitude'] - assert 'gps' == state.attributes['provider'] - assert 'running' == state.attributes['activity'] + assert state.state == STATE_NOT_HOME + assert state.attributes['gps_accuracy'] == 10.5 + assert state.attributes['battery'] == 10.0 + assert state.attributes['speed'] == 100.0 + assert state.attributes['direction'] == 105.32 + assert state.attributes['altitude'] == 102.0 + assert state.attributes['provider'] == 'gps' + assert state.attributes['activity'] == 'running' @pytest.mark.xfail( reason='The device_tracker component does not support unloading yet.' ) -async def test_load_unload_entry(hass): +async def test_load_unload_entry(hass, gpslogger_client, webhook_id): """Test that the appropriate dispatch signals are added and removed.""" - entry = MockConfigEntry(domain=DOMAIN, data={ - CONF_WEBHOOK_ID: 'gpslogger_test' - }) + url = '/api/webhook/{}'.format(webhook_id) + data = { + 'latitude': HOME_LATITUDE, + 'longitude': HOME_LONGITUDE, + 'device': '123', + } - await gpslogger.async_setup_entry(hass, entry) + # Enter the Home + req = await gpslogger_client.post(url, data=data) await hass.async_block_till_done() - assert 1 == len(hass.data[DATA_DISPATCHER][TRACKER_UPDATE]) + assert req.status == HTTP_OK + state_name = hass.states.get('{}.{}'.format(DEVICE_TRACKER_DOMAIN, + data['device'])).state + assert STATE_HOME == state_name + assert len(hass.data[DATA_DISPATCHER][TRACKER_UPDATE]) == 1 - await gpslogger.async_unload_entry(hass, entry) + entry = hass.config_entries.async_entries(DOMAIN)[0] + + assert await gpslogger.async_unload_entry(hass, entry) await hass.async_block_till_done() - assert 0 == len(hass.data[DATA_DISPATCHER][TRACKER_UPDATE]) + assert not hass.data[DATA_DISPATCHER][TRACKER_UPDATE] From b8a94c30e8ae11c0d59d1725d32a623c736181eb Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 28 Feb 2019 15:28:20 -0700 Subject: [PATCH 026/291] Add watchdog to Ambient PWS (#21507) * Add watchdog to Ambient PWS * Better labeling * Owner comments --- .../components/ambient_station/__init__.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index a8e1c7fb292..70f6ce9fbba 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -27,6 +27,7 @@ _LOGGER = logging.getLogger(__name__) DATA_CONFIG = 'config' DEFAULT_SOCKET_MIN_RETRY = 15 +DEFAULT_WATCHDOG_SECONDS = 5 * 60 TYPE_24HOURRAININ = '24hourrainin' TYPE_BAROMABSIN = 'baromabsin' @@ -296,6 +297,7 @@ class AmbientStation: """Initialize.""" self._config_entry = config_entry self._hass = hass + self._watchdog_listener = None self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY self.client = client self.monitored_conditions = monitored_conditions @@ -305,9 +307,18 @@ class AmbientStation: """Register handlers and connect to the websocket.""" from aioambient.errors import WebsocketError + async def _ws_reconnect(event_time): + """Forcibly disconnect from and reconnect to the websocket.""" + _LOGGER.debug('Watchdog expired; forcing socket reconnection') + await self.client.websocket.disconnect() + await self.client.websocket.connect() + def on_connect(): """Define a handler to fire when the websocket is connected.""" _LOGGER.info('Connected to websocket') + _LOGGER.debug('Watchdog starting') + self._watchdog_listener = async_call_later( + self._hass, DEFAULT_WATCHDOG_SECONDS, _ws_reconnect) def on_data(data): """Define a handler to fire when the data is received.""" @@ -317,6 +328,11 @@ class AmbientStation: self.stations[mac_address][ATTR_LAST_DATA] = data async_dispatcher_send(self._hass, TOPIC_UPDATE) + _LOGGER.debug('Resetting watchdog') + self._watchdog_listener() + self._watchdog_listener = async_call_later( + self._hass, DEFAULT_WATCHDOG_SECONDS, _ws_reconnect) + def on_disconnect(): """Define a handler to fire when the websocket is disconnected.""" _LOGGER.info('Disconnected from websocket') From 901b2b4ba329b3806966b36888e7f221fe70b314 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Thu, 28 Feb 2019 19:32:41 -0500 Subject: [PATCH 027/291] new websocket api way (#21533) --- homeassistant/components/zha/api.py | 633 +++++++++---------- homeassistant/components/zha/core/const.py | 1 + homeassistant/components/zha/core/gateway.py | 4 +- tests/components/zha/test_api.py | 12 +- 4 files changed, 302 insertions(+), 348 deletions(-) diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index 7ef42ef7da7..df6b6591bac 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -14,7 +14,8 @@ import homeassistant.helpers.config_validation as cv from .core.const import ( DOMAIN, ATTR_CLUSTER_ID, ATTR_CLUSTER_TYPE, ATTR_ATTRIBUTE, ATTR_VALUE, ATTR_MANUFACTURER, ATTR_COMMAND, ATTR_COMMAND_TYPE, ATTR_ARGS, IN, OUT, - CLIENT_COMMANDS, SERVER_COMMANDS, SERVER, NAME, ATTR_ENDPOINT_ID) + CLIENT_COMMANDS, SERVER_COMMANDS, SERVER, NAME, ATTR_ENDPOINT_ID, + DATA_ZHA_GATEWAY, DATA_ZHA) from .core.helpers import get_matched_clusters, async_is_bindable_target _LOGGER = logging.getLogger(__name__) @@ -71,73 +72,308 @@ SERVICE_SCHEMAS = { }), } -WS_RECONFIGURE_NODE = 'zha/devices/reconfigure' -SCHEMA_WS_RECONFIGURE_NODE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required(TYPE): WS_RECONFIGURE_NODE, + +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required(TYPE): 'zha/devices' +}) +async def websocket_get_devices(hass, connection, msg): + """Get ZHA devices.""" + zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + devices = [ + { + **device.device_info, + 'entities': [{ + 'entity_id': entity_ref.reference_id, + NAME: entity_ref.device_info[NAME] + } for entity_ref in zha_gateway.device_registry[device.ieee]] + } for device in zha_gateway.devices.values() + ] + + connection.send_result(msg[ID], devices) + + +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required(TYPE): 'zha/devices/reconfigure', vol.Required(ATTR_IEEE): str }) +async def websocket_reconfigure_node(hass, connection, msg): + """Reconfigure a ZHA nodes entities by its ieee address.""" + zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee = msg[ATTR_IEEE] + device = zha_gateway.get_device(ieee) + _LOGGER.debug("Reconfiguring node with ieee_address: %s", ieee) + hass.async_create_task(device.async_configure()) -WS_DEVICES = 'zha/devices' -SCHEMA_WS_LIST_DEVICES = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required(TYPE): WS_DEVICES, -}) -WS_DEVICE_CLUSTERS = 'zha/devices/clusters' -SCHEMA_WS_CLUSTERS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required(TYPE): WS_DEVICE_CLUSTERS, +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required(TYPE): 'zha/devices/clusters', vol.Required(ATTR_IEEE): str }) +async def websocket_device_clusters(hass, connection, msg): + """Return a list of device clusters.""" + zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee = msg[ATTR_IEEE] + zha_device = zha_gateway.get_device(ieee) + response_clusters = [] + if zha_device is not None: + clusters_by_endpoint = zha_device.async_get_clusters() + for ep_id, clusters in clusters_by_endpoint.items(): + for c_id, cluster in clusters[IN].items(): + response_clusters.append({ + TYPE: IN, + ID: c_id, + NAME: cluster.__class__.__name__, + 'endpoint_id': ep_id + }) + for c_id, cluster in clusters[OUT].items(): + response_clusters.append({ + TYPE: OUT, + ID: c_id, + NAME: cluster.__class__.__name__, + 'endpoint_id': ep_id + }) -WS_DEVICE_CLUSTER_ATTRIBUTES = 'zha/devices/clusters/attributes' -SCHEMA_WS_CLUSTER_ATTRIBUTES = \ - websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required(TYPE): WS_DEVICE_CLUSTER_ATTRIBUTES, - vol.Required(ATTR_IEEE): str, - vol.Required(ATTR_ENDPOINT_ID): int, - vol.Required(ATTR_CLUSTER_ID): int, - vol.Required(ATTR_CLUSTER_TYPE): str - }) + connection.send_result(msg[ID], response_clusters) -WS_READ_CLUSTER_ATTRIBUTE = 'zha/devices/clusters/attributes/value' -SCHEMA_WS_READ_CLUSTER_ATTRIBUTE = \ - websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required(TYPE): WS_READ_CLUSTER_ATTRIBUTE, - vol.Required(ATTR_IEEE): str, - vol.Required(ATTR_ENDPOINT_ID): int, - vol.Required(ATTR_CLUSTER_ID): int, - vol.Required(ATTR_CLUSTER_TYPE): str, - vol.Required(ATTR_ATTRIBUTE): int, - vol.Optional(ATTR_MANUFACTURER): object, - }) -WS_DEVICE_CLUSTER_COMMANDS = 'zha/devices/clusters/commands' -SCHEMA_WS_CLUSTER_COMMANDS = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required(TYPE): WS_DEVICE_CLUSTER_COMMANDS, +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required(TYPE): 'zha/devices/clusters/attributes', vol.Required(ATTR_IEEE): str, vol.Required(ATTR_ENDPOINT_ID): int, vol.Required(ATTR_CLUSTER_ID): int, vol.Required(ATTR_CLUSTER_TYPE): str }) +async def websocket_device_cluster_attributes(hass, connection, msg): + """Return a list of cluster attributes.""" + zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee = msg[ATTR_IEEE] + endpoint_id = msg[ATTR_ENDPOINT_ID] + cluster_id = msg[ATTR_CLUSTER_ID] + cluster_type = msg[ATTR_CLUSTER_TYPE] + cluster_attributes = [] + zha_device = zha_gateway.get_device(ieee) + attributes = None + if zha_device is not None: + attributes = zha_device.async_get_cluster_attributes( + endpoint_id, + cluster_id, + cluster_type) + if attributes is not None: + for attr_id in attributes: + cluster_attributes.append( + { + ID: attr_id, + NAME: attributes[attr_id][0] + } + ) + _LOGGER.debug("Requested attributes for: %s %s %s %s", + "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id), + "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type), + "{}: [{}]".format(ATTR_ENDPOINT_ID, endpoint_id), + "{}: [{}]".format(RESPONSE, cluster_attributes) + ) -WS_BIND_DEVICE = 'zha/devices/bind' -SCHEMA_WS_BIND_DEVICE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required(TYPE): WS_BIND_DEVICE, + connection.send_result(msg[ID], cluster_attributes) + + +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required(TYPE): 'zha/devices/clusters/commands', + vol.Required(ATTR_IEEE): str, + vol.Required(ATTR_ENDPOINT_ID): int, + vol.Required(ATTR_CLUSTER_ID): int, + vol.Required(ATTR_CLUSTER_TYPE): str +}) +async def websocket_device_cluster_commands(hass, connection, msg): + """Return a list of cluster commands.""" + zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + cluster_id = msg[ATTR_CLUSTER_ID] + cluster_type = msg[ATTR_CLUSTER_TYPE] + ieee = msg[ATTR_IEEE] + endpoint_id = msg[ATTR_ENDPOINT_ID] + zha_device = zha_gateway.get_device(ieee) + cluster_commands = [] + commands = None + if zha_device is not None: + commands = zha_device.async_get_cluster_commands( + endpoint_id, + cluster_id, + cluster_type) + + if commands is not None: + for cmd_id in commands[CLIENT_COMMANDS]: + cluster_commands.append( + { + TYPE: CLIENT, + ID: cmd_id, + NAME: commands[CLIENT_COMMANDS][cmd_id][0] + } + ) + for cmd_id in commands[SERVER_COMMANDS]: + cluster_commands.append( + { + TYPE: SERVER, + ID: cmd_id, + NAME: commands[SERVER_COMMANDS][cmd_id][0] + } + ) + _LOGGER.debug("Requested commands for: %s %s %s %s", + "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id), + "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type), + "{}: [{}]".format(ATTR_ENDPOINT_ID, endpoint_id), + "{}: [{}]".format(RESPONSE, cluster_commands) + ) + + connection.send_result(msg[ID], cluster_commands) + + +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required(TYPE): 'zha/devices/clusters/attributes/value', + vol.Required(ATTR_IEEE): str, + vol.Required(ATTR_ENDPOINT_ID): int, + vol.Required(ATTR_CLUSTER_ID): int, + vol.Required(ATTR_CLUSTER_TYPE): str, + vol.Required(ATTR_ATTRIBUTE): int, + vol.Optional(ATTR_MANUFACTURER): object, +}) +async def websocket_read_zigbee_cluster_attributes(hass, connection, msg): + """Read zigbee attribute for cluster on zha entity.""" + zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ieee = msg[ATTR_IEEE] + endpoint_id = msg[ATTR_ENDPOINT_ID] + cluster_id = msg[ATTR_CLUSTER_ID] + cluster_type = msg[ATTR_CLUSTER_TYPE] + attribute = msg[ATTR_ATTRIBUTE] + manufacturer = msg.get(ATTR_MANUFACTURER) or None + zha_device = zha_gateway.get_device(ieee) + success = failure = None + if zha_device is not None: + cluster = zha_device.async_get_cluster( + endpoint_id, cluster_id, cluster_type=cluster_type) + success, failure = await cluster.read_attributes( + [attribute], + allow_cache=False, + only_cache=False, + manufacturer=manufacturer + ) + _LOGGER.debug("Read attribute for: %s %s %s %s %s %s %s", + "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id), + "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type), + "{}: [{}]".format(ATTR_ENDPOINT_ID, endpoint_id), + "{}: [{}]".format(ATTR_ATTRIBUTE, attribute), + "{}: [{}]".format(ATTR_MANUFACTURER, manufacturer), + "{}: [{}]".format(RESPONSE, str(success.get(attribute))), + "{}: [{}]".format('failure', failure) + ) + connection.send_result(msg[ID], str(success.get(attribute))) + + +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required(TYPE): 'zha/devices/bindable', + vol.Required(ATTR_IEEE): str, +}) +async def websocket_get_bindable_devices(hass, connection, msg): + """Directly bind devices.""" + zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee = msg[ATTR_IEEE] + source_device = zha_gateway.get_device(source_ieee) + devices = [ + { + **device.device_info + } for device in zha_gateway.devices.values() if + async_is_bindable_target(source_device, device) + ] + + _LOGGER.debug("Get bindable devices: %s %s", + "{}: [{}]".format(ATTR_SOURCE_IEEE, source_ieee), + "{}: [{}]".format('bindable devices:', devices) + ) + + connection.send_message(websocket_api.result_message( + msg[ID], + devices + )) + + +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required(TYPE): 'zha/devices/bind', vol.Required(ATTR_SOURCE_IEEE): str, vol.Required(ATTR_TARGET_IEEE): str }) +async def websocket_bind_devices(hass, connection, msg): + """Directly bind devices.""" + zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee = msg[ATTR_SOURCE_IEEE] + target_ieee = msg[ATTR_TARGET_IEEE] + await async_binding_operation( + zha_gateway, source_ieee, target_ieee, BIND_REQUEST) + _LOGGER.info("Issue bind devices: %s %s", + "{}: [{}]".format(ATTR_SOURCE_IEEE, source_ieee), + "{}: [{}]".format(ATTR_TARGET_IEEE, target_ieee) + ) -WS_UNBIND_DEVICE = 'zha/devices/unbind' -SCHEMA_WS_UNBIND_DEVICE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required(TYPE): WS_UNBIND_DEVICE, + +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required(TYPE): 'zha/devices/unbind', vol.Required(ATTR_SOURCE_IEEE): str, vol.Required(ATTR_TARGET_IEEE): str }) +async def websocket_unbind_devices(hass, connection, msg): + """Remove a direct binding between devices.""" + zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + source_ieee = msg[ATTR_SOURCE_IEEE] + target_ieee = msg[ATTR_TARGET_IEEE] + await async_binding_operation( + zha_gateway, source_ieee, target_ieee, UNBIND_REQUEST) + _LOGGER.info("Issue unbind devices: %s %s", + "{}: [{}]".format(ATTR_SOURCE_IEEE, source_ieee), + "{}: [{}]".format(ATTR_TARGET_IEEE, target_ieee) + ) -WS_BINDABLE_DEVICES = 'zha/devices/bindable' -SCHEMA_WS_BINDABLE_DEVICES = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required(TYPE): WS_BINDABLE_DEVICES, - vol.Required(ATTR_IEEE): str -}) + +async def async_binding_operation(zha_gateway, source_ieee, target_ieee, + operation): + """Create or remove a direct zigbee binding between 2 devices.""" + from zigpy.zdo import types as zdo_types + source_device = zha_gateway.get_device(source_ieee) + target_device = zha_gateway.get_device(target_ieee) + + clusters_to_bind = await get_matched_clusters(source_device, + target_device) + + bind_tasks = [] + for cluster_pair in clusters_to_bind: + destination_address = zdo_types.MultiAddress() + destination_address.addrmode = 3 + destination_address.ieee = target_device.ieee + destination_address.endpoint = \ + cluster_pair.target_cluster.endpoint.endpoint_id + + zdo = cluster_pair.source_cluster.endpoint.device.zdo + + _LOGGER.debug("processing binding operation for: %s %s %s", + "{}: [{}]".format(ATTR_SOURCE_IEEE, source_ieee), + "{}: [{}]".format(ATTR_TARGET_IEEE, target_ieee), + "{}: {}".format( + 'cluster', + cluster_pair.source_cluster.cluster_id) + ) + bind_tasks.append(zdo.request( + operation, + source_device.ieee, + cluster_pair.source_cluster.endpoint.endpoint_id, + cluster_pair.source_cluster.cluster_id, + destination_address + )) + await asyncio.gather(*bind_tasks) def async_load_api(hass, application_controller, zha_gateway): @@ -237,301 +473,18 @@ def async_load_api(hass, application_controller, zha_gateway): SERVICE_ISSUE_ZIGBEE_CLUSTER_COMMAND ]) - @websocket_api.async_response - async def websocket_get_devices(hass, connection, msg): - """Get ZHA devices.""" - devices = [ - { - **device.device_info, - 'entities': [{ - 'entity_id': entity_ref.reference_id, - NAME: entity_ref.device_info[NAME] - } for entity_ref in zha_gateway.device_registry[device.ieee]] - } for device in zha_gateway.devices.values() - ] - - connection.send_message(websocket_api.result_message( - msg[ID], - devices - )) - - hass.components.websocket_api.async_register_command( - WS_DEVICES, websocket_get_devices, - SCHEMA_WS_LIST_DEVICES - ) - - @websocket_api.async_response - async def websocket_reconfigure_node(hass, connection, msg): - """Reconfigure a ZHA nodes entities by its ieee address.""" - ieee = msg[ATTR_IEEE] - device = zha_gateway.get_device(ieee) - _LOGGER.debug("Reconfiguring node with ieee_address: %s", ieee) - hass.async_create_task(device.async_configure()) - - hass.components.websocket_api.async_register_command( - WS_RECONFIGURE_NODE, websocket_reconfigure_node, - SCHEMA_WS_RECONFIGURE_NODE - ) - - @websocket_api.async_response - async def websocket_get_bindable_devices(hass, connection, msg): - """Directly bind devices.""" - source_ieee = msg[ATTR_IEEE] - source_device = zha_gateway.get_device(source_ieee) - devices = [ - { - **device.device_info - } for device in zha_gateway.devices.values() if - async_is_bindable_target(source_device, device) - ] - - _LOGGER.debug("Get bindable devices: %s %s", - "{}: [{}]".format(ATTR_SOURCE_IEEE, source_ieee), - "{}: [{}]".format('bindable devices:', devices) - ) - - connection.send_message(websocket_api.result_message( - msg[ID], - devices - )) - - hass.components.websocket_api.async_register_command( - WS_BINDABLE_DEVICES, websocket_get_bindable_devices, - SCHEMA_WS_BINDABLE_DEVICES - ) - - @websocket_api.async_response - async def websocket_bind_devices(hass, connection, msg): - """Directly bind devices.""" - source_ieee = msg[ATTR_SOURCE_IEEE] - target_ieee = msg[ATTR_TARGET_IEEE] - await async_binding_operation( - source_ieee, target_ieee, BIND_REQUEST) - _LOGGER.info("Issue bind devices: %s %s", - "{}: [{}]".format(ATTR_SOURCE_IEEE, source_ieee), - "{}: [{}]".format(ATTR_TARGET_IEEE, target_ieee) - ) - - hass.components.websocket_api.async_register_command( - WS_BIND_DEVICE, websocket_bind_devices, - SCHEMA_WS_BIND_DEVICE - ) - - @websocket_api.async_response - async def websocket_unbind_devices(hass, connection, msg): - """Remove a direct binding between devices.""" - source_ieee = msg[ATTR_SOURCE_IEEE] - target_ieee = msg[ATTR_TARGET_IEEE] - await async_binding_operation( - source_ieee, target_ieee, UNBIND_REQUEST) - _LOGGER.info("Issue unbind devices: %s %s", - "{}: [{}]".format(ATTR_SOURCE_IEEE, source_ieee), - "{}: [{}]".format(ATTR_TARGET_IEEE, target_ieee) - ) - - hass.components.websocket_api.async_register_command( - WS_UNBIND_DEVICE, websocket_unbind_devices, - SCHEMA_WS_UNBIND_DEVICE - ) - - async def async_binding_operation(source_ieee, target_ieee, - operation): - """Create or remove a direct zigbee binding between 2 devices.""" - from zigpy.zdo import types as zdo_types - source_device = zha_gateway.get_device(source_ieee) - target_device = zha_gateway.get_device(target_ieee) - - clusters_to_bind = await get_matched_clusters(source_device, - target_device) - - bind_tasks = [] - for cluster_pair in clusters_to_bind: - destination_address = zdo_types.MultiAddress() - destination_address.addrmode = 3 - destination_address.ieee = target_device.ieee - destination_address.endpoint = \ - cluster_pair.target_cluster.endpoint.endpoint_id - - zdo = cluster_pair.source_cluster.endpoint.device.zdo - - _LOGGER.debug("processing binding operation for: %s %s %s", - "{}: [{}]".format(ATTR_SOURCE_IEEE, source_ieee), - "{}: [{}]".format(ATTR_TARGET_IEEE, target_ieee), - "{}: {}".format( - 'cluster', - cluster_pair.source_cluster.cluster_id) - ) - bind_tasks.append(zdo.request( - operation, - source_device.ieee, - cluster_pair.source_cluster.endpoint.endpoint_id, - cluster_pair.source_cluster.cluster_id, - destination_address - )) - await asyncio.gather(*bind_tasks) - - @websocket_api.async_response - async def websocket_device_clusters(hass, connection, msg): - """Return a list of device clusters.""" - ieee = msg[ATTR_IEEE] - zha_device = zha_gateway.get_device(ieee) - response_clusters = [] - if zha_device is not None: - clusters_by_endpoint = zha_device.async_get_clusters() - for ep_id, clusters in clusters_by_endpoint.items(): - for c_id, cluster in clusters[IN].items(): - response_clusters.append({ - TYPE: IN, - ID: c_id, - NAME: cluster.__class__.__name__, - 'endpoint_id': ep_id - }) - for c_id, cluster in clusters[OUT].items(): - response_clusters.append({ - TYPE: OUT, - ID: c_id, - NAME: cluster.__class__.__name__, - 'endpoint_id': ep_id - }) - - connection.send_message(websocket_api.result_message( - msg[ID], - response_clusters - )) - - hass.components.websocket_api.async_register_command( - WS_DEVICE_CLUSTERS, websocket_device_clusters, - SCHEMA_WS_CLUSTERS - ) - - @websocket_api.async_response - async def websocket_device_cluster_attributes(hass, connection, msg): - """Return a list of cluster attributes.""" - ieee = msg[ATTR_IEEE] - endpoint_id = msg[ATTR_ENDPOINT_ID] - cluster_id = msg[ATTR_CLUSTER_ID] - cluster_type = msg[ATTR_CLUSTER_TYPE] - cluster_attributes = [] - zha_device = zha_gateway.get_device(ieee) - attributes = None - if zha_device is not None: - attributes = zha_device.async_get_cluster_attributes( - endpoint_id, - cluster_id, - cluster_type) - if attributes is not None: - for attr_id in attributes: - cluster_attributes.append( - { - ID: attr_id, - NAME: attributes[attr_id][0] - } - ) - _LOGGER.debug("Requested attributes for: %s %s %s %s", - "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id), - "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type), - "{}: [{}]".format(ATTR_ENDPOINT_ID, endpoint_id), - "{}: [{}]".format(RESPONSE, cluster_attributes) - ) - - connection.send_message(websocket_api.result_message( - msg[ID], - cluster_attributes - )) - - hass.components.websocket_api.async_register_command( - WS_DEVICE_CLUSTER_ATTRIBUTES, websocket_device_cluster_attributes, - SCHEMA_WS_CLUSTER_ATTRIBUTES - ) - - @websocket_api.async_response - async def websocket_device_cluster_commands(hass, connection, msg): - """Return a list of cluster commands.""" - cluster_id = msg[ATTR_CLUSTER_ID] - cluster_type = msg[ATTR_CLUSTER_TYPE] - ieee = msg[ATTR_IEEE] - endpoint_id = msg[ATTR_ENDPOINT_ID] - zha_device = zha_gateway.get_device(ieee) - cluster_commands = [] - commands = None - if zha_device is not None: - commands = zha_device.async_get_cluster_commands( - endpoint_id, - cluster_id, - cluster_type) - - if commands is not None: - for cmd_id in commands[CLIENT_COMMANDS]: - cluster_commands.append( - { - TYPE: CLIENT, - ID: cmd_id, - NAME: commands[CLIENT_COMMANDS][cmd_id][0] - } - ) - for cmd_id in commands[SERVER_COMMANDS]: - cluster_commands.append( - { - TYPE: SERVER, - ID: cmd_id, - NAME: commands[SERVER_COMMANDS][cmd_id][0] - } - ) - _LOGGER.debug("Requested commands for: %s %s %s %s", - "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id), - "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type), - "{}: [{}]".format(ATTR_ENDPOINT_ID, endpoint_id), - "{}: [{}]".format(RESPONSE, cluster_commands) - ) - - connection.send_message(websocket_api.result_message( - msg[ID], - cluster_commands - )) - - hass.components.websocket_api.async_register_command( - WS_DEVICE_CLUSTER_COMMANDS, websocket_device_cluster_commands, - SCHEMA_WS_CLUSTER_COMMANDS - ) - - @websocket_api.async_response - async def websocket_read_zigbee_cluster_attributes(hass, connection, msg): - """Read zigbee attribute for cluster on zha entity.""" - ieee = msg[ATTR_IEEE] - endpoint_id = msg[ATTR_ENDPOINT_ID] - cluster_id = msg[ATTR_CLUSTER_ID] - cluster_type = msg[ATTR_CLUSTER_TYPE] - attribute = msg[ATTR_ATTRIBUTE] - manufacturer = msg.get(ATTR_MANUFACTURER) or None - zha_device = zha_gateway.get_device(ieee) - success = failure = None - if zha_device is not None: - cluster = zha_device.async_get_cluster( - endpoint_id, cluster_id, cluster_type=cluster_type) - success, failure = await cluster.read_attributes( - [attribute], - allow_cache=False, - only_cache=False, - manufacturer=manufacturer - ) - _LOGGER.debug("Read attribute for: %s %s %s %s %s %s %s", - "{}: [{}]".format(ATTR_CLUSTER_ID, cluster_id), - "{}: [{}]".format(ATTR_CLUSTER_TYPE, cluster_type), - "{}: [{}]".format(ATTR_ENDPOINT_ID, endpoint_id), - "{}: [{}]".format(ATTR_ATTRIBUTE, attribute), - "{}: [{}]".format(ATTR_MANUFACTURER, manufacturer), - "{}: [{}]".format(RESPONSE, str(success.get(attribute))), - "{}: [{}]".format('failure', failure) - ) - connection.send_message(websocket_api.result_message( - msg[ID], - str(success.get(attribute)) - )) - - hass.components.websocket_api.async_register_command( - WS_READ_CLUSTER_ATTRIBUTE, websocket_read_zigbee_cluster_attributes, - SCHEMA_WS_READ_CLUSTER_ATTRIBUTE - ) + websocket_api.async_register_command(hass, websocket_get_devices) + websocket_api.async_register_command(hass, websocket_reconfigure_node) + websocket_api.async_register_command(hass, websocket_device_clusters) + websocket_api.async_register_command( + hass, websocket_device_cluster_attributes) + websocket_api.async_register_command( + hass, websocket_device_cluster_commands) + websocket_api.async_register_command( + hass, websocket_read_zigbee_cluster_attributes) + websocket_api.async_register_command(hass, websocket_get_bindable_devices) + websocket_api.async_register_command(hass, websocket_bind_devices) + websocket_api.async_register_command(hass, websocket_unbind_devices) def async_unload_api(hass): diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 3c8adb09748..757ffbaa328 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -14,6 +14,7 @@ DATA_ZHA_RADIO = 'zha_radio' DATA_ZHA_DISPATCHERS = 'zha_dispatchers' DATA_ZHA_CORE_COMPONENT = 'zha_core_component' DATA_ZHA_CORE_EVENTS = 'zha_core_events' +DATA_ZHA_GATEWAY = 'zha_gateway' ZHA_DISCOVERY_NEW = 'zha_discovery_new_{}' COMPONENTS = [ diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 35a79311253..a498e1e8ee1 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -22,7 +22,8 @@ from .const import ( OCCUPANCY, CLUSTER_REPORT_CONFIGS, REPORT_CONFIG_IMMEDIATE, REPORT_CONFIG_ASAP, REPORT_CONFIG_DEFAULT, REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, REPORT_CONFIG_OP, SIGNAL_REMOVE, - NO_SENSOR_CLUSTERS, POWER_CONFIGURATION_CHANNEL, BINDABLE_CLUSTERS) + NO_SENSOR_CLUSTERS, POWER_CONFIGURATION_CHANNEL, BINDABLE_CLUSTERS, + DATA_ZHA_GATEWAY) from .device import ZHADevice, DeviceStatus from ..device_entity import ZhaDeviceEntity from .channels import ( @@ -52,6 +53,7 @@ class ZHAGateway: self._devices = {} self._device_registry = collections.defaultdict(list) hass.data[DATA_ZHA][DATA_ZHA_CORE_COMPONENT] = self._component + hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] = self def device_joined(self, device): """Handle device joined. diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index 616a94e8b89..5858c7560d9 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -3,9 +3,7 @@ from unittest.mock import Mock import pytest from homeassistant.components.switch import DOMAIN from homeassistant.components.zha.api import ( - async_load_api, WS_DEVICE_CLUSTERS, ATTR_IEEE, TYPE, - ID, WS_DEVICE_CLUSTER_ATTRIBUTES, WS_DEVICE_CLUSTER_COMMANDS, - WS_DEVICES + async_load_api, ATTR_IEEE, TYPE, ID ) from homeassistant.components.zha.core.const import ( ATTR_CLUSTER_ID, ATTR_CLUSTER_TYPE, IN, IEEE, MODEL, NAME, QUIRK_APPLIED, @@ -38,7 +36,7 @@ async def test_device_clusters(hass, config_entry, zha_gateway, zha_client): """Test getting device cluster info.""" await zha_client.send_json({ ID: 5, - TYPE: WS_DEVICE_CLUSTERS, + TYPE: 'zha/devices/clusters', ATTR_IEEE: '00:0d:6f:00:0a:90:69:e7' }) @@ -64,7 +62,7 @@ async def test_device_cluster_attributes( """Test getting device cluster attributes.""" await zha_client.send_json({ ID: 5, - TYPE: WS_DEVICE_CLUSTER_ATTRIBUTES, + TYPE: 'zha/devices/clusters/attributes', ATTR_ENDPOINT_ID: 1, ATTR_IEEE: '00:0d:6f:00:0a:90:69:e7', ATTR_CLUSTER_ID: 6, @@ -86,7 +84,7 @@ async def test_device_cluster_commands( """Test getting device cluster commands.""" await zha_client.send_json({ ID: 5, - TYPE: WS_DEVICE_CLUSTER_COMMANDS, + TYPE: 'zha/devices/clusters/commands', ATTR_ENDPOINT_ID: 1, ATTR_IEEE: '00:0d:6f:00:0a:90:69:e7', ATTR_CLUSTER_ID: 6, @@ -109,7 +107,7 @@ async def test_list_devices( """Test getting entity cluster commands.""" await zha_client.send_json({ ID: 5, - TYPE: WS_DEVICES + TYPE: 'zha/devices' }) msg = await zha_client.receive_json() From 5e67054ee14aa5dbc4959a02211d9e17640c049a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 28 Feb 2019 17:43:36 -0800 Subject: [PATCH 028/291] Updated frontend to 20190228.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 7ef030e87d7..614b5228f60 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -21,7 +21,7 @@ from homeassistant.loader import bind_hass from .storage import async_setup_frontend_storage -REQUIREMENTS = ['home-assistant-frontend==20190227.0'] +REQUIREMENTS = ['home-assistant-frontend==20190228.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 703be36352d..5b91e095aa3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -535,7 +535,7 @@ hole==0.3.0 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190227.0 +home-assistant-frontend==20190228.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 26d77409fca..89c071e0ad3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -116,7 +116,7 @@ hdate==0.8.7 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190227.0 +home-assistant-frontend==20190228.0 # homeassistant.components.homekit_controller homekit==0.12.2 From 326513af906f968900fa83c012588b715444006e Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Thu, 28 Feb 2019 18:58:39 -0700 Subject: [PATCH 029/291] Add pause/unpause services to RainMachine (#21548) * Add pause/unpause services to RainMachine * Update requirements --- .../components/rainmachine/__init__.py | 21 +++++++++++++++++-- .../components/rainmachine/services.yaml | 8 +++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 29 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 0591af8acfa..6da5b2d6c10 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -19,7 +19,7 @@ from .config_flow import configured_instances from .const import ( DATA_CLIENT, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DEFAULT_SSL, DOMAIN) -REQUIREMENTS = ['regenmaschine==1.1.0'] +REQUIREMENTS = ['regenmaschine==1.2.0'] _LOGGER = logging.getLogger(__name__) @@ -31,6 +31,7 @@ ZONE_UPDATE_TOPIC = '{0}_zone_update'.format(DOMAIN) CONF_CONTROLLERS = 'controllers' CONF_PROGRAM_ID = 'program_id' +CONF_SECONDS = 'seconds' CONF_ZONE_ID = 'zone_id' CONF_ZONE_RUN_TIME = 'zone_run_time' @@ -73,6 +74,10 @@ SENSOR_SCHEMA = vol.Schema({ vol.All(cv.ensure_list, [vol.In(SENSORS)]) }) +SERVICE_PAUSE_WATERING = vol.Schema({ + vol.Required(CONF_SECONDS): cv.positive_int, +}) + SERVICE_START_PROGRAM_SCHEMA = vol.Schema({ vol.Required(CONF_PROGRAM_ID): cv.positive_int, }) @@ -184,6 +189,11 @@ async def async_setup_entry(hass, config_entry): refresh, timedelta(seconds=config_entry.data[CONF_SCAN_INTERVAL])) + async def pause_watering(service): + """Pause watering for a set number of seconds.""" + await rainmachine.client.watering.pause_all(service.data[CONF_SECONDS]) + async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) + async def start_program(service): """Start a particular program.""" await rainmachine.client.programs.start(service.data[CONF_PROGRAM_ID]) @@ -210,12 +220,19 @@ async def async_setup_entry(hass, config_entry): await rainmachine.client.zones.stop(service.data[CONF_ZONE_ID]) async_dispatcher_send(hass, ZONE_UPDATE_TOPIC) + async def unpause_watering(service): + """Unpause watering.""" + await rainmachine.client.watering.unpause_all() + async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) + for service, method, schema in [ + ('pause_watering', pause_watering, SERVICE_PAUSE_WATERING), ('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) + ('stop_zone', stop_zone, SERVICE_STOP_ZONE_SCHEMA), + ('unpause_watering', unpause_watering, {}), ]: hass.services.async_register(DOMAIN, service, method, schema=schema) diff --git a/homeassistant/components/rainmachine/services.yaml b/homeassistant/components/rainmachine/services.yaml index a8c77628c8f..a165c14d0e6 100644 --- a/homeassistant/components/rainmachine/services.yaml +++ b/homeassistant/components/rainmachine/services.yaml @@ -1,6 +1,12 @@ # Describes the format for available RainMachine services --- +pause_watering: + description: Pause all watering for a number of seconds. + fields: + seconds: + description: The number of seconds to pause. + example: 30 start_program: description: Start a program. fields: @@ -30,3 +36,5 @@ stop_zone: zone_id: description: The zone to stop. example: 3 +unpause_watering: + description: Unpause all watering. diff --git a/requirements_all.txt b/requirements_all.txt index 5b91e095aa3..93397ed18e8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1486,7 +1486,7 @@ raspyrfm-client==1.2.8 recollect-waste==1.0.1 # homeassistant.components.rainmachine -regenmaschine==1.1.0 +regenmaschine==1.2.0 # homeassistant.components.python_script restrictedpython==4.0b8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 89c071e0ad3..8513a00ad30 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -254,7 +254,7 @@ pyunifi==2.16 pywebpush==1.6.0 # homeassistant.components.rainmachine -regenmaschine==1.1.0 +regenmaschine==1.2.0 # homeassistant.components.python_script restrictedpython==4.0b8 From aa30ac52eaeed16aba8d30af2e8387d806c796a6 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Thu, 28 Feb 2019 22:53:59 -0500 Subject: [PATCH 030/291] prevent duplicate event channel registration (#21534) --- homeassistant/components/zha/core/device.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 182a08357b6..b0b41a87809 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -49,8 +49,8 @@ class ZHADevice: self._model = zigpy_device.endpoints[ept_id].model self._zha_gateway = zha_gateway self.cluster_channels = {} - self._relay_channels = [] - self._all_channels = [] + self._relay_channels = {} + self._all_channels = {} self._name = "{} {}".format( self.manufacturer, self.model @@ -126,7 +126,7 @@ class ZHADevice: @property def all_channels(self): """Return cluster channels and relay channels for device.""" - return self._all_channels + return self._all_channels.values() @property def available_signal(self): @@ -173,11 +173,13 @@ class ZHADevice: if cluster_channel.name is POWER_CONFIGURATION_CHANNEL and \ POWER_CONFIGURATION_CHANNEL in self.cluster_channels: return - self._all_channels.append(cluster_channel) + if isinstance(cluster_channel, EventRelayChannel): - self._relay_channels.append(cluster_channel) + self._relay_channels[cluster_channel.unique_id] = cluster_channel + self._all_channels[cluster_channel.unique_id] = cluster_channel else: self.cluster_channels[cluster_channel.name] = cluster_channel + self._all_channels[cluster_channel.name] = cluster_channel async def async_configure(self): """Configure the device.""" From ee4be13bda21cedea636d242fb389ba31296ed88 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 28 Feb 2019 20:27:20 -0800 Subject: [PATCH 031/291] Allow config entry reloading (#21502) * Allow config entry reloading * Fix duplicate test name * Add comment * fix typing --- homeassistant/bootstrap.py | 2 +- homeassistant/config_entries.py | 139 ++++++++++++++------ tests/test_config_entries.py | 220 +++++++++++++++++++++++++++++++- 3 files changed, 317 insertions(+), 44 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index eef36b026e1..d37e3babac8 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -117,7 +117,7 @@ async def async_from_config_dict(config: Dict[str, Any], hass, config, core_config.get(conf_util.CONF_PACKAGES, {})) hass.config_entries = config_entries.ConfigEntries(hass, config) - await hass.config_entries.async_load() + await hass.config_entries.async_initialize() # Filter out the repeating and common config section [homeassistant] components = set(key.split(' ')[0] for key in config.keys() diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 7b22c2e197c..69696561303 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -119,6 +119,7 @@ should follow the same return values as a normal step. If the result of the step is to show a form, the user will be able to continue the flow from the config panel. """ +import asyncio import logging import functools import uuid @@ -205,6 +206,11 @@ ENTRY_STATE_NOT_LOADED = 'not_loaded' # An error occurred when trying to unload the entry ENTRY_STATE_FAILED_UNLOAD = 'failed_unload' +UNRECOVERABLE_STATES = ( + ENTRY_STATE_MIGRATION_ERROR, + ENTRY_STATE_FAILED_UNLOAD, +) + DISCOVERY_NOTIFICATION_ID = 'config_entry_discovery' DISCOVERY_SOURCES = ( SOURCE_DISCOVERY, @@ -221,6 +227,18 @@ CONN_CLASS_ASSUMED = 'assumed' CONN_CLASS_UNKNOWN = 'unknown' +class ConfigError(HomeAssistantError): + """Error while configuring an account.""" + + +class UnknownEntry(ConfigError): + """Unknown entry specified.""" + + +class OperationNotAllowed(ConfigError): + """Raised when a config entry operation is not allowed.""" + + class ConfigEntry: """Hold a configuration entry.""" @@ -228,7 +246,7 @@ class ConfigEntry: 'source', 'connection_class', 'state', '_setup_lock', 'update_listeners', '_async_cancel_retry_setup') - def __init__(self, version: str, domain: str, title: str, data: dict, + def __init__(self, version: int, domain: str, title: str, data: dict, source: str, connection_class: str, options: Optional[dict] = None, entry_id: Optional[str] = None, @@ -283,7 +301,7 @@ class ConfigEntry: result = await component.async_setup_entry(hass, self) if not isinstance(result, bool): - _LOGGER.error('%s.async_config_entry did not return boolean', + _LOGGER.error('%s.async_setup_entry did not return boolean', component.DOMAIN) result = False except ConfigEntryNotReady: @@ -316,7 +334,7 @@ class ConfigEntry: else: self.state = ENTRY_STATE_SETUP_ERROR - async def async_unload(self, hass, *, component=None): + async def async_unload(self, hass, *, component=None) -> bool: """Unload an entry. Returns if unload is possible and was successful. @@ -325,17 +343,22 @@ class ConfigEntry: component = getattr(hass.components, self.domain) if component.DOMAIN == self.domain: - if self._async_cancel_retry_setup is not None: - self._async_cancel_retry_setup() - self.state = ENTRY_STATE_NOT_LOADED - return True + if self.state in UNRECOVERABLE_STATES: + return False if self.state != ENTRY_STATE_LOADED: + if self._async_cancel_retry_setup is not None: + self._async_cancel_retry_setup() + self._async_cancel_retry_setup = None + + self.state = ENTRY_STATE_NOT_LOADED return True supports_unload = hasattr(component, 'async_unload_entry') if not supports_unload: + if component.DOMAIN == self.domain: + self.state = ENTRY_STATE_FAILED_UNLOAD return False try: @@ -420,14 +443,6 @@ class ConfigEntry: } -class ConfigError(HomeAssistantError): - """Error while configuring an account.""" - - -class UnknownEntry(ConfigError): - """Unknown entry specified.""" - - class ConfigEntries: """Manage the configuration entries. @@ -474,34 +489,33 @@ class ConfigEntries: async def async_remove(self, entry_id): """Remove an entry.""" - found = None - for index, entry in enumerate(self._entries): - if entry.entry_id == entry_id: - found = index - break + entry = self.async_get_entry(entry_id) - if found is None: + if entry is None: raise UnknownEntry - entry = self._entries.pop(found) + if entry.state in UNRECOVERABLE_STATES: + unload_success = entry.state != ENTRY_STATE_FAILED_UNLOAD + else: + unload_success = await self.async_unload(entry_id) + + self._entries.remove(entry) self._async_schedule_save() - unloaded = await entry.async_unload(self.hass) + dev_reg, ent_reg = await asyncio.gather( + self.hass.helpers.device_registry.async_get_registry(), + self.hass.helpers.entity_registry.async_get_registry(), + ) - device_registry = await \ - self.hass.helpers.device_registry.async_get_registry() - device_registry.async_clear_config_entry(entry_id) - - entity_registry = await \ - self.hass.helpers.entity_registry.async_get_registry() - entity_registry.async_clear_config_entry(entry_id) + dev_reg.async_clear_config_entry(entry_id) + ent_reg.async_clear_config_entry(entry_id) return { - 'require_restart': not unloaded + 'require_restart': not unload_success } - async def async_load(self) -> None: - """Handle loading the config.""" + async def async_initialize(self) -> None: + """Initialize config entry 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, @@ -527,6 +541,56 @@ class ConfigEntries: options=entry.get('options')) for entry in config['entries']] + async def async_setup(self, entry_id: str) -> bool: + """Set up a config entry. + + Return True if entry has been successfully loaded. + """ + entry = self.async_get_entry(entry_id) + + if entry is None: + raise UnknownEntry + + if entry.state != ENTRY_STATE_NOT_LOADED: + raise OperationNotAllowed + + # Setup Component if not set up yet + if entry.domain in self.hass.config.components: + await entry.async_setup(self.hass) + else: + # Setting up the component will set up all its config entries + result = await async_setup_component( + self.hass, entry.domain, self._hass_config) + + if not result: + return result + + return entry.state == ENTRY_STATE_LOADED + + async def async_unload(self, entry_id: str) -> bool: + """Unload a config entry.""" + entry = self.async_get_entry(entry_id) + + if entry is None: + raise UnknownEntry + + if entry.state in UNRECOVERABLE_STATES: + raise OperationNotAllowed + + return await entry.async_unload(self.hass) + + async def async_reload(self, entry_id: str) -> bool: + """Reload an entry. + + If an entry was not loaded, will just load. + """ + unload_result = await self.async_unload(entry_id) + + if not unload_result: + return unload_result + + return await self.async_setup(entry_id) + @callback def async_update_entry(self, entry, *, data=_UNDEF, options=_UNDEF): """Update a config entry.""" @@ -597,14 +661,7 @@ class ConfigEntries: self._entries.append(entry) self._async_schedule_save() - # Setup entry - if entry.domain in self.hass.config.components: - # Component already set up, just need to call setup_entry - await entry.async_setup(self.hass) - else: - # Setting up component will also load the entries - await async_setup_component( - self.hass, entry.domain, self._hass_config) + await self.async_setup(entry.entry_id) result['result'] = entry return result diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 8991035cc22..e7a5b763796 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -407,7 +407,7 @@ async def test_saving_and_loading(hass): # Now load written data in new config manager manager = config_entries.ConfigEntries(hass, {}) - await manager.async_load() + await manager.async_initialize() # Ensure same order for orig, loaded in zip(hass.config_entries.async_entries(), @@ -518,7 +518,7 @@ async def test_loading_default_config(hass): manager = config_entries.ConfigEntries(hass, {}) with patch('homeassistant.util.json.open', side_effect=FileNotFoundError): - await manager.async_load() + await manager.async_initialize() assert len(manager.async_entries()) == 0 @@ -650,3 +650,219 @@ async def test_entry_options(hass, manager): assert entry.options == { 'second': True } + + +async def test_entry_setup_succeed(hass, manager): + """Test that we can setup an entry.""" + entry = MockConfigEntry( + domain='comp', + state=config_entries.ENTRY_STATE_NOT_LOADED + ) + entry.add_to_hass(hass) + + mock_setup = MagicMock(return_value=mock_coro(True)) + mock_setup_entry = MagicMock(return_value=mock_coro(True)) + + loader.set_component(hass, 'comp', MockModule( + 'comp', + async_setup=mock_setup, + async_setup_entry=mock_setup_entry + )) + + assert await manager.async_setup(entry.entry_id) + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + assert entry.state == config_entries.ENTRY_STATE_LOADED + + +@pytest.mark.parametrize('state', ( + config_entries.ENTRY_STATE_LOADED, + config_entries.ENTRY_STATE_SETUP_ERROR, + config_entries.ENTRY_STATE_MIGRATION_ERROR, + config_entries.ENTRY_STATE_SETUP_RETRY, + config_entries.ENTRY_STATE_FAILED_UNLOAD, +)) +async def test_entry_setup_invalid_state(hass, manager, state): + """Test that we cannot setup an entry with invalid state.""" + entry = MockConfigEntry( + domain='comp', + state=state + ) + entry.add_to_hass(hass) + + mock_setup = MagicMock(return_value=mock_coro(True)) + mock_setup_entry = MagicMock(return_value=mock_coro(True)) + + loader.set_component(hass, 'comp', MockModule( + 'comp', + async_setup=mock_setup, + async_setup_entry=mock_setup_entry + )) + + with pytest.raises(config_entries.OperationNotAllowed): + assert await manager.async_setup(entry.entry_id) + + assert len(mock_setup.mock_calls) == 0 + assert len(mock_setup_entry.mock_calls) == 0 + assert entry.state == state + + +async def test_entry_unload_succeed(hass, manager): + """Test that we can unload an entry.""" + entry = MockConfigEntry( + domain='comp', + state=config_entries.ENTRY_STATE_LOADED + ) + entry.add_to_hass(hass) + + async_unload_entry = MagicMock(return_value=mock_coro(True)) + + loader.set_component(hass, 'comp', MockModule( + 'comp', + async_unload_entry=async_unload_entry + )) + + assert await manager.async_unload(entry.entry_id) + assert len(async_unload_entry.mock_calls) == 1 + assert entry.state == config_entries.ENTRY_STATE_NOT_LOADED + + +@pytest.mark.parametrize('state', ( + config_entries.ENTRY_STATE_NOT_LOADED, + config_entries.ENTRY_STATE_SETUP_ERROR, + config_entries.ENTRY_STATE_SETUP_RETRY, +)) +async def test_entry_unload_failed_to_load(hass, manager, state): + """Test that we can unload an entry.""" + entry = MockConfigEntry( + domain='comp', + state=state, + ) + entry.add_to_hass(hass) + + async_unload_entry = MagicMock(return_value=mock_coro(True)) + + loader.set_component(hass, 'comp', MockModule( + 'comp', + async_unload_entry=async_unload_entry + )) + + assert await manager.async_unload(entry.entry_id) + assert len(async_unload_entry.mock_calls) == 0 + assert entry.state == config_entries.ENTRY_STATE_NOT_LOADED + + +@pytest.mark.parametrize('state', ( + config_entries.ENTRY_STATE_MIGRATION_ERROR, + config_entries.ENTRY_STATE_FAILED_UNLOAD, +)) +async def test_entry_unload_invalid_state(hass, manager, state): + """Test that we cannot unload an entry with invalid state.""" + entry = MockConfigEntry( + domain='comp', + state=state + ) + entry.add_to_hass(hass) + + async_unload_entry = MagicMock(return_value=mock_coro(True)) + + loader.set_component(hass, 'comp', MockModule( + 'comp', + async_unload_entry=async_unload_entry + )) + + with pytest.raises(config_entries.OperationNotAllowed): + assert await manager.async_unload(entry.entry_id) + + assert len(async_unload_entry.mock_calls) == 0 + assert entry.state == state + + +async def test_entry_reload_succeed(hass, manager): + """Test that we can reload an entry.""" + entry = MockConfigEntry( + domain='comp', + state=config_entries.ENTRY_STATE_LOADED + ) + entry.add_to_hass(hass) + + async_setup = MagicMock(return_value=mock_coro(True)) + async_setup_entry = MagicMock(return_value=mock_coro(True)) + async_unload_entry = MagicMock(return_value=mock_coro(True)) + + loader.set_component(hass, 'comp', MockModule( + 'comp', + async_setup=async_setup, + async_setup_entry=async_setup_entry, + async_unload_entry=async_unload_entry + )) + + assert await manager.async_reload(entry.entry_id) + assert len(async_unload_entry.mock_calls) == 1 + assert len(async_setup.mock_calls) == 1 + assert len(async_setup_entry.mock_calls) == 1 + assert entry.state == config_entries.ENTRY_STATE_LOADED + + +@pytest.mark.parametrize('state', ( + config_entries.ENTRY_STATE_NOT_LOADED, + config_entries.ENTRY_STATE_SETUP_ERROR, + config_entries.ENTRY_STATE_SETUP_RETRY, +)) +async def test_entry_reload_not_loaded(hass, manager, state): + """Test that we can reload an entry.""" + entry = MockConfigEntry( + domain='comp', + state=state + ) + entry.add_to_hass(hass) + + async_setup = MagicMock(return_value=mock_coro(True)) + async_setup_entry = MagicMock(return_value=mock_coro(True)) + async_unload_entry = MagicMock(return_value=mock_coro(True)) + + loader.set_component(hass, 'comp', MockModule( + 'comp', + async_setup=async_setup, + async_setup_entry=async_setup_entry, + async_unload_entry=async_unload_entry + )) + + assert await manager.async_reload(entry.entry_id) + assert len(async_unload_entry.mock_calls) == 0 + assert len(async_setup.mock_calls) == 1 + assert len(async_setup_entry.mock_calls) == 1 + assert entry.state == config_entries.ENTRY_STATE_LOADED + + +@pytest.mark.parametrize('state', ( + config_entries.ENTRY_STATE_MIGRATION_ERROR, + config_entries.ENTRY_STATE_FAILED_UNLOAD, +)) +async def test_entry_reload_error(hass, manager, state): + """Test that we can reload an entry.""" + entry = MockConfigEntry( + domain='comp', + state=state + ) + entry.add_to_hass(hass) + + async_setup = MagicMock(return_value=mock_coro(True)) + async_setup_entry = MagicMock(return_value=mock_coro(True)) + async_unload_entry = MagicMock(return_value=mock_coro(True)) + + loader.set_component(hass, 'comp', MockModule( + 'comp', + async_setup=async_setup, + async_setup_entry=async_setup_entry, + async_unload_entry=async_unload_entry + )) + + with pytest.raises(config_entries.OperationNotAllowed): + assert await manager.async_reload(entry.entry_id) + + assert len(async_unload_entry.mock_calls) == 0 + assert len(async_setup.mock_calls) == 0 + assert len(async_setup_entry.mock_calls) == 0 + + assert entry.state == state From 0aba49adcec11d7d8987ee467cfc18be451006df Mon Sep 17 00:00:00 2001 From: msvinth Date: Fri, 1 Mar 2019 08:17:59 +0100 Subject: [PATCH 032/291] Add separate on/off ids on manual configured IHC lights (#20253) * Add support for separate on/off ids on manual configured IHC lights. This makes it easier to support IHC code units thats relies on being turned on and off through specific inputs. Also adds a pulse service (ihc.pulse) that supports sending a short on/off pulse to an IHC input. * Fix * Lint fix * Add on/off id support in switch * Make pulse async * Code review fixes --- homeassistant/components/ihc/__init__.py | 29 +++++++++++++-- homeassistant/components/ihc/const.py | 3 ++ homeassistant/components/ihc/light.py | 43 ++++++++++++++++------ homeassistant/components/ihc/services.yaml | 5 +++ homeassistant/components/ihc/switch.py | 30 +++++++++++---- homeassistant/components/ihc/util.py | 22 +++++++++++ 6 files changed, 109 insertions(+), 23 deletions(-) create mode 100644 homeassistant/components/ihc/util.py diff --git a/homeassistant/components/ihc/__init__.py b/homeassistant/components/ihc/__init__.py index bd45a52944c..daaf471e318 100644 --- a/homeassistant/components/ihc/__init__.py +++ b/homeassistant/components/ihc/__init__.py @@ -7,9 +7,11 @@ import voluptuous as vol from homeassistant.components.binary_sensor import DEVICE_CLASSES_SCHEMA from homeassistant.components.ihc.const import ( ATTR_IHC_ID, ATTR_VALUE, CONF_AUTOSETUP, CONF_BINARY_SENSOR, CONF_DIMMABLE, - CONF_INFO, CONF_INVERTING, CONF_LIGHT, CONF_NODE, CONF_NOTE, CONF_POSITION, - CONF_SENSOR, CONF_SWITCH, CONF_XPATH, SERVICE_SET_RUNTIME_VALUE_BOOL, - SERVICE_SET_RUNTIME_VALUE_FLOAT, SERVICE_SET_RUNTIME_VALUE_INT) + CONF_INFO, CONF_INVERTING, CONF_LIGHT, CONF_NODE, CONF_NOTE, CONF_OFF_ID, + CONF_ON_ID, CONF_POSITION, CONF_SENSOR, CONF_SWITCH, CONF_XPATH, + SERVICE_SET_RUNTIME_VALUE_BOOL, SERVICE_SET_RUNTIME_VALUE_FLOAT, + SERVICE_SET_RUNTIME_VALUE_INT, SERVICE_PULSE) +from homeassistant.components.ihc.util import async_pulse from homeassistant.config import load_yaml_config_file from homeassistant.const import ( CONF_ID, CONF_NAME, CONF_PASSWORD, CONF_TYPE, CONF_UNIT_OF_MEASUREMENT, @@ -50,7 +52,10 @@ DEVICE_SCHEMA = vol.Schema({ }) -SWITCH_SCHEMA = DEVICE_SCHEMA.extend({}) +SWITCH_SCHEMA = DEVICE_SCHEMA.extend({ + vol.Optional(CONF_OFF_ID, default=0): cv.positive_int, + vol.Optional(CONF_ON_ID, default=0): cv.positive_int, +}) BINARY_SENSOR_SCHEMA = DEVICE_SCHEMA.extend({ vol.Optional(CONF_INVERTING, default=False): cv.boolean, @@ -59,6 +64,8 @@ BINARY_SENSOR_SCHEMA = DEVICE_SCHEMA.extend({ LIGHT_SCHEMA = DEVICE_SCHEMA.extend({ vol.Optional(CONF_DIMMABLE, default=False): cv.boolean, + vol.Optional(CONF_OFF_ID, default=0): cv.positive_int, + vol.Optional(CONF_ON_ID, default=0): cv.positive_int, }) SENSOR_SCHEMA = DEVICE_SCHEMA.extend({ @@ -138,6 +145,10 @@ SET_RUNTIME_VALUE_FLOAT_SCHEMA = vol.Schema({ vol.Required(ATTR_VALUE): vol.Coerce(float), }) +PULSE_SCHEMA = vol.Schema({ + vol.Required(ATTR_IHC_ID): cv.positive_int, +}) + def setup(hass, config): """Set up the IHC platform.""" @@ -197,6 +208,8 @@ def get_manual_configuration( 'product_cfg': { 'type': sensor_cfg.get(CONF_TYPE), 'inverting': sensor_cfg.get(CONF_INVERTING), + 'off_id': sensor_cfg.get(CONF_OFF_ID), + 'on_id': sensor_cfg.get(CONF_ON_ID), 'dimmable': sensor_cfg.get(CONF_DIMMABLE), 'unit_of_measurement': sensor_cfg.get( CONF_UNIT_OF_MEASUREMENT) @@ -287,6 +300,11 @@ def setup_service_functions(hass: HomeAssistantType, ihc_controller): value = call.data[ATTR_VALUE] ihc_controller.set_runtime_value_float(ihc_id, value) + async def async_pulse_runtime_input(call): + """Pulse a IHC controller input function.""" + ihc_id = call.data[ATTR_IHC_ID] + await async_pulse(hass, ihc_controller, ihc_id) + hass.services.register(DOMAIN, SERVICE_SET_RUNTIME_VALUE_BOOL, set_runtime_value_bool, schema=SET_RUNTIME_VALUE_BOOL_SCHEMA) @@ -296,3 +314,6 @@ def setup_service_functions(hass: HomeAssistantType, ihc_controller): hass.services.register(DOMAIN, SERVICE_SET_RUNTIME_VALUE_FLOAT, set_runtime_value_float, schema=SET_RUNTIME_VALUE_FLOAT_SCHEMA) + hass.services.register(DOMAIN, SERVICE_PULSE, + async_pulse_runtime_input, + schema=PULSE_SCHEMA) diff --git a/homeassistant/components/ihc/const.py b/homeassistant/components/ihc/const.py index 69342c944ba..2199a8a156e 100644 --- a/homeassistant/components/ihc/const.py +++ b/homeassistant/components/ihc/const.py @@ -9,6 +9,8 @@ CONF_LIGHT = 'light' CONF_NAME = 'name' CONF_NODE = 'node' CONF_NOTE = 'note' +CONF_OFF_ID = 'off_id' +CONF_ON_ID = 'on_id' CONF_POSITION = 'position' CONF_SENSOR = 'sensor' CONF_SWITCH = 'switch' @@ -20,3 +22,4 @@ ATTR_VALUE = 'value' SERVICE_SET_RUNTIME_VALUE_BOOL = 'set_runtime_value_bool' SERVICE_SET_RUNTIME_VALUE_FLOAT = 'set_runtime_value_float' SERVICE_SET_RUNTIME_VALUE_INT = 'set_runtime_value_int' +SERVICE_PULSE = 'pulse' diff --git a/homeassistant/components/ihc/light.py b/homeassistant/components/ihc/light.py index 2590ea83222..646be7597d0 100644 --- a/homeassistant/components/ihc/light.py +++ b/homeassistant/components/ihc/light.py @@ -2,7 +2,10 @@ import logging from homeassistant.components.ihc import IHC_CONTROLLER, IHC_DATA, IHC_INFO -from homeassistant.components.ihc.const import CONF_DIMMABLE +from homeassistant.components.ihc.const import ( + CONF_DIMMABLE, CONF_OFF_ID, CONF_ON_ID) +from homeassistant.components.ihc.util import ( + async_pulse, async_set_bool, async_set_int) from homeassistant.components.ihc.ihcdevice import IHCDevice from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS, Light) @@ -26,9 +29,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): ihc_key = IHC_DATA.format(ctrl_id) info = hass.data[ihc_key][IHC_INFO] ihc_controller = hass.data[ihc_key][IHC_CONTROLLER] + ihc_off_id = product_cfg.get(CONF_OFF_ID) + ihc_on_id = product_cfg.get(CONF_ON_ID) dimmable = product_cfg[CONF_DIMMABLE] - light = IhcLight(ihc_controller, name, ihc_id, info, - dimmable, product) + light = IhcLight(ihc_controller, name, ihc_id, ihc_off_id, ihc_on_id, + info, dimmable, product) devices.append(light) add_entities(devices) @@ -41,10 +46,13 @@ class IhcLight(IHCDevice, Light): an on/off (boolean) resource """ - def __init__(self, ihc_controller, name, ihc_id: int, info: bool, - dimmable=False, product=None) -> None: + def __init__(self, ihc_controller, name, ihc_id: int, ihc_off_id: int, + ihc_on_id: int, info: bool, dimmable=False, + product=None) -> None: """Initialize the light.""" super().__init__(ihc_controller, name, ihc_id, info, product) + self._ihc_off_id = ihc_off_id + self._ihc_on_id = ihc_on_id self._brightness = 0 self._dimmable = dimmable self._state = None @@ -66,7 +74,7 @@ class IhcLight(IHCDevice, Light): return SUPPORT_BRIGHTNESS return 0 - def turn_on(self, **kwargs) -> None: + async def async_turn_on(self, **kwargs): """Turn the light on.""" if ATTR_BRIGHTNESS in kwargs: brightness = kwargs[ATTR_BRIGHTNESS] @@ -76,17 +84,28 @@ class IhcLight(IHCDevice, Light): brightness = 255 if self._dimmable: - self.ihc_controller.set_runtime_value_int( - self.ihc_id, int(brightness * 100 / 255)) + await async_set_int(self.hass, self.ihc_controller, + self.ihc_id, int(brightness * 100 / 255)) else: - self.ihc_controller.set_runtime_value_bool(self.ihc_id, True) + if self._ihc_on_id: + await async_pulse(self.hass, self.ihc_controller, + self._ihc_on_id) + else: + await async_set_bool(self.hass, self.ihc_controller, + self.ihc_id, True) - def turn_off(self, **kwargs) -> None: + async def async_turn_off(self, **kwargs): """Turn the light off.""" if self._dimmable: - self.ihc_controller.set_runtime_value_int(self.ihc_id, 0) + await async_set_int(self.hass, self.ihc_controller, + self.ihc_id, 0) else: - self.ihc_controller.set_runtime_value_bool(self.ihc_id, False) + if self._ihc_off_id: + await async_pulse(self.hass, self.ihc_controller, + self._ihc_off_id) + else: + await async_set_bool(self.hass, self.ihc_controller, + self.ihc_id, False) def on_ihc_change(self, ihc_id, value): """Handle IHC notifications.""" diff --git a/homeassistant/components/ihc/services.yaml b/homeassistant/components/ihc/services.yaml index 0c63b32a618..0a78c45d7b2 100644 --- a/homeassistant/components/ihc/services.yaml +++ b/homeassistant/components/ihc/services.yaml @@ -24,3 +24,8 @@ set_runtime_value_float: value: description: The float value to set. +pulse: + description: Pulses an input on the IHC controller. + fields: + ihc_id: + description: The integer IHC resource ID. \ No newline at end of file diff --git a/homeassistant/components/ihc/switch.py b/homeassistant/components/ihc/switch.py index bbab9d3e68c..d25b343446d 100644 --- a/homeassistant/components/ihc/switch.py +++ b/homeassistant/components/ihc/switch.py @@ -1,5 +1,7 @@ """Support for IHC switches.""" from homeassistant.components.ihc import IHC_CONTROLLER, IHC_DATA, IHC_INFO +from homeassistant.components.ihc.const import CONF_OFF_ID, CONF_ON_ID +from homeassistant.components.ihc.util import async_pulse, async_set_bool from homeassistant.components.ihc.ihcdevice import IHCDevice from homeassistant.components.switch import SwitchDevice @@ -13,14 +15,18 @@ def setup_platform(hass, config, add_entities, discovery_info=None): devices = [] for name, device in discovery_info.items(): ihc_id = device['ihc_id'] + product_cfg = device['product_cfg'] product = device['product'] # Find controller that corresponds with device id ctrl_id = device['ctrl_id'] ihc_key = IHC_DATA.format(ctrl_id) info = hass.data[ihc_key][IHC_INFO] ihc_controller = hass.data[ihc_key][IHC_CONTROLLER] + ihc_off_id = product_cfg.get(CONF_OFF_ID) + ihc_on_id = product_cfg.get(CONF_ON_ID) - switch = IHCSwitch(ihc_controller, name, ihc_id, info, product) + switch = IHCSwitch(ihc_controller, name, ihc_id, ihc_off_id, ihc_on_id, + info, product) devices.append(switch) add_entities(devices) @@ -28,10 +34,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): class IHCSwitch(IHCDevice, SwitchDevice): """Representation of an IHC switch.""" - def __init__(self, ihc_controller, name: str, ihc_id: int, - info: bool, product=None) -> None: + def __init__(self, ihc_controller, name: str, ihc_id: int, ihc_off_id: int, + ihc_on_id: int, info: bool, product=None) -> None: """Initialize the IHC switch.""" super().__init__(ihc_controller, name, ihc_id, product) + self._ihc_off_id = ihc_off_id + self._ihc_on_id = ihc_on_id self._state = False @property @@ -39,13 +47,21 @@ class IHCSwitch(IHCDevice, SwitchDevice): """Return true if switch is on.""" return self._state - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the switch on.""" - self.ihc_controller.set_runtime_value_bool(self.ihc_id, True) + if self._ihc_on_id: + await async_pulse(self.hass, self.ihc_controller, self._ihc_on_id) + else: + await async_set_bool(self.hass, self.ihc_controller, + self.ihc_id, True) - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the device off.""" - self.ihc_controller.set_runtime_value_bool(self.ihc_id, False) + if self._ihc_off_id: + await async_pulse(self.hass, self.ihc_controller, self._ihc_off_id) + else: + await async_set_bool(self.hass, self.ihc_controller, + self.ihc_id, False) def on_ihc_change(self, ihc_id, value): """Handle IHC resource change.""" diff --git a/homeassistant/components/ihc/util.py b/homeassistant/components/ihc/util.py new file mode 100644 index 00000000000..a6780262f5e --- /dev/null +++ b/homeassistant/components/ihc/util.py @@ -0,0 +1,22 @@ +"""Useful functions for the IHC component.""" + +import asyncio + + +async def async_pulse(hass, ihc_controller, ihc_id: int): + """Send a short on/off pulse to an IHC controller resource.""" + await async_set_bool(hass, ihc_controller, ihc_id, True) + await asyncio.sleep(0.1) + await async_set_bool(hass, ihc_controller, ihc_id, False) + + +def async_set_bool(hass, ihc_controller, ihc_id: int, value: bool): + """Set a bool value on an IHC controller resource.""" + return hass.async_add_executor_job(ihc_controller.set_runtime_value_bool, + ihc_id, value) + + +def async_set_int(hass, ihc_controller, ihc_id: int, value: int): + """Set a int value on an IHC controller resource.""" + return hass.async_add_executor_job(ihc_controller.set_runtime_value_int, + ihc_id, value) From b39846fb6b2675a10363d34d3663fcb957d39f38 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Fri, 1 Mar 2019 08:11:24 -0500 Subject: [PATCH 033/291] add friendly name to devices in the device registry (#21499) switch to name_by_user review comments add device reg info to zha device api --- homeassistant/components/zha/api.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index df6b6591bac..5895e4c14f8 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -11,6 +11,7 @@ import voluptuous as vol from homeassistant.components import websocket_api import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import async_get_registry from .core.const import ( DOMAIN, ATTR_CLUSTER_ID, ATTR_CLUSTER_TYPE, ATTR_ATTRIBUTE, ATTR_VALUE, ATTR_MANUFACTURER, ATTR_COMMAND, ATTR_COMMAND_TYPE, ATTR_ARGS, IN, OUT, @@ -80,9 +81,14 @@ SERVICE_SCHEMAS = { async def websocket_get_devices(hass, connection, msg): """Get ZHA devices.""" zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] + ha_device_registry = await async_get_registry(hass) devices = [ { **device.device_info, + 'user_given_name': ha_device_registry.async_get_device( + {(DOMAIN, str(device.ieee))}, set()).name_by_user, + 'device_reg_id': ha_device_registry.async_get_device( + {(DOMAIN, str(device.ieee))}, set()).id, 'entities': [{ 'entity_id': entity_ref.reference_id, NAME: entity_ref.device_info[NAME] From 52f337ef00c42da006564ddd6580f9a26578e68a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 1 Mar 2019 10:08:38 -0800 Subject: [PATCH 034/291] Allow chaining contexts (#21028) * Allow chaining contexts * Add stubbed out migration --- .../components/automation/__init__.py | 26 ++++++++----- .../components/recorder/migration.py | 9 +++++ homeassistant/components/recorder/models.py | 18 ++++++--- homeassistant/core.py | 5 +++ tests/components/automation/test_event.py | 2 +- .../automation/test_geo_location.py | 4 +- tests/components/automation/test_init.py | 39 ++++++++++++------- .../automation/test_numeric_state.py | 4 +- tests/components/automation/test_state.py | 2 +- tests/components/automation/test_template.py | 2 +- tests/components/automation/test_zone.py | 2 +- tests/test_core.py | 14 +++++++ 12 files changed, 88 insertions(+), 39 deletions(-) diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index 35cf695f1e3..ad231a2a348 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -7,7 +7,7 @@ import logging import voluptuous as vol from homeassistant.setup import async_prepare_setup_platform -from homeassistant.core import CoreState +from homeassistant.core import CoreState, Context from homeassistant.loader import bind_hass from homeassistant.const import ( ATTR_ENTITY_ID, CONF_PLATFORM, STATE_ON, SERVICE_TURN_ON, SERVICE_TURN_OFF, @@ -280,15 +280,21 @@ class AutomationEntity(ToggleEntity, RestoreEntity): This method is a coroutine. """ - if skip_condition or self._cond_func(variables): - self.async_set_context(context) - self.hass.bus.async_fire(EVENT_AUTOMATION_TRIGGERED, { - ATTR_NAME: self._name, - ATTR_ENTITY_ID: self.entity_id, - }, context=context) - await self._async_action(self.entity_id, variables, context) - self._last_triggered = utcnow() - await self.async_update_ha_state() + if not skip_condition and not self._cond_func(variables): + return + + # Create a new context referring to the old context. + parent_id = None if context is None else context.id + trigger_context = Context(parent_id=parent_id) + + self.async_set_context(trigger_context) + self.hass.bus.async_fire(EVENT_AUTOMATION_TRIGGERED, { + ATTR_NAME: self._name, + ATTR_ENTITY_ID: self.entity_id, + }, context=trigger_context) + await self._async_action(self.entity_id, variables, trigger_context) + self._last_triggered = utcnow() + await self.async_update_ha_state() async def async_will_remove_from_hass(self): """Remove listeners when removing automation from HASS.""" diff --git a/homeassistant/components/recorder/migration.py b/homeassistant/components/recorder/migration.py index 825f402aef2..972862e7a9c 100644 --- a/homeassistant/components/recorder/migration.py +++ b/homeassistant/components/recorder/migration.py @@ -220,6 +220,15 @@ def _apply_update(engine, new_version, old_version): _create_index(engine, "states", "ix_states_context_user_id") elif new_version == 7: _create_index(engine, "states", "ix_states_entity_id") + elif new_version == 8: + # Pending migration, want to group a few. + pass + # _add_columns(engine, "events", [ + # 'context_parent_id CHARACTER(36)', + # ]) + # _add_columns(engine, "states", [ + # 'context_parent_id CHARACTER(36)', + # ]) 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 d1be17b83d5..bea2b12b370 100644 --- a/homeassistant/components/recorder/models.py +++ b/homeassistant/components/recorder/models.py @@ -34,16 +34,20 @@ class Events(Base): # type: ignore created = Column(DateTime(timezone=True), default=datetime.utcnow) context_id = Column(String(36), index=True) context_user_id = Column(String(36), index=True) + # context_parent_id = Column(String(36), index=True) @staticmethod def from_event(event): """Create an event database object from a native 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, - context_id=event.context.id, - context_user_id=event.context.user_id) + return Events( + event_type=event.event_type, + event_data=json.dumps(event.data, cls=JSONEncoder), + origin=str(event.origin), + time_fired=event.time_fired, + context_id=event.context.id, + context_user_id=event.context.user_id, + # context_parent_id=event.context.parent_id, + ) def to_native(self): """Convert to a natve HA Event.""" @@ -81,6 +85,7 @@ class States(Base): # type: ignore created = Column(DateTime(timezone=True), default=datetime.utcnow) context_id = Column(String(36), index=True) context_user_id = Column(String(36), index=True) + # context_parent_id = Column(String(36), index=True) __table_args__ = ( # Used for fetching the state of entities at a specific time @@ -99,6 +104,7 @@ class States(Base): # type: ignore entity_id=entity_id, context_id=event.context.id, context_user_id=event.context.user_id, + # context_parent_id=event.context.parent_id, ) # State got deleted diff --git a/homeassistant/core.py b/homeassistant/core.py index 48ef4f46272..253900a39ef 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -409,6 +409,10 @@ class Context: type=str, default=None, ) + parent_id = attr.ib( + type=Optional[str], + default=None + ) id = attr.ib( type=str, default=attr.Factory(lambda: uuid.uuid4().hex), @@ -418,6 +422,7 @@ class Context: """Return a dictionary representation of the context.""" return { 'id': self.id, + 'parent_id': self.parent_id, 'user_id': self.user_id, } diff --git a/tests/components/automation/test_event.py b/tests/components/automation/test_event.py index 4b669fc1356..8ca7f6b13f5 100644 --- a/tests/components/automation/test_event.py +++ b/tests/components/automation/test_event.py @@ -41,7 +41,7 @@ async def test_if_fires_on_event(hass, calls): hass.bus.async_fire('test_event', context=context) await hass.async_block_till_done() assert 1 == len(calls) - assert calls[0].context is context + assert calls[0].context.parent_id == context.id await common.async_turn_off(hass) await hass.async_block_till_done() diff --git a/tests/components/automation/test_geo_location.py b/tests/components/automation/test_geo_location.py index 928296c8d27..92ded1a07db 100644 --- a/tests/components/automation/test_geo_location.py +++ b/tests/components/automation/test_geo_location.py @@ -68,7 +68,7 @@ async def test_if_fires_on_zone_enter(hass, calls): await hass.async_block_till_done() assert 1 == len(calls) - assert calls[0].context is context + assert calls[0].context.parent_id == context.id assert 'geo_location - geo_location.entity - hello - hello - test' == \ calls[0].data['some'] @@ -221,7 +221,7 @@ async def test_if_fires_on_zone_appear(hass, calls): await hass.async_block_till_done() assert 1 == len(calls) - assert calls[0].context is context + assert calls[0].context.parent_id == context.id assert 'geo_location - geo_location.entity - - hello - test' == \ calls[0].data['some'] diff --git a/tests/components/automation/test_init.py b/tests/components/automation/test_init.py index 12c97507a13..a019f65afcf 100644 --- a/tests/components/automation/test_init.py +++ b/tests/components/automation/test_init.py @@ -369,38 +369,47 @@ async def test_shared_context(hass, calls): }) context = Context() - automation_mock = Mock() + first_automation_listener = Mock() event_mock = Mock() - hass.bus.async_listen('test_event2', automation_mock) + hass.bus.async_listen('test_event2', first_automation_listener) hass.bus.async_listen(EVENT_AUTOMATION_TRIGGERED, event_mock) hass.bus.async_fire('test_event', context=context) await hass.async_block_till_done() # Ensure events was fired - assert automation_mock.call_count == 1 + assert first_automation_listener.call_count == 1 assert event_mock.call_count == 2 - # Ensure context carries through the event - args, kwargs = automation_mock.call_args - assert args[0].context == context + # Verify automation triggered evenet for 'hello' automation + args, kwargs = event_mock.call_args_list[0] + first_trigger_context = args[0].context + assert first_trigger_context.parent_id == context.id + # Ensure event data has all attributes set + assert args[0].data.get(ATTR_NAME) is not None + assert args[0].data.get(ATTR_ENTITY_ID) is not None - for call in event_mock.call_args_list: - args, kwargs = call - assert args[0].context == context - # Ensure event data has all attributes set - assert args[0].data.get(ATTR_NAME) is not None - assert args[0].data.get(ATTR_ENTITY_ID) is not None + # Ensure context set correctly for event fired by 'hello' automation + args, kwargs = first_automation_listener.call_args + assert args[0].context is first_trigger_context - # Ensure the automation state shares the same context + # Ensure the 'hello' automation state has the right context state = hass.states.get('automation.hello') assert state is not None - assert state.context == context + assert state.context is first_trigger_context + + # Verify automation triggered evenet for 'bye' automation + args, kwargs = event_mock.call_args_list[1] + second_trigger_context = args[0].context + assert second_trigger_context.parent_id == first_trigger_context.id + # Ensure event data has all attributes set + assert args[0].data.get(ATTR_NAME) is not None + assert args[0].data.get(ATTR_ENTITY_ID) is not None # Ensure the service call from the second automation # shares the same context assert len(calls) == 1 - assert calls[0].context == context + assert calls[0].context is second_trigger_context async def test_services(hass, calls): diff --git a/tests/components/automation/test_numeric_state.py b/tests/components/automation/test_numeric_state.py index 92a5f3b8b92..803a15e9634 100644 --- a/tests/components/automation/test_numeric_state.py +++ b/tests/components/automation/test_numeric_state.py @@ -45,7 +45,7 @@ async def test_if_fires_on_entity_change_below(hass, calls): hass.states.async_set('test.entity', 9, context=context) await hass.async_block_till_done() assert 1 == len(calls) - assert calls[0].context is context + assert calls[0].context.parent_id == context.id # Set above 12 so the automation will fire again hass.states.async_set('test.entity', 12) @@ -134,7 +134,7 @@ async def test_if_not_fires_on_entity_change_below_to_below(hass, calls): hass.states.async_set('test.entity', 9, context=context) await hass.async_block_till_done() assert 1 == len(calls) - assert calls[0].context is context + assert calls[0].context.parent_id == context.id # already below so should not fire again hass.states.async_set('test.entity', 5) diff --git a/tests/components/automation/test_state.py b/tests/components/automation/test_state.py index abe02638f26..53c1eaab3d9 100644 --- a/tests/components/automation/test_state.py +++ b/tests/components/automation/test_state.py @@ -55,7 +55,7 @@ async def test_if_fires_on_entity_change(hass, calls): hass.states.async_set('test.entity', 'world', context=context) await hass.async_block_till_done() assert 1 == len(calls) - assert calls[0].context is context + assert calls[0].context.parent_id == context.id assert 'state - test.entity - hello - world - None' == \ calls[0].data['some'] diff --git a/tests/components/automation/test_template.py b/tests/components/automation/test_template.py index c326c7f03f4..f803f97f4ab 100644 --- a/tests/components/automation/test_template.py +++ b/tests/components/automation/test_template.py @@ -257,7 +257,7 @@ async def test_if_fires_on_change_with_template_advanced(hass, calls): hass.states.async_set('test.entity', 'world', context=context) await hass.async_block_till_done() assert 1 == len(calls) - assert calls[0].context is context + assert calls[0].context.parent_id == context.id assert 'template - test.entity - hello - world' == \ calls[0].data['some'] diff --git a/tests/components/automation/test_zone.py b/tests/components/automation/test_zone.py index 04ffeaf13aa..d5bfd9fdf88 100644 --- a/tests/components/automation/test_zone.py +++ b/tests/components/automation/test_zone.py @@ -66,7 +66,7 @@ async def test_if_fires_on_zone_enter(hass, calls): await hass.async_block_till_done() assert 1 == len(calls) - assert calls[0].context is context + assert calls[0].context.parent_id == context.id assert 'zone - test.entity - hello - hello - test' == \ calls[0].data['some'] diff --git a/tests/test_core.py b/tests/test_core.py index e2ed249f441..5e23fab36e7 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -310,6 +310,7 @@ class TestEvent(unittest.TestCase): 'time_fired': now, 'context': { 'id': event.context.id, + 'parent_id': None, 'user_id': event.context.user_id, }, } @@ -1076,3 +1077,16 @@ async def test_service_call_event_contains_original_data(hass): assert len(calls) == 1 assert calls[0].data['number'] == 23 assert calls[0].context is context + + +def test_context(): + """Test context init.""" + c = ha.Context() + assert c.user_id is None + assert c.parent_id is None + assert c.id is not None + + c = ha.Context(23, 100) + assert c.user_id == 23 + assert c.parent_id == 100 + assert c.id is not None From 1a9dcaefd21c41a838b7919d3d0644096a8a0d2c Mon Sep 17 00:00:00 2001 From: damarco Date: Fri, 1 Mar 2019 19:47:20 +0100 Subject: [PATCH 035/291] Bump zigpy-deconz (#21566) --- homeassistant/components/zha/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index 96c3a30d313..cafbae13421 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -33,7 +33,7 @@ REQUIREMENTS = [ 'zigpy-homeassistant==0.3.0', 'zigpy-xbee-homeassistant==0.1.2', 'zha-quirks==0.0.6', - 'zigpy-deconz==0.1.1' + 'zigpy-deconz==0.1.2' ] DEVICE_CONFIG_SCHEMA_ENTRY = vol.Schema({ diff --git a/requirements_all.txt b/requirements_all.txt index 93397ed18e8..b828b7f34b7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1822,7 +1822,7 @@ zhong_hong_hvac==1.0.9 ziggo-mediabox-xl==1.1.0 # homeassistant.components.zha -zigpy-deconz==0.1.1 +zigpy-deconz==0.1.2 # homeassistant.components.zha zigpy-homeassistant==0.3.0 From b8ec74cc1575c78e703d29535cfadac165db4b92 Mon Sep 17 00:00:00 2001 From: Wagner Sartori Junior Date: Sat, 2 Mar 2019 02:11:32 +0100 Subject: [PATCH 036/291] bump pyxeoma to 1.4.1 to fix and close #19306 (#21568) --- homeassistant/components/camera/xeoma.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/camera/xeoma.py b/homeassistant/components/camera/xeoma.py index c268c3533e0..74532a935fc 100644 --- a/homeassistant/components/camera/xeoma.py +++ b/homeassistant/components/camera/xeoma.py @@ -13,7 +13,7 @@ from homeassistant.const import ( CONF_HOST, CONF_NAME, CONF_PASSWORD, CONF_USERNAME) from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['pyxeoma==1.4.0'] +REQUIREMENTS = ['pyxeoma==1.4.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index b828b7f34b7..747118b507b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1453,7 +1453,7 @@ pywebpush==1.6.0 pywemo==0.4.34 # homeassistant.components.camera.xeoma -pyxeoma==1.4.0 +pyxeoma==1.4.1 # homeassistant.components.zabbix pyzabbix==0.7.4 From cd6c9231238ae5538e50fb44bfb87cb011fe0fdf Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Fri, 1 Mar 2019 20:15:36 -0500 Subject: [PATCH 037/291] fix exception (#21571) --- homeassistant/components/zha/api.py | 30 ++++++++++++++++------------- 1 file changed, 17 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/zha/api.py b/homeassistant/components/zha/api.py index 5895e4c14f8..6d79f3b3320 100644 --- a/homeassistant/components/zha/api.py +++ b/homeassistant/components/zha/api.py @@ -82,19 +82,23 @@ async def websocket_get_devices(hass, connection, msg): """Get ZHA devices.""" zha_gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] ha_device_registry = await async_get_registry(hass) - devices = [ - { - **device.device_info, - 'user_given_name': ha_device_registry.async_get_device( - {(DOMAIN, str(device.ieee))}, set()).name_by_user, - 'device_reg_id': ha_device_registry.async_get_device( - {(DOMAIN, str(device.ieee))}, set()).id, - 'entities': [{ - 'entity_id': entity_ref.reference_id, - NAME: entity_ref.device_info[NAME] - } for entity_ref in zha_gateway.device_registry[device.ieee]] - } for device in zha_gateway.devices.values() - ] + + devices = [] + for device in zha_gateway.devices.values(): + ret_device = {} + ret_device.update(device.device_info) + ret_device['entities'] = [{ + 'entity_id': entity_ref.reference_id, + NAME: entity_ref.device_info[NAME] + } for entity_ref in zha_gateway.device_registry[device.ieee]] + + reg_device = ha_device_registry.async_get_device( + {(DOMAIN, str(device.ieee))}, set()) + if reg_device is not None: + ret_device['user_given_name'] = reg_device.name_by_user + ret_device['device_reg_id'] = reg_device.id + + devices.append(ret_device) connection.send_result(msg[ID], devices) From 0903bd92f0b8071da00f1ca1da563482502d2945 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Fri, 1 Mar 2019 23:13:55 -0600 Subject: [PATCH 038/291] Add config entry remove callback (#21576) --- homeassistant/config_entries.py | 13 ++++++++++ tests/common.py | 5 +++- tests/test_config_entries.py | 46 ++++++++++++++++++++++++++++++++- 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 69696561303..1036c02fd0d 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -378,6 +378,17 @@ class ConfigEntry: self.state = ENTRY_STATE_FAILED_UNLOAD return False + async def async_remove(self, hass: HomeAssistant) -> None: + """Invoke remove callback on component.""" + component = getattr(hass.components, self.domain) + if not hasattr(component, 'async_remove_entry'): + return + try: + await component.async_remove_entry(hass, self) + except Exception: # pylint: disable=broad-except + _LOGGER.exception('Error calling entry remove callback %s for %s', + self.title, component.DOMAIN) + async def async_migrate(self, hass: HomeAssistant) -> bool: """Migrate an entry. @@ -499,6 +510,8 @@ class ConfigEntries: else: unload_success = await self.async_unload(entry_id) + await entry.async_remove(self.hass) + self._entries.remove(entry) self._async_schedule_save() diff --git a/tests/common.py b/tests/common.py index a55546da73b..8681db1b4f3 100644 --- a/tests/common.py +++ b/tests/common.py @@ -452,7 +452,7 @@ class MockModule: requirements=None, config_schema=None, platform_schema=None, platform_schema_base=None, async_setup=None, async_setup_entry=None, async_unload_entry=None, - async_migrate_entry=None): + async_migrate_entry=None, async_remove_entry=None): """Initialize the mock module.""" self.__name__ = 'homeassistant.components.{}'.format(domain) self.DOMAIN = domain @@ -487,6 +487,9 @@ class MockModule: if async_migrate_entry is not None: self.async_migrate_entry = async_migrate_entry + if async_remove_entry is not None: + self.async_remove_entry = async_remove_entry + class MockPlatform: """Provide a fake platform.""" diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index e7a5b763796..324db971583 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -173,6 +173,9 @@ async def test_remove_entry(hass, manager): assert result return result + mock_remove_entry = MagicMock( + side_effect=lambda *args, **kwargs: mock_coro()) + entity = MockEntity( unique_id='1234', name='Test Entity', @@ -185,7 +188,8 @@ async def test_remove_entry(hass, manager): loader.set_component(hass, 'test', MockModule( 'test', async_setup_entry=mock_setup_entry, - async_unload_entry=mock_unload_entry + async_unload_entry=mock_unload_entry, + async_remove_entry=mock_remove_entry )) loader.set_component( hass, 'light.test', @@ -227,6 +231,9 @@ async def test_remove_entry(hass, manager): 'require_restart': False } + # Check the remove callback was invoked. + assert mock_remove_entry.call_count == 1 + # Check that config entry was removed. assert [item.entry_id for item in manager.async_entries()] == \ ['test1', 'test3'] @@ -241,6 +248,43 @@ async def test_remove_entry(hass, manager): assert entity_entry.config_entry_id is None +async def test_remove_entry_handles_callback_error(hass, manager): + """Test that exceptions in the remove callback are handled.""" + mock_setup_entry = MagicMock(return_value=mock_coro(True)) + mock_unload_entry = MagicMock(return_value=mock_coro(True)) + mock_remove_entry = MagicMock( + side_effect=lambda *args, **kwargs: mock_coro()) + loader.set_component(hass, 'test', MockModule( + 'test', + async_setup_entry=mock_setup_entry, + async_unload_entry=mock_unload_entry, + async_remove_entry=mock_remove_entry + )) + entry = MockConfigEntry( + domain='test', + entry_id='test1', + ) + entry.add_to_manager(manager) + # Check all config entries exist + assert [item.entry_id for item in manager.async_entries()] == \ + ['test1'] + # Setup entry + await entry.async_setup(hass) + await hass.async_block_till_done() + + # Remove entry + result = await manager.async_remove('test1') + await hass.async_block_till_done() + # Check that unload went well and so no need to restart + assert result == { + 'require_restart': False + } + # Check the remove callback was invoked. + assert mock_remove_entry.call_count == 1 + # Check that config entry was removed. + assert [item.entry_id for item in manager.async_entries()] == [] + + @asyncio.coroutine def test_remove_entry_raises(hass, manager): """Test if a component raises while removing entry.""" From 655ada1374a66ee10950aa2310f3c42552920651 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Fri, 1 Mar 2019 23:08:20 -0800 Subject: [PATCH 039/291] mobile_app component (#21475) * Initial pass of a mobile_app component * Fully support encryption, validation for the webhook payloads, and other general improvements * Return same format as original API calls * Minor encryption fixes, logging improvements * Migrate Owntracks to use the superior PyNaCl instead of libnacl, mark it as a requirement in mobile_app * Add mobile_app to .coveragerc * Dont manually b64decode on OT * Initial requested changes * Round two of fixes * Initial mobile_app tests * Dont allow making registration requests for same/existing device * Test formatting fixes * Add mobile_app to default_config * Add some more keys allowed in registration payloads * Add support for getting a single device, updating a device, getting all devices. Also change from /api/mobile_app/register to /api/mobile_app/devices * Change device_id to fingerprint * Next round of changes * Add keyword args and pass context on all relevant calls * Remove SingleDeviceView in favor of webhook type to update registration * Only allow some properties to be updated on registrations, rename integration_data to app_data * Add call service test, ensure events actually fire, only run the encryption tests if sodium is installed * pylint * Fix OwnTracks test * Fix iteration of devices and remove device_for_webhook_id --- .coveragerc | 3 +- .../components/default_config/__init__.py | 1 + .../components/mobile_app/__init__.py | 355 ++++++++++++++++++ .../components/owntracks/__init__.py | 2 +- .../components/owntracks/config_flow.py | 2 +- .../components/owntracks/device_tracker.py | 10 +- requirements_all.txt | 7 +- requirements_test_all.txt | 4 + script/gen_requirements_all.py | 1 + .../device_tracker/test_owntracks.py | 26 +- tests/components/mobile_app/__init__.py | 1 + tests/components/mobile_app/test_init.py | 275 ++++++++++++++ 12 files changed, 666 insertions(+), 21 deletions(-) create mode 100644 homeassistant/components/mobile_app/__init__.py create mode 100644 tests/components/mobile_app/__init__.py create mode 100644 tests/components/mobile_app/test_init.py diff --git a/.coveragerc b/.coveragerc index 03dab64e32c..ef64886f776 100644 --- a/.coveragerc +++ b/.coveragerc @@ -320,6 +320,7 @@ omit = homeassistant/components/media_player/yamaha.py homeassistant/components/media_player/ziggo_mediabox_xl.py homeassistant/components/meteo_france/* + homeassistant/components/mobile_app/* homeassistant/components/mochad/* homeassistant/components/modbus/* homeassistant/components/mychevy/* @@ -384,7 +385,7 @@ omit = homeassistant/components/point/* homeassistant/components/prometheus/* homeassistant/components/ps4/__init__.py - homeassistant/components/ps4/media_player.py + homeassistant/components/ps4/media_player.py homeassistant/components/qwikswitch/* homeassistant/components/rachio/* homeassistant/components/rainbird/* diff --git a/homeassistant/components/default_config/__init__.py b/homeassistant/components/default_config/__init__.py index d56cf9a4ee8..badc403c7c8 100644 --- a/homeassistant/components/default_config/__init__.py +++ b/homeassistant/components/default_config/__init__.py @@ -11,6 +11,7 @@ DEPENDENCIES = ( 'history', 'logbook', 'map', + 'mobile_app', 'person', 'script', 'sun', diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py new file mode 100644 index 00000000000..19a81b4aa45 --- /dev/null +++ b/homeassistant/components/mobile_app/__init__.py @@ -0,0 +1,355 @@ +"""Support for native mobile apps.""" +import logging +import json +from functools import partial + +import voluptuous as vol +from aiohttp.web import json_response, Response +from aiohttp.web_exceptions import HTTPBadRequest + +from homeassistant import config_entries +from homeassistant.auth.util import generate_secret +import homeassistant.core as ha +from homeassistant.core import Context +from homeassistant.components import webhook +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.components.device_tracker import ( + DOMAIN as DEVICE_TRACKER_DOMAIN, SERVICE_SEE as DEVICE_TRACKER_SEE, + SERVICE_SEE_PAYLOAD_SCHEMA as SEE_SCHEMA) +from homeassistant.const import (ATTR_DOMAIN, ATTR_SERVICE, ATTR_SERVICE_DATA, + HTTP_BAD_REQUEST, HTTP_CREATED, + HTTP_INTERNAL_SERVER_ERROR, CONF_WEBHOOK_ID) +from homeassistant.exceptions import (HomeAssistantError, ServiceNotFound, + TemplateError) +from homeassistant.helpers import config_validation as cv, template +from homeassistant.helpers.typing import HomeAssistantType + +REQUIREMENTS = ['PyNaCl==1.3.0'] + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'mobile_app' + +DEPENDENCIES = ['device_tracker', 'http', 'webhook'] + +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 + +CONF_SECRET = 'secret' +CONF_USER_ID = 'user_id' + +ATTR_APP_DATA = 'app_data' +ATTR_APP_ID = 'app_id' +ATTR_APP_NAME = 'app_name' +ATTR_APP_VERSION = 'app_version' +ATTR_DEVICE_NAME = 'device_name' +ATTR_MANUFACTURER = 'manufacturer' +ATTR_MODEL = 'model' +ATTR_OS_VERSION = 'os_version' +ATTR_SUPPORTS_ENCRYPTION = 'supports_encryption' + +ATTR_EVENT_DATA = 'event_data' +ATTR_EVENT_TYPE = 'event_type' + +ATTR_TEMPLATE = 'template' +ATTR_TEMPLATE_VARIABLES = 'variables' + +ATTR_WEBHOOK_DATA = 'data' +ATTR_WEBHOOK_ENCRYPTED = 'encrypted' +ATTR_WEBHOOK_ENCRYPTED_DATA = 'encrypted_data' +ATTR_WEBHOOK_TYPE = 'type' + +WEBHOOK_TYPE_CALL_SERVICE = 'call_service' +WEBHOOK_TYPE_FIRE_EVENT = 'fire_event' +WEBHOOK_TYPE_RENDER_TEMPLATE = 'render_template' +WEBHOOK_TYPE_UPDATE_LOCATION = 'update_location' +WEBHOOK_TYPE_UPDATE_REGISTRATION = 'update_registration' + +WEBHOOK_TYPES = [WEBHOOK_TYPE_CALL_SERVICE, WEBHOOK_TYPE_FIRE_EVENT, + WEBHOOK_TYPE_RENDER_TEMPLATE, WEBHOOK_TYPE_UPDATE_LOCATION, + WEBHOOK_TYPE_UPDATE_REGISTRATION] + +REGISTER_DEVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_APP_DATA, default={}): dict, + vol.Required(ATTR_APP_ID): cv.string, + vol.Optional(ATTR_APP_NAME): cv.string, + vol.Required(ATTR_APP_VERSION): cv.string, + vol.Required(ATTR_DEVICE_NAME): cv.string, + vol.Required(ATTR_MANUFACTURER): cv.string, + vol.Required(ATTR_MODEL): cv.string, + vol.Optional(ATTR_OS_VERSION): cv.string, + vol.Required(ATTR_SUPPORTS_ENCRYPTION, default=False): cv.boolean, +}) + +UPDATE_DEVICE_SCHEMA = vol.Schema({ + vol.Optional(ATTR_APP_DATA, default={}): dict, + vol.Required(ATTR_APP_VERSION): cv.string, + vol.Required(ATTR_DEVICE_NAME): cv.string, + vol.Required(ATTR_MANUFACTURER): cv.string, + vol.Required(ATTR_MODEL): cv.string, + vol.Optional(ATTR_OS_VERSION): cv.string, +}) + +WEBHOOK_PAYLOAD_SCHEMA = vol.Schema({ + vol.Required(ATTR_WEBHOOK_TYPE): vol.In(WEBHOOK_TYPES), + vol.Required(ATTR_WEBHOOK_DATA, default={}): dict, + vol.Optional(ATTR_WEBHOOK_ENCRYPTED, default=False): cv.boolean, + vol.Optional(ATTR_WEBHOOK_ENCRYPTED_DATA): cv.string, +}) + +CALL_SERVICE_SCHEMA = vol.Schema({ + vol.Required(ATTR_DOMAIN): cv.string, + vol.Required(ATTR_SERVICE): cv.string, + vol.Optional(ATTR_SERVICE_DATA, default={}): dict, +}) + +FIRE_EVENT_SCHEMA = vol.Schema({ + vol.Required(ATTR_EVENT_TYPE): cv.string, + vol.Optional(ATTR_EVENT_DATA, default={}): dict, +}) + +RENDER_TEMPLATE_SCHEMA = vol.Schema({ + vol.Required(ATTR_TEMPLATE): cv.string, + vol.Optional(ATTR_TEMPLATE_VARIABLES, default={}): dict, +}) + +WEBHOOK_SCHEMAS = { + WEBHOOK_TYPE_CALL_SERVICE: CALL_SERVICE_SCHEMA, + WEBHOOK_TYPE_FIRE_EVENT: FIRE_EVENT_SCHEMA, + WEBHOOK_TYPE_RENDER_TEMPLATE: RENDER_TEMPLATE_SCHEMA, + WEBHOOK_TYPE_UPDATE_LOCATION: SEE_SCHEMA, + WEBHOOK_TYPE_UPDATE_REGISTRATION: UPDATE_DEVICE_SCHEMA, +} + + +def get_cipher(): + """Return decryption function and length of key. + + Async friendly. + """ + from nacl.secret import SecretBox + from nacl.encoding import Base64Encoder + + def decrypt(ciphertext, key): + """Decrypt ciphertext using key.""" + return SecretBox(key).decrypt(ciphertext, encoder=Base64Encoder) + return (SecretBox.KEY_SIZE, decrypt) + + +def _decrypt_payload(key, ciphertext): + """Decrypt encrypted payload.""" + try: + keylen, decrypt = get_cipher() + except OSError: + _LOGGER.warning( + "Ignoring encrypted payload because libsodium not installed") + return None + + if key is None: + _LOGGER.warning( + "Ignoring encrypted payload because no decryption key known") + return None + + key = key.encode("utf-8") + key = key[:keylen] + key = key.ljust(keylen, b'\0') + + try: + message = decrypt(ciphertext, key) + message = json.loads(message.decode("utf-8")) + _LOGGER.debug("Successfully decrypted mobile_app payload") + return message + except ValueError: + _LOGGER.warning("Ignoring encrypted payload because unable to decrypt") + return None + + +def context(device): + """Generate a context from a request.""" + return Context(user_id=device[CONF_USER_ID]) + + +async def handle_webhook(store, hass: HomeAssistantType, webhook_id: str, + request): + """Handle webhook callback.""" + device = hass.data[DOMAIN][webhook_id] + + try: + req_data = await request.json() + except ValueError: + _LOGGER.warning('Received invalid JSON from mobile_app') + return json_response([], status=HTTP_BAD_REQUEST) + + try: + req_data = WEBHOOK_PAYLOAD_SCHEMA(req_data) + except vol.Invalid as ex: + err = vol.humanize.humanize_error(req_data, ex) + _LOGGER.error('Received invalid webhook payload: %s', err) + return Response(status=200) + + webhook_type = req_data[ATTR_WEBHOOK_TYPE] + + webhook_payload = req_data.get(ATTR_WEBHOOK_DATA, {}) + + if req_data[ATTR_WEBHOOK_ENCRYPTED]: + enc_data = req_data[ATTR_WEBHOOK_ENCRYPTED_DATA] + webhook_payload = _decrypt_payload(device[CONF_SECRET], enc_data) + + try: + data = WEBHOOK_SCHEMAS[webhook_type](webhook_payload) + except vol.Invalid as ex: + err = vol.humanize.humanize_error(webhook_payload, ex) + _LOGGER.error('Received invalid webhook payload: %s', err) + return Response(status=200) + + if webhook_type == WEBHOOK_TYPE_CALL_SERVICE: + try: + await hass.services.async_call(data[ATTR_DOMAIN], + data[ATTR_SERVICE], + data[ATTR_SERVICE_DATA], + blocking=True, + context=context(device)) + except (vol.Invalid, ServiceNotFound): + raise HTTPBadRequest() + + return Response(status=200) + + if webhook_type == WEBHOOK_TYPE_FIRE_EVENT: + event_type = data[ATTR_EVENT_TYPE] + hass.bus.async_fire(event_type, data[ATTR_EVENT_DATA], + ha.EventOrigin.remote, context=context(device)) + return Response(status=200) + + if webhook_type == WEBHOOK_TYPE_RENDER_TEMPLATE: + try: + tpl = template.Template(data[ATTR_TEMPLATE], hass) + rendered = tpl.async_render(data.get(ATTR_TEMPLATE_VARIABLES)) + return json_response({"rendered": rendered}) + except (ValueError, TemplateError) as ex: + return json_response(({"error": ex}), status=HTTP_BAD_REQUEST) + + if webhook_type == WEBHOOK_TYPE_UPDATE_LOCATION: + await hass.services.async_call(DEVICE_TRACKER_DOMAIN, + DEVICE_TRACKER_SEE, data, + blocking=True, context=context(device)) + return Response(status=200) + + if webhook_type == WEBHOOK_TYPE_UPDATE_REGISTRATION: + data[ATTR_APP_ID] = device[ATTR_APP_ID] + data[ATTR_APP_NAME] = device[ATTR_APP_NAME] + data[ATTR_SUPPORTS_ENCRYPTION] = device[ATTR_SUPPORTS_ENCRYPTION] + data[CONF_SECRET] = device[CONF_SECRET] + data[CONF_USER_ID] = device[CONF_USER_ID] + data[CONF_WEBHOOK_ID] = device[CONF_WEBHOOK_ID] + + hass.data[DOMAIN][webhook_id] = data + + try: + await store.async_save(hass.data[DOMAIN]) + except HomeAssistantError as ex: + _LOGGER.error("Error updating mobile_app registration: %s", ex) + return Response(status=200) + + return json_response(safe_device(data)) + + +def supports_encryption(): + """Test if we support encryption.""" + try: + import nacl # noqa pylint: disable=unused-import + return True + except OSError: + return False + + +def safe_device(device: dict): + """Return a device without webhook_id or secret.""" + return { + ATTR_APP_DATA: device[ATTR_APP_DATA], + ATTR_APP_ID: device[ATTR_APP_ID], + ATTR_APP_NAME: device[ATTR_APP_NAME], + ATTR_APP_VERSION: device[ATTR_APP_VERSION], + ATTR_DEVICE_NAME: device[ATTR_DEVICE_NAME], + ATTR_MANUFACTURER: device[ATTR_MANUFACTURER], + ATTR_MODEL: device[ATTR_MODEL], + ATTR_OS_VERSION: device[ATTR_OS_VERSION], + ATTR_SUPPORTS_ENCRYPTION: device[ATTR_SUPPORTS_ENCRYPTION], + } + + +def register_device_webhook(hass: HomeAssistantType, store, device): + """Register the webhook for a device.""" + device_name = 'Mobile App: {}'.format(device[ATTR_DEVICE_NAME]) + webhook_id = device[CONF_WEBHOOK_ID] + webhook.async_register(hass, DOMAIN, device_name, webhook_id, + partial(handle_webhook, store)) + + +async def async_setup(hass, config): + """Set up the mobile app component.""" + conf = config.get(DOMAIN) + + store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + app_config = await store.async_load() + if app_config is None: + app_config = {} + + hass.data[DOMAIN] = app_config + + for device in app_config.values(): + register_device_webhook(hass, store, device) + + if conf is not None: + hass.async_create_task(hass.config_entries.flow.async_init( + DOMAIN, context={'source': config_entries.SOURCE_IMPORT})) + + hass.http.register_view(DevicesView(store)) + + return True + + +async def async_setup_entry(hass, entry): + """Set up an mobile_app entry.""" + return True + + +class DevicesView(HomeAssistantView): + """A view that accepts device registration requests.""" + + url = '/api/mobile_app/devices' + name = 'api:mobile_app:register-device' + + def __init__(self, store): + """Initialize the view.""" + self._store = store + + @RequestDataValidator(REGISTER_DEVICE_SCHEMA) + async def post(self, request, data): + """Handle the POST request for device registration.""" + hass = request.app['hass'] + + resp = {} + + webhook_id = generate_secret() + + data[CONF_WEBHOOK_ID] = resp[CONF_WEBHOOK_ID] = webhook_id + + if data[ATTR_SUPPORTS_ENCRYPTION] and supports_encryption(): + secret = generate_secret(16) + + data[CONF_SECRET] = resp[CONF_SECRET] = secret + + data[CONF_USER_ID] = request['hass_user'].id + + hass.data[DOMAIN][webhook_id] = data + + try: + await self._store.async_save(hass.data[DOMAIN]) + except HomeAssistantError: + return self.json_message("Error saving device.", + HTTP_INTERNAL_SERVER_ERROR) + + register_device_webhook(hass, self._store, data) + + return self.json(resp, status_code=HTTP_CREATED) diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index cc918dcf674..c0d3d152270 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -16,7 +16,7 @@ from homeassistant.setup import async_when_setup from .config_flow import CONF_SECRET -REQUIREMENTS = ['libnacl==1.6.1'] +REQUIREMENTS = ['PyNaCl==1.3.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/owntracks/config_flow.py b/homeassistant/components/owntracks/config_flow.py index 6818efbbf75..59e8c4825df 100644 --- a/homeassistant/components/owntracks/config_flow.py +++ b/homeassistant/components/owntracks/config_flow.py @@ -9,7 +9,7 @@ CONF_SECRET = 'secret' def supports_encryption(): """Test if we support encryption.""" try: - import libnacl # noqa pylint: disable=unused-import + import nacl # noqa pylint: disable=unused-import return True except OSError: return False diff --git a/homeassistant/components/owntracks/device_tracker.py b/homeassistant/components/owntracks/device_tracker.py index e85ebbe6fe1..be8698a47b1 100644 --- a/homeassistant/components/owntracks/device_tracker.py +++ b/homeassistant/components/owntracks/device_tracker.py @@ -4,7 +4,6 @@ Device tracker platform that adds support for OwnTracks over MQTT. For more details about this platform, please refer to the documentation at https://home-assistant.io/components/device_tracker.owntracks/ """ -import base64 import json import logging @@ -37,13 +36,13 @@ def get_cipher(): Async friendly. """ - from libnacl import crypto_secretbox_KEYBYTES as KEYLEN - from libnacl.secret import SecretBox + from nacl.secret import SecretBox + from nacl.encoding import Base64Encoder def decrypt(ciphertext, key): """Decrypt ciphertext using key.""" - return SecretBox(key).decrypt(ciphertext) - return (KEYLEN, decrypt) + return SecretBox(key).decrypt(ciphertext, encoder=Base64Encoder) + return (SecretBox.KEY_SIZE, decrypt) def _parse_topic(topic, subscribe_topic): @@ -141,7 +140,6 @@ def _decrypt_payload(secret, topic, ciphertext): key = key.ljust(keylen, b'\0') try: - ciphertext = base64.b64decode(ciphertext) message = decrypt(ciphertext, key) message = message.decode("utf-8") _LOGGER.debug("Decrypted payload: %s", message) diff --git a/requirements_all.txt b/requirements_all.txt index 747118b507b..452ea63324d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -50,6 +50,10 @@ PyMVGLive==1.1.4 # homeassistant.components.arduino PyMata==2.14 +# homeassistant.components.mobile_app +# homeassistant.components.owntracks +PyNaCl==1.3.0 + # homeassistant.auth.mfa_modules.totp PyQRCode==1.2.1 @@ -608,9 +612,6 @@ konnected==0.1.4 # homeassistant.components.eufy lakeside==0.12 -# homeassistant.components.owntracks -libnacl==1.6.1 - # homeassistant.components.dyson libpurecoollink==0.4.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8513a00ad30..0fa31ed80fe 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -21,6 +21,10 @@ requests_mock==1.5.2 # homeassistant.components.homekit HAP-python==2.4.2 +# homeassistant.components.mobile_app +# homeassistant.components.owntracks +PyNaCl==1.3.0 + # homeassistant.components.sensor.rmvtransport PyRMVtransport==0.1.3 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 09eb9f21d4a..7db76b1361b 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -108,6 +108,7 @@ TEST_REQUIREMENTS = ( 'pyupnp-async', 'pywebpush', 'pyHS100', + 'PyNaCl', 'regenmaschine', 'restrictedpython', 'rflink', diff --git a/tests/components/device_tracker/test_owntracks.py b/tests/components/device_tracker/test_owntracks.py index 1ac3fc4a194..8e868296703 100644 --- a/tests/components/device_tracker/test_owntracks.py +++ b/tests/components/device_tracker/test_owntracks.py @@ -1295,18 +1295,25 @@ async def test_unsupported_message(hass, context): def generate_ciphers(secret): """Generate test ciphers for the DEFAULT_LOCATION_MESSAGE.""" - # libnacl ciphertext generation will fail if the module + # PyNaCl ciphertext generation will fail if the module # cannot be imported. However, the test for decryption # also relies on this library and won't be run without it. import pickle import base64 try: - from libnacl import crypto_secretbox_KEYBYTES as KEYLEN - from libnacl.secret import SecretBox - key = secret.encode("utf-8")[:KEYLEN].ljust(KEYLEN, b'\0') - ctxt = base64.b64encode(SecretBox(key).encrypt(json.dumps( - DEFAULT_LOCATION_MESSAGE).encode("utf-8"))).decode("utf-8") + from nacl.secret import SecretBox + from nacl.encoding import Base64Encoder + + keylen = SecretBox.KEY_SIZE + key = secret.encode("utf-8") + key = key[:keylen] + key = key.ljust(keylen, b'\0') + + msg = json.dumps(DEFAULT_LOCATION_MESSAGE).encode("utf-8") + + ctxt = SecretBox(key).encrypt(msg, + encoder=Base64Encoder).decode("utf-8") except (ImportError, OSError): ctxt = '' @@ -1341,7 +1348,8 @@ def mock_cipher(): def mock_decrypt(ciphertext, key): """Decrypt/unpickle.""" import pickle - (mkey, plaintext) = pickle.loads(ciphertext) + import base64 + (mkey, plaintext) = pickle.loads(base64.b64decode(ciphertext)) if key != mkey: raise ValueError() return plaintext @@ -1443,9 +1451,9 @@ async def test_encrypted_payload_libsodium(hass, setup_comp): """Test sending encrypted message payload.""" try: # pylint: disable=unused-import - import libnacl # noqa: F401 + import nacl # noqa: F401 except (ImportError, OSError): - pytest.skip("libnacl/libsodium is not installed") + pytest.skip("PyNaCl/libsodium is not installed") return await setup_owntracks(hass, { diff --git a/tests/components/mobile_app/__init__.py b/tests/components/mobile_app/__init__.py new file mode 100644 index 00000000000..becdc2841f3 --- /dev/null +++ b/tests/components/mobile_app/__init__.py @@ -0,0 +1 @@ +"""Tests for mobile_app component.""" diff --git a/tests/components/mobile_app/test_init.py b/tests/components/mobile_app/test_init.py new file mode 100644 index 00000000000..d0c1ae02c6c --- /dev/null +++ b/tests/components/mobile_app/test_init.py @@ -0,0 +1,275 @@ +"""Test the mobile_app_http platform.""" +import pytest + +from homeassistant.setup import async_setup_component + +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.components.mobile_app import (DOMAIN, STORAGE_KEY, + STORAGE_VERSION, + CONF_SECRET, CONF_USER_ID) +from homeassistant.core import callback + +from tests.common import async_mock_service + +FIRE_EVENT = { + 'type': 'fire_event', + 'data': { + 'event_type': 'test_event', + 'event_data': { + 'hello': 'yo world' + } + } +} + +RENDER_TEMPLATE = { + 'type': 'render_template', + 'data': { + 'template': 'Hello world' + } +} + +CALL_SERVICE = { + 'type': 'call_service', + 'data': { + 'domain': 'test', + 'service': 'mobile_app', + 'service_data': { + 'foo': 'bar' + } + } +} + +REGISTER = { + 'app_data': {'foo': 'bar'}, + 'app_id': 'io.homeassistant.mobile_app_test', + 'app_name': 'Mobile App Tests', + 'app_version': '1.0.0', + 'device_name': 'Test 1', + 'manufacturer': 'mobile_app', + 'model': 'Test', + 'os_version': '1.0', + 'supports_encryption': True +} + +UPDATE = { + 'app_data': {'foo': 'bar'}, + 'app_version': '2.0.0', + 'device_name': 'Test 1', + 'manufacturer': 'mobile_app', + 'model': 'Test', + 'os_version': '1.0' +} + +# pylint: disable=redefined-outer-name + + +@pytest.fixture +def mobile_app_client(hass, aiohttp_client, hass_storage, hass_admin_user): + """mobile_app mock client.""" + hass_storage[STORAGE_KEY] = { + 'version': STORAGE_VERSION, + 'data': { + 'mobile_app_test': { + CONF_SECRET: '58eb127991594dad934d1584bdee5f27', + 'supports_encryption': True, + CONF_WEBHOOK_ID: 'mobile_app_test', + 'device_name': 'Test Device', + CONF_USER_ID: hass_admin_user.id, + } + } + } + + assert hass.loop.run_until_complete(async_setup_component( + hass, DOMAIN, { + DOMAIN: {} + })) + + return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + + +@pytest.fixture +async def mock_api_client(hass, hass_client): + """Provide an authenticated client for mobile_app to use.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + return await hass_client() + + +async def test_handle_render_template(mobile_app_client): + """Test that we render templates properly.""" + resp = await mobile_app_client.post( + '/api/webhook/mobile_app_test', + json=RENDER_TEMPLATE + ) + + assert resp.status == 200 + + json = await resp.json() + assert json == {'rendered': 'Hello world'} + + +async def test_handle_call_services(hass, mobile_app_client): + """Test that we call services properly.""" + calls = async_mock_service(hass, 'test', 'mobile_app') + + resp = await mobile_app_client.post( + '/api/webhook/mobile_app_test', + json=CALL_SERVICE + ) + + assert resp.status == 200 + + assert len(calls) == 1 + + +async def test_handle_fire_event(hass, mobile_app_client): + """Test that we can fire events.""" + events = [] + + @callback + def store_event(event): + """Helepr to store events.""" + events.append(event) + + hass.bus.async_listen('test_event', store_event) + + resp = await mobile_app_client.post( + '/api/webhook/mobile_app_test', + json=FIRE_EVENT + ) + + assert resp.status == 200 + text = await resp.text() + assert text == "" + + assert len(events) == 1 + assert events[0].data['hello'] == 'yo world' + + +async def test_update_registration(mobile_app_client, hass_client): + """Test that a we can update an existing registration via webhook.""" + mock_api_client = await hass_client() + register_resp = await mock_api_client.post( + '/api/mobile_app/devices', json=REGISTER + ) + + assert register_resp.status == 201 + register_json = await register_resp.json() + + webhook_id = register_json[CONF_WEBHOOK_ID] + + update_container = { + 'type': 'update_registration', + 'data': UPDATE + } + + update_resp = await mobile_app_client.post( + '/api/webhook/{}'.format(webhook_id), json=update_container + ) + + assert update_resp.status == 200 + update_json = await update_resp.json() + assert update_json['app_version'] == '2.0.0' + assert CONF_WEBHOOK_ID not in update_json + assert CONF_SECRET not in update_json + + +async def test_returns_error_incorrect_json(mobile_app_client, caplog): + """Test that an error is returned when JSON is invalid.""" + resp = await mobile_app_client.post( + '/api/webhook/mobile_app_test', + data='not json' + ) + + assert resp.status == 400 + json = await resp.json() + assert json == [] + assert 'invalid JSON' in caplog.text + + +async def test_handle_decryption(mobile_app_client): + """Test that we can encrypt/decrypt properly.""" + try: + # pylint: disable=unused-import + from nacl.secret import SecretBox # noqa: F401 + from nacl.encoding import Base64Encoder # noqa: F401 + except (ImportError, OSError): + pytest.skip("libnacl/libsodium is not installed") + return + + import json + + keylen = SecretBox.KEY_SIZE + key = "58eb127991594dad934d1584bdee5f27".encode("utf-8") + key = key[:keylen] + key = key.ljust(keylen, b'\0') + + payload = json.dumps({'template': 'Hello world'}).encode("utf-8") + + data = SecretBox(key).encrypt(payload, + encoder=Base64Encoder).decode("utf-8") + + container = { + 'type': 'render_template', + 'encrypted': True, + 'encrypted_data': data, + } + + resp = await mobile_app_client.post( + '/api/webhook/mobile_app_test', + json=container + ) + + assert resp.status == 200 + + json = await resp.json() + assert json == {'rendered': 'Hello world'} + + +async def test_register_device(hass_client, mock_api_client): + """Test that a device can be registered.""" + try: + # pylint: disable=unused-import + from nacl.secret import SecretBox # noqa: F401 + from nacl.encoding import Base64Encoder # noqa: F401 + except (ImportError, OSError): + pytest.skip("libnacl/libsodium is not installed") + return + + import json + + resp = await mock_api_client.post( + '/api/mobile_app/devices', json=REGISTER + ) + + assert resp.status == 201 + register_json = await resp.json() + assert CONF_WEBHOOK_ID in register_json + assert CONF_SECRET in register_json + + keylen = SecretBox.KEY_SIZE + key = register_json[CONF_SECRET].encode("utf-8") + key = key[:keylen] + key = key.ljust(keylen, b'\0') + + payload = json.dumps({'template': 'Hello world'}).encode("utf-8") + + data = SecretBox(key).encrypt(payload, + encoder=Base64Encoder).decode("utf-8") + + container = { + 'type': 'render_template', + 'encrypted': True, + 'encrypted_data': data, + } + + mobile_app_client = await hass_client() + + resp = await mobile_app_client.post( + '/api/webhook/{}'.format(register_json[CONF_WEBHOOK_ID]), + json=container + ) + + assert resp.status == 200 + + webhook_json = await resp.json() + assert webhook_json == {'rendered': 'Hello world'} From cd89809be520f6533014a9fb847f11dc22ccd97a Mon Sep 17 00:00:00 2001 From: emontnemery Date: Sat, 2 Mar 2019 08:09:12 +0100 Subject: [PATCH 040/291] Make time trigger data trigger.now local (#21544) * Make time trigger data trigger.now local * Make time pattern trigger data trigger.now local * Lint * Rework according to review comment * Lint --- homeassistant/helpers/event.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index c1dae00bed5..b55c259f503 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -370,7 +370,9 @@ def async_track_utc_time_change(hass, action, last_now = now if next_time <= now: - hass.async_run_job(action, event.data[ATTR_NOW]) + if local: + now = dt_util.as_local(now) + hass.async_run_job(action, now) calculate_next(now + timedelta(seconds=1)) # We can't use async_track_point_in_utc_time here because it would From f1b867dccb9efe2fb9ff3710f929a4a1579072b6 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Fri, 1 Mar 2019 23:09:31 -0800 Subject: [PATCH 041/291] Re-thrown exception occurred in the blocking service call (#21573) * Rethrown exception occurred in the actual service call * Fix lint and test --- .../components/websocket_api/commands.py | 9 +++- .../components/websocket_api/const.py | 1 + homeassistant/helpers/service.py | 10 +++- tests/components/deconz/test_climate.py | 14 ++++-- .../components/websocket_api/test_commands.py | 46 +++++++++++++++++++ tests/test_core.py | 42 ++++++++++++++++- 6 files changed, 114 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 34bb04cb394..33a41dc8511 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -3,7 +3,8 @@ import voluptuous as vol from homeassistant.const import MATCH_ALL, EVENT_TIME_CHANGED from homeassistant.core import callback, DOMAIN as HASS_DOMAIN -from homeassistant.exceptions import Unauthorized, ServiceNotFound +from homeassistant.exceptions import Unauthorized, ServiceNotFound, \ + HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.service import async_get_all_descriptions @@ -149,6 +150,12 @@ async def handle_call_service(hass, connection, msg): except ServiceNotFound: connection.send_message(messages.error_message( msg['id'], const.ERR_NOT_FOUND, 'Service not found.')) + except HomeAssistantError as err: + connection.send_message(messages.error_message( + msg['id'], const.ERR_HOME_ASSISTANT_ERROR, '{}'.format(err))) + except Exception as err: # pylint: disable=broad-except + connection.send_message(messages.error_message( + msg['id'], const.ERR_UNKNOWN_ERROR, '{}'.format(err))) @callback diff --git a/homeassistant/components/websocket_api/const.py b/homeassistant/components/websocket_api/const.py index fd8f7eb7b08..01145275b31 100644 --- a/homeassistant/components/websocket_api/const.py +++ b/homeassistant/components/websocket_api/const.py @@ -9,6 +9,7 @@ MAX_PENDING_MSG = 512 ERR_ID_REUSE = 'id_reuse' ERR_INVALID_FORMAT = 'invalid_format' ERR_NOT_FOUND = 'not_found' +ERR_HOME_ASSISTANT_ERROR = 'home_assistant_error' ERR_UNKNOWN_COMMAND = 'unknown_command' ERR_UNKNOWN_ERROR = 'unknown_error' ERR_UNAUTHORIZED = 'unauthorized' diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index d2211d031f5..22138d7c2aa 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -272,7 +272,10 @@ async def entity_service_call(hass, platforms, func, call, service_name=''): ] if tasks: - await asyncio.wait(tasks) + done, pending = await asyncio.wait(tasks) + assert not pending + for future in done: + future.result() # pop exception if have async def _handle_service_platform_call(func, data, entities, context): @@ -294,4 +297,7 @@ async def _handle_service_platform_call(func, data, entities, context): tasks.append(entity.async_update_ha_state(True)) if tasks: - await asyncio.wait(tasks) + done, pending = await asyncio.wait(tasks) + assert not pending + for future in done: + future.result() # pop exception if have diff --git a/tests/components/deconz/test_climate.py b/tests/components/deconz/test_climate.py index 13083594c8a..fa274f1d676 100644 --- a/tests/components/deconz/test_climate.py +++ b/tests/components/deconz/test_climate.py @@ -1,6 +1,8 @@ """deCONZ climate platform tests.""" from unittest.mock import Mock, patch +import asynctest + from homeassistant import config_entries from homeassistant.components import deconz from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -43,8 +45,14 @@ ENTRY_CONFIG = { async def setup_gateway(hass, data, allow_clip_sensor=True): """Load the deCONZ sensor platform.""" from pydeconz import DeconzSession - loop = Mock() - session = Mock() + + session = Mock(put=asynctest.CoroutineMock( + return_value=Mock(status=200, + json=asynctest.CoroutineMock(), + text=asynctest.CoroutineMock(), + ) + ) + ) ENTRY_CONFIG[deconz.const.CONF_ALLOW_CLIP_SENSOR] = allow_clip_sensor @@ -52,7 +60,7 @@ async def setup_gateway(hass, data, allow_clip_sensor=True): 1, deconz.DOMAIN, 'Mock Title', ENTRY_CONFIG, 'test', config_entries.CONN_CLASS_LOCAL_PUSH) gateway = deconz.DeconzGateway(hass, config_entry) - gateway.api = DeconzSession(loop, session, **config_entry.data) + gateway.api = DeconzSession(hass.loop, session, **config_entry.data) gateway.api.config = Mock() hass.data[deconz.DOMAIN] = gateway diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 78a5bf6d57e..c9ec04c5d7e 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -7,6 +7,7 @@ from homeassistant.components.websocket_api.auth import ( TYPE_AUTH, TYPE_AUTH_OK, TYPE_AUTH_REQUIRED ) from homeassistant.components.websocket_api import const, commands +from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component from tests.common import async_mock_service @@ -66,6 +67,51 @@ async def test_call_service_not_found(hass, websocket_client): assert msg['error']['code'] == const.ERR_NOT_FOUND +async def test_call_service_error(hass, websocket_client): + """Test call service command with error.""" + @callback + def ha_error_call(_): + raise HomeAssistantError('error_message') + + hass.services.async_register('domain_test', 'ha_error', ha_error_call) + + async def unknown_error_call(_): + raise ValueError('value_error') + + hass.services.async_register( + 'domain_test', 'unknown_error', unknown_error_call) + + await websocket_client.send_json({ + 'id': 5, + 'type': commands.TYPE_CALL_SERVICE, + 'domain': 'domain_test', + 'service': 'ha_error', + }) + + msg = await websocket_client.receive_json() + print(msg) + assert msg['id'] == 5 + assert msg['type'] == const.TYPE_RESULT + assert msg['success'] is False + assert msg['error']['code'] == 'home_assistant_error' + assert msg['error']['message'] == 'error_message' + + await websocket_client.send_json({ + 'id': 6, + 'type': commands.TYPE_CALL_SERVICE, + 'domain': 'domain_test', + 'service': 'unknown_error', + }) + + msg = await websocket_client.receive_json() + print(msg) + assert msg['id'] == 6 + assert msg['type'] == const.TYPE_RESULT + assert msg['success'] is False + assert msg['error']['code'] == 'unknown_error' + assert msg['error']['message'] == 'value_error' + + async def test_subscribe_unsubscribe_events(hass, websocket_client): """Test subscribe/unsubscribe events command.""" init_count = sum(hass.bus.async_listeners().values()) diff --git a/tests/test_core.py b/tests/test_core.py index 5e23fab36e7..cdcf30fa8b3 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -727,8 +727,7 @@ class TestServiceRegistry(unittest.TestCase): """Test registering and calling an async service.""" calls = [] - @asyncio.coroutine - def service_handler(call): + async def service_handler(call): """Service handler coroutine.""" calls.append(call) @@ -804,6 +803,45 @@ class TestServiceRegistry(unittest.TestCase): self.hass.block_till_done() assert len(calls_remove) == 0 + def test_async_service_raise_exception(self): + """Test registering and calling an async service raise exception.""" + async def service_handler(_): + """Service handler coroutine.""" + raise ValueError + + self.services.register( + 'test_domain', 'register_calls', service_handler) + self.hass.block_till_done() + + with pytest.raises(ValueError): + assert self.services.call('test_domain', 'REGISTER_CALLS', + blocking=True) + self.hass.block_till_done() + + # Non-blocking service call never throw exception + self.services.call('test_domain', 'REGISTER_CALLS', blocking=False) + self.hass.block_till_done() + + def test_callback_service_raise_exception(self): + """Test registering and calling an callback service raise exception.""" + @ha.callback + def service_handler(_): + """Service handler coroutine.""" + raise ValueError + + self.services.register( + 'test_domain', 'register_calls', service_handler) + self.hass.block_till_done() + + with pytest.raises(ValueError): + assert self.services.call('test_domain', 'REGISTER_CALLS', + blocking=True) + self.hass.block_till_done() + + # Non-blocking service call never throw exception + self.services.call('test_domain', 'REGISTER_CALLS', blocking=False) + self.hass.block_till_done() + class TestConfig(unittest.TestCase): """Test configuration methods.""" From 0c8a31b8ec11d123cf74664af33c5ca651ac6e17 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sat, 2 Mar 2019 08:23:45 +0100 Subject: [PATCH 042/291] Memory optimization for logbook (#21549) --- homeassistant/components/logbook/__init__.py | 111 +++++++++---------- homeassistant/scripts/benchmark/__init__.py | 11 +- tests/components/logbook/test_init.py | 106 +++++++++++------- 3 files changed, 125 insertions(+), 103 deletions(-) diff --git a/homeassistant/components/logbook/__init__.py b/homeassistant/components/logbook/__init__.py index dbedc8c6d70..7a0fb5e2654 100644 --- a/homeassistant/components/logbook/__init__.py +++ b/homeassistant/components/logbook/__init__.py @@ -146,8 +146,8 @@ class LogbookView(HomeAssistantView): def json_events(): """Fetch events and generate JSON.""" - return self.json(list( - _get_events(hass, self.config, start_day, end_day, entity_id))) + return self.json( + _get_events(hass, self.config, start_day, end_day, entity_id)) return await hass.async_add_job(json_events) @@ -393,11 +393,17 @@ def _generate_filter_from_config(config): def _get_events(hass, config, start_day, end_day, entity_id=None): """Get events for a period of time.""" from homeassistant.components.recorder.models import Events, States - from homeassistant.components.recorder.util import ( - execute, session_scope) + from homeassistant.components.recorder.util import session_scope entities_filter = _generate_filter_from_config(config) + def yield_events(query): + """Yield Events that are not filtered away.""" + for row in query.yield_per(500): + event = row.to_native() + if _keep_event(event, entities_filter): + yield event + with session_scope(hass=hass) as session: if entity_id is not None: entity_ids = [entity_id.lower()] @@ -413,77 +419,70 @@ def _get_events(hass, config, start_day, end_day, entity_id=None): States.entity_id.in_(entity_ids)) | (States.state_id.is_(None))) - events = execute(query) - - return humanify(hass, _exclude_events(events, entities_filter)) + return list(humanify(hass, yield_events(query))) -def _exclude_events(events, entities_filter): - filtered_events = [] - for event in events: - domain, entity_id = None, None +def _keep_event(event, entities_filter): + domain, entity_id = None, None - if event.event_type == EVENT_STATE_CHANGED: - entity_id = event.data.get('entity_id') + if event.event_type == EVENT_STATE_CHANGED: + entity_id = event.data.get('entity_id') - if entity_id is None: - continue + if entity_id is None: + return False - # Do not report on new entities - if event.data.get('old_state') is None: - continue + # Do not report on new entities + if event.data.get('old_state') is None: + return False - new_state = event.data.get('new_state') + new_state = event.data.get('new_state') - # Do not report on entity removal - if not new_state: - continue + # Do not report on entity removal + if not new_state: + return False - attributes = new_state.get('attributes', {}) + attributes = new_state.get('attributes', {}) - # If last_changed != last_updated only attributes have changed - # we do not report on that yet. - last_changed = new_state.get('last_changed') - last_updated = new_state.get('last_updated') - if last_changed != last_updated: - continue + # If last_changed != last_updated only attributes have changed + # we do not report on that yet. + last_changed = new_state.get('last_changed') + last_updated = new_state.get('last_updated') + if last_changed != last_updated: + return False - domain = split_entity_id(entity_id)[0] + domain = split_entity_id(entity_id)[0] - # Also filter auto groups. - if domain == 'group' and attributes.get('auto', False): - continue + # Also filter auto groups. + if domain == 'group' and attributes.get('auto', False): + return False - # exclude entities which are customized hidden - hidden = attributes.get(ATTR_HIDDEN, False) - if hidden: - continue + # exclude entities which are customized hidden + hidden = attributes.get(ATTR_HIDDEN, False) + if hidden: + return False - elif event.event_type == EVENT_LOGBOOK_ENTRY: - domain = event.data.get(ATTR_DOMAIN) - entity_id = event.data.get(ATTR_ENTITY_ID) + elif event.event_type == EVENT_LOGBOOK_ENTRY: + domain = event.data.get(ATTR_DOMAIN) + entity_id = event.data.get(ATTR_ENTITY_ID) - elif event.event_type == EVENT_AUTOMATION_TRIGGERED: - domain = 'automation' - entity_id = event.data.get(ATTR_ENTITY_ID) + elif event.event_type == EVENT_AUTOMATION_TRIGGERED: + domain = 'automation' + entity_id = event.data.get(ATTR_ENTITY_ID) - elif event.event_type == EVENT_SCRIPT_STARTED: - domain = 'script' - entity_id = event.data.get(ATTR_ENTITY_ID) + elif event.event_type == EVENT_SCRIPT_STARTED: + domain = 'script' + entity_id = event.data.get(ATTR_ENTITY_ID) - elif event.event_type == EVENT_ALEXA_SMART_HOME: - domain = 'alexa' + elif event.event_type == EVENT_ALEXA_SMART_HOME: + domain = 'alexa' - elif event.event_type == EVENT_HOMEKIT_CHANGED: - domain = DOMAIN_HOMEKIT + elif event.event_type == EVENT_HOMEKIT_CHANGED: + domain = DOMAIN_HOMEKIT - if not entity_id and domain: - entity_id = "%s." % (domain, ) + if not entity_id and domain: + entity_id = "%s." % (domain, ) - if not entity_id or entities_filter(entity_id): - filtered_events.append(event) - - return filtered_events + return not entity_id or entities_filter(entity_id) def _entry_message_from_state(domain, state): diff --git a/homeassistant/scripts/benchmark/__init__.py b/homeassistant/scripts/benchmark/__init__.py index f0df58a51f4..e231d7602cd 100644 --- a/homeassistant/scripts/benchmark/__init__.py +++ b/homeassistant/scripts/benchmark/__init__.py @@ -180,12 +180,15 @@ def _logbook_filtering(hass, last_changed, last_updated): 'new_state': new_state }) - events = [event] * 10**5 + def yield_events(event): + # pylint: disable=protected-access + entities_filter = logbook._generate_filter_from_config({}) + for _ in range(10**5): + if logbook._keep_event(event, entities_filter): + yield event start = timer() - # pylint: disable=protected-access - events = logbook._exclude_events(events, {}) - list(logbook.humanify(None, events)) + list(logbook.humanify(None, yield_events(event))) return timer() - start diff --git a/tests/components/logbook/test_init.py b/tests/components/logbook/test_init.py index c8ade907dd3..9d69affae4a 100644 --- a/tests/components/logbook/test_init.py +++ b/tests/components/logbook/test_init.py @@ -148,10 +148,11 @@ class TestComponentLogbook(unittest.TestCase): eventB = self.create_state_changed_event(pointB, entity_id2, 20) eventA.data['old_state'] = None - events = logbook._exclude_events( - (ha.Event(EVENT_HOMEASSISTANT_STOP), - eventA, eventB), - logbook._generate_filter_from_config({})) + entities_filter = logbook._generate_filter_from_config({}) + events = [e for e in + (ha.Event(EVENT_HOMEASSISTANT_STOP), + eventA, eventB) + if logbook._keep_event(e, entities_filter)] entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -172,10 +173,11 @@ class TestComponentLogbook(unittest.TestCase): eventB = self.create_state_changed_event(pointB, entity_id2, 20) eventA.data['new_state'] = None - events = logbook._exclude_events( - (ha.Event(EVENT_HOMEASSISTANT_STOP), - eventA, eventB), - logbook._generate_filter_from_config({})) + entities_filter = logbook._generate_filter_from_config({}) + events = [e for e in + (ha.Event(EVENT_HOMEASSISTANT_STOP), + eventA, eventB) + if logbook._keep_event(e, entities_filter)] entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -196,10 +198,11 @@ class TestComponentLogbook(unittest.TestCase): {ATTR_HIDDEN: 'true'}) eventB = self.create_state_changed_event(pointB, entity_id2, 20) - events = logbook._exclude_events( - (ha.Event(EVENT_HOMEASSISTANT_STOP), - eventA, eventB), - logbook._generate_filter_from_config({})) + entities_filter = logbook._generate_filter_from_config({}) + events = [e for e in + (ha.Event(EVENT_HOMEASSISTANT_STOP), + eventA, eventB) + if logbook._keep_event(e, entities_filter)] entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -223,9 +226,11 @@ class TestComponentLogbook(unittest.TestCase): ha.DOMAIN: {}, logbook.DOMAIN: {logbook.CONF_EXCLUDE: { logbook.CONF_ENTITIES: [entity_id, ]}}}) - events = logbook._exclude_events( - (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB), - logbook._generate_filter_from_config(config[logbook.DOMAIN])) + entities_filter = logbook._generate_filter_from_config( + config[logbook.DOMAIN]) + events = [e for e in + (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB) + if logbook._keep_event(e, entities_filter)] entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -249,11 +254,13 @@ class TestComponentLogbook(unittest.TestCase): ha.DOMAIN: {}, logbook.DOMAIN: {logbook.CONF_EXCLUDE: { logbook.CONF_DOMAINS: ['switch', 'alexa', DOMAIN_HOMEKIT]}}}) - events = logbook._exclude_events( - (ha.Event(EVENT_HOMEASSISTANT_START), - ha.Event(EVENT_ALEXA_SMART_HOME), - ha.Event(EVENT_HOMEKIT_CHANGED), eventA, eventB), - logbook._generate_filter_from_config(config[logbook.DOMAIN])) + entities_filter = logbook._generate_filter_from_config( + config[logbook.DOMAIN]) + events = [e for e in + (ha.Event(EVENT_HOMEASSISTANT_START), + ha.Event(EVENT_ALEXA_SMART_HOME), + ha.Event(EVENT_HOMEKIT_CHANGED), eventA, eventB) + if logbook._keep_event(e, entities_filter)] entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -283,9 +290,11 @@ class TestComponentLogbook(unittest.TestCase): ha.DOMAIN: {}, logbook.DOMAIN: {logbook.CONF_EXCLUDE: { logbook.CONF_ENTITIES: [entity_id, ]}}}) - events = logbook._exclude_events( - (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB), - logbook._generate_filter_from_config(config[logbook.DOMAIN])) + entities_filter = logbook._generate_filter_from_config( + config[logbook.DOMAIN]) + events = [e for e in + (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB) + if logbook._keep_event(e, entities_filter)] entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -316,9 +325,11 @@ class TestComponentLogbook(unittest.TestCase): ha.DOMAIN: {}, logbook.DOMAIN: {logbook.CONF_EXCLUDE: { logbook.CONF_ENTITIES: [entity_id, ]}}}) - events = logbook._exclude_events( - (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB), - logbook._generate_filter_from_config(config[logbook.DOMAIN])) + entities_filter = logbook._generate_filter_from_config( + config[logbook.DOMAIN]) + events = [e for e in + (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB) + if logbook._keep_event(e, entities_filter)] entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -342,9 +353,11 @@ class TestComponentLogbook(unittest.TestCase): ha.DOMAIN: {}, logbook.DOMAIN: {logbook.CONF_INCLUDE: { logbook.CONF_ENTITIES: [entity_id2, ]}}}) - events = logbook._exclude_events( - (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB), - logbook._generate_filter_from_config(config[logbook.DOMAIN])) + entities_filter = logbook._generate_filter_from_config( + config[logbook.DOMAIN]) + events = [e for e in + (ha.Event(EVENT_HOMEASSISTANT_STOP), eventA, eventB) + if logbook._keep_event(e, entities_filter)] entries = list(logbook.humanify(self.hass, events)) assert 2 == len(entries) @@ -378,10 +391,12 @@ class TestComponentLogbook(unittest.TestCase): ha.DOMAIN: {}, logbook.DOMAIN: {logbook.CONF_INCLUDE: { logbook.CONF_DOMAINS: ['sensor', 'alexa', DOMAIN_HOMEKIT]}}}) - events = logbook._exclude_events( - (ha.Event(EVENT_HOMEASSISTANT_START), - event_alexa, event_homekit, eventA, eventB), - logbook._generate_filter_from_config(config[logbook.DOMAIN])) + entities_filter = logbook._generate_filter_from_config( + config[logbook.DOMAIN]) + events = [e for e in + (ha.Event(EVENT_HOMEASSISTANT_START), + event_alexa, event_homekit, eventA, eventB) + if logbook._keep_event(e, entities_filter)] entries = list(logbook.humanify(self.hass, events)) assert 4 == len(entries) @@ -415,10 +430,13 @@ class TestComponentLogbook(unittest.TestCase): logbook.CONF_EXCLUDE: { logbook.CONF_DOMAINS: ['switch', ], logbook.CONF_ENTITIES: ['sensor.bli', ]}}}) - events = logbook._exclude_events( - (ha.Event(EVENT_HOMEASSISTANT_START), eventA1, eventA2, eventA3, - eventB1, eventB2), - logbook._generate_filter_from_config(config[logbook.DOMAIN])) + entities_filter = logbook._generate_filter_from_config( + config[logbook.DOMAIN]) + events = [e for e in + (ha.Event(EVENT_HOMEASSISTANT_START), + eventA1, eventA2, eventA3, + eventB1, eventB2) + if logbook._keep_event(e, entities_filter)] entries = list(logbook.humanify(self.hass, events)) assert 5 == len(entries) @@ -443,9 +461,10 @@ class TestComponentLogbook(unittest.TestCase): eventB = self.create_state_changed_event(pointA, entity_id2, 20, {'auto': True}) - events = logbook._exclude_events( - (eventA, eventB), - logbook._generate_filter_from_config({})) + entities_filter = logbook._generate_filter_from_config({}) + events = [e for e in + (eventA, eventB) + if logbook._keep_event(e, entities_filter)] entries = list(logbook.humanify(self.hass, events)) assert 1 == len(entries) @@ -463,9 +482,10 @@ class TestComponentLogbook(unittest.TestCase): eventB = self.create_state_changed_event( pointA, entity_id2, 20, last_changed=pointA, last_updated=pointB) - events = logbook._exclude_events( - (eventA, eventB), - logbook._generate_filter_from_config({})) + entities_filter = logbook._generate_filter_from_config({}) + events = [e for e in + (eventA, eventB) + if logbook._keep_event(e, entities_filter)] entries = list(logbook.humanify(self.hass, events)) assert 1 == len(entries) From f61f6504954a69edeebf571de4f826cd9c4f72d9 Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Sat, 2 Mar 2019 15:31:57 +0800 Subject: [PATCH 043/291] Get room hints from areas (#21519) * Get google room hint from area. * Test case for area code. * Updates as per code review. --- .../components/google_assistant/smart_home.py | 30 ++++-- .../google_assistant/test_smart_home.py | 100 +++++++++++++++++- 2 files changed, 121 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 21316c62085..31323decd6c 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -1,4 +1,5 @@ """Support for Google Assistant Smart Home API.""" +from asyncio import gather from collections.abc import Mapping from itertools import product import logging @@ -89,8 +90,7 @@ class _GoogleEntity: return [Trait(self.hass, state, self.config) for Trait in trait.TRAITS if Trait.supported(domain, features)] - @callback - def sync_serialize(self): + async def sync_serialize(self): """Serialize entity for a SYNC response. https://developers.google.com/actions/smarthome/create-app#actiondevicessync @@ -132,13 +132,31 @@ class _GoogleEntity: if aliases: device['name']['nicknames'] = aliases - # add room hint if annotated + for trt in traits: + device['attributes'].update(trt.sync_attributes()) + room = entity_config.get(CONF_ROOM_HINT) if room: device['roomHint'] = room + return device - for trt in traits: - device['attributes'].update(trt.sync_attributes()) + dev_reg, ent_reg, area_reg = await gather( + self.hass.helpers.device_registry.async_get_registry(), + self.hass.helpers.entity_registry.async_get_registry(), + self.hass.helpers.area_registry.async_get_registry(), + ) + + entity_entry = ent_reg.async_get(state.entity_id) + if not (entity_entry and entity_entry.device_id): + return device + + device_entry = dev_reg.devices.get(entity_entry.device_id) + if not (device_entry and device_entry.area_id): + return device + + area_entry = area_reg.areas.get(device_entry.area_id) + if area_entry and area_entry.name: + device['roomHint'] = area_entry.name return device @@ -253,7 +271,7 @@ async def async_devices_sync(hass, config, request_id, payload): continue entity = _GoogleEntity(hass, config, state) - serialized = entity.sync_serialize() + serialized = await entity.sync_serialize() if serialized is None: _LOGGER.debug("No mapping for %s domain", entity.state) diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index d1ec80844b6..76fb7b5ddde 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -1,4 +1,6 @@ """Test Google Smart Home.""" +import pytest + from homeassistant.core import State from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) @@ -11,6 +13,9 @@ from homeassistant.components.google_assistant import ( EVENT_COMMAND_RECEIVED, EVENT_QUERY_RECEIVED, EVENT_SYNC_RECEIVED) from homeassistant.components.light.demo import DemoLight +from homeassistant.helpers import device_registry +from tests.common import (mock_device_registry, mock_registry, + mock_area_registry) BASIC_CONFIG = helpers.Config( should_expose=lambda state: True, @@ -20,6 +25,17 @@ BASIC_CONFIG = helpers.Config( REQ_ID = 'ff36a3cc-ec34-11e6-b1a0-64510650abcf' +@pytest.fixture +def registries(hass): + """Registry mock setup.""" + from types import SimpleNamespace + ret = SimpleNamespace() + ret.entity = mock_registry(hass) + ret.device = mock_device_registry(hass) + ret.area = mock_area_registry(hass) + return ret + + async def test_sync_message(hass): """Test a sync message.""" light = DemoLight( @@ -98,6 +114,83 @@ async def test_sync_message(hass): } +async def test_sync_in_area(hass, registries): + """Test a sync message where room hint comes from area.""" + area = registries.area.async_create("Living Room") + + device = registries.device.async_get_or_create( + config_entry_id='1234', + connections={ + (device_registry.CONNECTION_NETWORK_MAC, '12:34:56:AB:CD:EF') + }) + registries.device.async_update_device(device.id, area_id=area.id) + + entity = registries.entity.async_get_or_create( + 'light', 'test', '1235', + suggested_object_id='demo_light', + device_id=device.id) + + light = DemoLight( + None, 'Demo Light', + state=False, + hs_color=(180, 75), + ) + light.hass = hass + light.entity_id = entity.entity_id + await light.async_update_ha_state() + + config = helpers.Config( + should_expose=lambda _: True, + allow_unlock=False, + agent_user_id='test-agent', + entity_config={} + ) + + events = [] + hass.bus.async_listen(EVENT_SYNC_RECEIVED, events.append) + + result = await sh.async_handle_message(hass, config, { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.SYNC" + }] + }) + + assert result == { + 'requestId': REQ_ID, + 'payload': { + 'agentUserId': 'test-agent', + 'devices': [{ + 'id': 'light.demo_light', + 'name': { + 'name': 'Demo Light' + }, + 'traits': [ + trait.TRAIT_BRIGHTNESS, + trait.TRAIT_ONOFF, + trait.TRAIT_COLOR_SPECTRUM, + trait.TRAIT_COLOR_TEMP, + ], + 'type': sh.TYPE_LIGHT, + 'willReportState': False, + 'attributes': { + 'colorModel': 'rgb', + 'temperatureMinK': 2000, + 'temperatureMaxK': 6535, + }, + 'roomHint': 'Living Room' + }] + } + } + await hass.async_block_till_done() + + assert len(events) == 1 + assert events[0].event_type == EVENT_SYNC_RECEIVED + assert events[0].data == { + 'request_id': REQ_ID, + } + + async def test_query_message(hass): """Test a sync message.""" light = DemoLight( @@ -350,11 +443,12 @@ async def test_raising_error_trait(hass): } -def test_serialize_input_boolean(): +async def test_serialize_input_boolean(hass): """Test serializing an input boolean entity.""" state = State('input_boolean.bla', 'on') - entity = sh._GoogleEntity(None, BASIC_CONFIG, state) - assert entity.sync_serialize() == { + entity = sh._GoogleEntity(hass, BASIC_CONFIG, state) + result = await entity.sync_serialize() + assert result == { 'id': 'input_boolean.bla', 'attributes': {}, 'name': {'name': 'bla'}, From 8e75bfb11ed1e7728b6c56ccb4ca5bba3e4119c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Sat, 2 Mar 2019 11:27:36 +0100 Subject: [PATCH 044/291] Upgrade PyXiaomiGateway library (#21582) --- homeassistant/components/xiaomi_aqara/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index 5e47adc47f9..19d7aaaa30d 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow -REQUIREMENTS = ['PyXiaomiGateway==0.11.2'] +REQUIREMENTS = ['PyXiaomiGateway==0.12.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 452ea63324d..8396683ac40 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -67,7 +67,7 @@ PyRMVtransport==0.1.3 PyTransportNSW==0.1.1 # homeassistant.components.xiaomi_aqara -PyXiaomiGateway==0.11.2 +PyXiaomiGateway==0.12.0 # homeassistant.components.rpi_gpio # RPi.GPIO==0.6.5 From e55ce61100364e5d49e63a91037499d34ae6fdbd Mon Sep 17 00:00:00 2001 From: Kevin Fronczak Date: Sat, 2 Mar 2019 05:28:44 -0500 Subject: [PATCH 045/291] Upgrade blinkpy==0.13.1 (Fixes #21559) (#21578) * Upgrade blinkpy with new api endpoint * Change wifi units to dBm --- homeassistant/components/blink/__init__.py | 4 ++-- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/blink/__init__.py b/homeassistant/components/blink/__init__.py index 8e95f967396..488209e3689 100644 --- a/homeassistant/components/blink/__init__.py +++ b/homeassistant/components/blink/__init__.py @@ -10,7 +10,7 @@ from homeassistant.const import ( CONF_BINARY_SENSORS, CONF_SENSORS, CONF_FILENAME, CONF_MONITORED_CONDITIONS, TEMP_FAHRENHEIT) -REQUIREMENTS = ['blinkpy==0.12.1'] +REQUIREMENTS = ['blinkpy==0.13.1'] _LOGGER = logging.getLogger(__name__) @@ -44,7 +44,7 @@ BINARY_SENSORS = { SENSORS = { TYPE_TEMPERATURE: ['Temperature', TEMP_FAHRENHEIT, 'mdi:thermometer'], TYPE_BATTERY: ['Battery', '%', 'mdi:battery-80'], - TYPE_WIFI_STRENGTH: ['Wifi Signal', 'bars', 'mdi:wifi-strength-2'], + TYPE_WIFI_STRENGTH: ['Wifi Signal', 'dBm', 'mdi:wifi-strength-2'], } BINARY_SENSOR_SCHEMA = vol.Schema({ diff --git a/requirements_all.txt b/requirements_all.txt index 8396683ac40..2ef12c7f7be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -206,7 +206,7 @@ bellows-homeassistant==0.7.1 bimmer_connected==0.5.3 # homeassistant.components.blink -blinkpy==0.12.1 +blinkpy==0.13.1 # homeassistant.components.light.blinksticklight blinkstick==1.1.8 From ed2b9e54837dcefd52ecae5461f9c93e691e889c Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sat, 2 Mar 2019 10:29:59 +0000 Subject: [PATCH 046/291] Centrally define Watt (#21570) * centralize Watt definition * lint --- homeassistant/components/apcupsd/sensor.py | 6 +++--- homeassistant/components/edp_redy/sensor.py | 5 +++-- homeassistant/components/enocean/sensor.py | 4 ++-- homeassistant/components/homematic/sensor.py | 4 ++-- homeassistant/components/homematicip_cloud/sensor.py | 4 ++-- homeassistant/components/isy994/sensor.py | 5 +++-- homeassistant/components/juicenet/sensor.py | 4 ++-- homeassistant/components/mysensors/sensor.py | 4 ++-- homeassistant/components/rfxtrx/__init__.py | 7 ++++--- homeassistant/components/sense/sensor.py | 3 ++- homeassistant/components/sensor/efergy.py | 6 +++--- homeassistant/components/sensor/eliqonline.py | 4 ++-- homeassistant/components/sensor/emoncms.py | 4 ++-- homeassistant/components/sensor/enphase_envoy.py | 5 +++-- homeassistant/components/sensor/greeneye_monitor.py | 4 ++-- homeassistant/components/sensor/neurio_energy.py | 4 ++-- homeassistant/components/sensor/nut.py | 7 ++++--- homeassistant/components/sensor/solaredge.py | 4 ++-- homeassistant/components/sensor/ted5000.py | 9 +++++---- homeassistant/components/sensor/volkszaehler.py | 8 ++++---- homeassistant/components/smappee/sensor.py | 6 ++++-- homeassistant/components/smartthings/sensor.py | 4 ++-- homeassistant/components/switch/fritzdect.py | 4 ++-- homeassistant/components/tellduslive/sensor.py | 4 ++-- homeassistant/components/zha/sensor.py | 6 +++--- homeassistant/const.py | 3 +++ 26 files changed, 70 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/apcupsd/sensor.py b/homeassistant/components/apcupsd/sensor.py index 4ebe0ac8aaf..09f9b324bdd 100644 --- a/homeassistant/components/apcupsd/sensor.py +++ b/homeassistant/components/apcupsd/sensor.py @@ -6,7 +6,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA import homeassistant.helpers.config_validation as cv from homeassistant.components import apcupsd -from homeassistant.const import (TEMP_CELSIUS, CONF_RESOURCES) +from homeassistant.const import (TEMP_CELSIUS, CONF_RESOURCES, POWER_WATT) from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) @@ -57,7 +57,7 @@ SENSOR_TYPES = { 'nombattv': ['Battery Nominal Voltage', 'V', 'mdi:flash'], 'nominv': ['Nominal Input Voltage', 'V', 'mdi:flash'], 'nomoutv': ['Nominal Output Voltage', 'V', 'mdi:flash'], - 'nompower': ['Nominal Output Power', 'W', 'mdi:flash'], + 'nompower': ['Nominal Output Power', POWER_WATT, 'mdi:flash'], 'nomapnt': ['Nominal Apparent Power', 'VA', 'mdi:flash'], 'numxfers': ['Transfer Count', '', 'mdi:counter'], 'outcurnt': ['Output Current', 'A', 'mdi:flash'], @@ -93,7 +93,7 @@ INFERRED_UNITS = { ' Volts': 'V', ' Ampere': 'A', ' Volt-Ampere': 'VA', - ' Watts': 'W', + ' Watts': POWER_WATT, ' Hz': 'Hz', ' C': TEMP_CELSIUS, ' Percent Load Capacity': '%', diff --git a/homeassistant/components/edp_redy/sensor.py b/homeassistant/components/edp_redy/sensor.py index 926a073832c..389ae77f35b 100644 --- a/homeassistant/components/edp_redy/sensor.py +++ b/homeassistant/components/edp_redy/sensor.py @@ -2,6 +2,7 @@ import logging from homeassistant.helpers.entity import Entity +from homeassistant.const import POWER_WATT from homeassistant.components.edp_redy import EdpRedyDevice, EDP_REDY @@ -29,7 +30,7 @@ async def async_setup_platform( # Create a sensor for global active power devices.append(EdpRedySensor(session, ACTIVE_POWER_ID, "Power Home", - 'mdi:flash', 'W')) + 'mdi:flash', POWER_WATT)) async_add_entities(devices, True) @@ -89,7 +90,7 @@ class EdpRedyModuleSensor(EdpRedyDevice, Entity): @property def unit_of_measurement(self): """Return the unit of measurement of this sensor.""" - return 'W' + return POWER_WATT async def async_update(self): """Parse the data for this sensor.""" diff --git a/homeassistant/components/enocean/sensor.py b/homeassistant/components/enocean/sensor.py index d2e88ed3825..8d79de2c50d 100644 --- a/homeassistant/components/enocean/sensor.py +++ b/homeassistant/components/enocean/sensor.py @@ -4,7 +4,7 @@ import logging import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (CONF_NAME, CONF_ID) +from homeassistant.const import (CONF_NAME, CONF_ID, POWER_WATT) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv from homeassistant.components import enocean @@ -59,4 +59,4 @@ class EnOceanSensor(enocean.EnOceanDevice, Entity): @property def unit_of_measurement(self): """Return the unit of measurement.""" - return 'W' + return POWER_WATT diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py index c4d97dca3fe..1bdd2323108 100644 --- a/homeassistant/components/homematic/sensor.py +++ b/homeassistant/components/homematic/sensor.py @@ -2,7 +2,7 @@ import logging from homeassistant.components.homematic import ATTR_DISCOVER_DEVICES, HMDevice -from homeassistant.const import STATE_UNKNOWN +from homeassistant.const import STATE_UNKNOWN, POWER_WATT _LOGGER = logging.getLogger(__name__) @@ -24,7 +24,7 @@ HM_UNIT_HA_CAST = { 'TEMPERATURE': '°C', 'ACTUAL_TEMPERATURE': '°C', 'BRIGHTNESS': '#', - 'POWER': 'W', + 'POWER': POWER_WATT, 'CURRENT': 'mA', 'VOLTAGE': 'V', 'ENERGY_COUNTER': 'Wh', diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index d755735e0e0..f1a20e46922 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -5,7 +5,7 @@ from homeassistant.components.homematicip_cloud import ( DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice) from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, - TEMP_CELSIUS) + TEMP_CELSIUS, POWER_WATT) _LOGGER = logging.getLogger(__name__) @@ -223,4 +223,4 @@ class HomematicipPowerSensor(HomematicipGenericDevice): @property def unit_of_measurement(self): """Return the unit this state is expressed in.""" - return 'W' + return POWER_WATT diff --git a/homeassistant/components/isy994/sensor.py b/homeassistant/components/isy994/sensor.py index 60212d081de..2115c19f496 100644 --- a/homeassistant/components/isy994/sensor.py +++ b/homeassistant/components/isy994/sensor.py @@ -5,7 +5,8 @@ from typing import Callable from homeassistant.components.isy994 import ( ISY994_NODES, ISY994_WEATHER, ISYDevice) from homeassistant.components.sensor import DOMAIN -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT, UNIT_UV_INDEX +from homeassistant.const import ( + TEMP_CELSIUS, TEMP_FAHRENHEIT, UNIT_UV_INDEX, POWER_WATT) from homeassistant.helpers.typing import ConfigType _LOGGER = logging.getLogger(__name__) @@ -75,7 +76,7 @@ UOM_FRIENDLY_NAME = { '69': 'gal', '71': UNIT_UV_INDEX, '72': 'V', - '73': 'W', + '73': POWER_WATT, '74': 'W/m²', '75': 'weekday', '76': 'Wind Direction (°)', diff --git a/homeassistant/components/juicenet/sensor.py b/homeassistant/components/juicenet/sensor.py index e3786627075..35cf5628d6f 100644 --- a/homeassistant/components/juicenet/sensor.py +++ b/homeassistant/components/juicenet/sensor.py @@ -1,7 +1,7 @@ """Support for monitoring juicenet/juicepoint/juicebox based EVSE sensors.""" import logging -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import TEMP_CELSIUS, POWER_WATT from homeassistant.helpers.entity import Entity from homeassistant.components.juicenet import JuicenetDevice, DOMAIN @@ -14,7 +14,7 @@ SENSOR_TYPES = { 'temperature': ['Temperature', TEMP_CELSIUS], 'voltage': ['Voltage', 'V'], 'amps': ['Amps', 'A'], - 'watts': ['Watts', 'W'], + 'watts': ['Watts', POWER_WATT], 'charge_time': ['Charge time', 's'], 'energy_added': ['Energy added', 'Wh'] } diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index ce6d5da2b4c..7aa19e2157c 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -1,7 +1,7 @@ """Support for MySensors sensors.""" from homeassistant.components import mysensors from homeassistant.components.sensor import DOMAIN -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT, POWER_WATT SENSORS = { 'V_TEMP': [None, 'mdi:thermometer'], @@ -12,7 +12,7 @@ SENSORS = { 'V_WEIGHT': ['kg', 'mdi:weight-kilogram'], 'V_DISTANCE': ['m', 'mdi:ruler'], 'V_IMPEDANCE': ['ohm', None], - 'V_WATT': ['W', None], + 'V_WATT': [POWER_WATT, None], 'V_KWH': ['kWh', None], 'V_FLOW': ['m', None], 'V_VOLUME': ['m³', None], diff --git a/homeassistant/components/rfxtrx/__init__.py b/homeassistant/components/rfxtrx/__init__.py index a7b703ef2ab..411f0538bde 100644 --- a/homeassistant/components/rfxtrx/__init__.py +++ b/homeassistant/components/rfxtrx/__init__.py @@ -6,7 +6,8 @@ import voluptuous as vol from homeassistant.const import ( ATTR_ENTITY_ID, ATTR_NAME, ATTR_STATE, CONF_DEVICE, CONF_DEVICES, - EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS) + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP, TEMP_CELSIUS, + POWER_WATT) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import slugify @@ -40,8 +41,8 @@ DATA_TYPES = OrderedDict([ ('Barometer', ''), ('Wind direction', ''), ('Rain rate', ''), - ('Energy usage', 'W'), - ('Total usage', 'W'), + ('Energy usage', POWER_WATT), + ('Total usage', POWER_WATT), ('Sound', ''), ('Sensor Status', ''), ('Counter value', ''), diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py index 2995b860e5b..8fefea03506 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -3,6 +3,7 @@ from datetime import timedelta import logging from homeassistant.components.sense import SENSE_DATA +from homeassistant.const import POWER_WATT from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -90,7 +91,7 @@ class Sense(Entity): self._state = None if sensor_type == ACTIVE_TYPE: - self._unit_of_measurement = 'W' + self._unit_of_measurement = POWER_WATT else: self._unit_of_measurement = 'kWh' diff --git a/homeassistant/components/sensor/efergy.py b/homeassistant/components/sensor/efergy.py index 54666c74f96..18a8fa2e02d 100644 --- a/homeassistant/components/sensor/efergy.py +++ b/homeassistant/components/sensor/efergy.py @@ -10,7 +10,7 @@ import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_CURRENCY +from homeassistant.const import CONF_CURRENCY, POWER_WATT import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -34,11 +34,11 @@ DEFAULT_PERIOD = 'year' DEFAULT_UTC_OFFSET = '0' SENSOR_TYPES = { - CONF_INSTANT: ['Energy Usage', 'W'], + CONF_INSTANT: ['Energy Usage', POWER_WATT], CONF_AMOUNT: ['Energy Consumed', 'kWh'], CONF_BUDGET: ['Energy Budget', None], CONF_COST: ['Energy Cost', None], - CONF_CURRENT_VALUES: ['Per-Device Usage', 'W'] + CONF_CURRENT_VALUES: ['Per-Device Usage', POWER_WATT] } TYPES_SCHEMA = vol.In(SENSOR_TYPES) diff --git a/homeassistant/components/sensor/eliqonline.py b/homeassistant/components/sensor/eliqonline.py index 8e750a8d5e1..b03164a30d4 100644 --- a/homeassistant/components/sensor/eliqonline.py +++ b/homeassistant/components/sensor/eliqonline.py @@ -10,7 +10,7 @@ import logging import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (CONF_ACCESS_TOKEN, CONF_NAME) +from homeassistant.const import (CONF_ACCESS_TOKEN, CONF_NAME, POWER_WATT) from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv from homeassistant.helpers.aiohttp_client import async_get_clientsession @@ -27,7 +27,7 @@ ICON = 'mdi:gauge' SCAN_INTERVAL = timedelta(seconds=60) -UNIT_OF_MEASUREMENT = 'W' +UNIT_OF_MEASUREMENT = POWER_WATT PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_ACCESS_TOKEN): cv.string, diff --git a/homeassistant/components/sensor/emoncms.py b/homeassistant/components/sensor/emoncms.py index 7546224d4c5..5d619878d98 100644 --- a/homeassistant/components/sensor/emoncms.py +++ b/homeassistant/components/sensor/emoncms.py @@ -14,7 +14,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_API_KEY, CONF_URL, CONF_VALUE_TEMPLATE, CONF_UNIT_OF_MEASUREMENT, - CONF_ID, CONF_SCAN_INTERVAL, STATE_UNKNOWN) + CONF_ID, CONF_SCAN_INTERVAL, STATE_UNKNOWN, POWER_WATT) from homeassistant.helpers.entity import Entity from homeassistant.helpers import template from homeassistant.util import Throttle @@ -34,7 +34,7 @@ CONF_ONLY_INCLUDE_FEEDID = 'include_only_feed_id' CONF_SENSOR_NAMES = 'sensor_names' DECIMALS = 2 -DEFAULT_UNIT = 'W' +DEFAULT_UNIT = POWER_WATT MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) diff --git a/homeassistant/components/sensor/enphase_envoy.py b/homeassistant/components/sensor/enphase_envoy.py index 4bbf7eec01b..1bfee88d41c 100644 --- a/homeassistant/components/sensor/enphase_envoy.py +++ b/homeassistant/components/sensor/enphase_envoy.py @@ -11,14 +11,15 @@ 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) +from homeassistant.const import ( + CONF_IP_ADDRESS, CONF_MONITORED_CONDITIONS, POWER_WATT) REQUIREMENTS = ['envoy_reader==0.3'] _LOGGER = logging.getLogger(__name__) SENSORS = { - "production": ("Envoy Current Energy Production", 'W'), + "production": ("Envoy Current Energy Production", POWER_WATT), "daily_production": ("Envoy Today's Energy Production", "Wh"), "seven_days_production": ("Envoy Last Seven Days Energy Production", "Wh"), "lifetime_production": ("Envoy Lifetime Energy Production", "Wh"), diff --git a/homeassistant/components/sensor/greeneye_monitor.py b/homeassistant/components/sensor/greeneye_monitor.py index 3793ea7846c..4dee9d69b42 100644 --- a/homeassistant/components/sensor/greeneye_monitor.py +++ b/homeassistant/components/sensor/greeneye_monitor.py @@ -6,7 +6,7 @@ https://home-assistant.io/components/sensors.greeneye_monitor_temperature/ """ import logging -from homeassistant.const import CONF_NAME, CONF_TEMPERATURE_UNIT +from homeassistant.const import CONF_NAME, CONF_TEMPERATURE_UNIT, POWER_WATT from homeassistant.helpers.entity import Entity from ..greeneye_monitor import ( @@ -33,7 +33,7 @@ DEPENDENCIES = ['greeneye_monitor'] DATA_PULSES = 'pulses' DATA_WATT_SECONDS = 'watt_seconds' -UNIT_WATTS = 'W' +UNIT_WATTS = POWER_WATT COUNTER_ICON = 'mdi:counter' CURRENT_SENSOR_ICON = 'mdi:flash' diff --git a/homeassistant/components/sensor/neurio_energy.py b/homeassistant/components/sensor/neurio_energy.py index addb7925bc2..a9fbc316751 100644 --- a/homeassistant/components/sensor/neurio_energy.py +++ b/homeassistant/components/sensor/neurio_energy.py @@ -11,7 +11,7 @@ import requests.exceptions import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (CONF_API_KEY) +from homeassistant.const import (CONF_API_KEY, POWER_WATT) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv @@ -148,7 +148,7 @@ class NeurioEnergy(Entity): self._state = None if sensor_type == ACTIVE_TYPE: - self._unit_of_measurement = 'W' + self._unit_of_measurement = POWER_WATT elif sensor_type == DAILY_TYPE: self._unit_of_measurement = 'kWh' diff --git a/homeassistant/components/sensor/nut.py b/homeassistant/components/sensor/nut.py index 79ad176e42e..1464c0d91c1 100644 --- a/homeassistant/components/sensor/nut.py +++ b/homeassistant/components/sensor/nut.py @@ -13,7 +13,8 @@ from homeassistant.components.sensor import PLATFORM_SCHEMA import homeassistant.helpers.config_validation as cv from homeassistant.const import ( CONF_HOST, CONF_PORT, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, - TEMP_CELSIUS, CONF_RESOURCES, CONF_ALIAS, ATTR_STATE, STATE_UNKNOWN) + TEMP_CELSIUS, CONF_RESOURCES, CONF_ALIAS, ATTR_STATE, STATE_UNKNOWN, + POWER_WATT) from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -63,8 +64,8 @@ SENSOR_TYPES = { 'ups.efficiency': ['Efficiency', '%', 'mdi:gauge'], 'ups.power': ['Current Apparent Power', 'VA', 'mdi:flash'], 'ups.power.nominal': ['Nominal Power', 'VA', 'mdi:flash'], - 'ups.realpower': ['Current Real Power', 'W', 'mdi:flash'], - 'ups.realpower.nominal': ['Nominal Real Power', 'W', 'mdi:flash'], + 'ups.realpower': ['Current Real Power', POWER_WATT, 'mdi:flash'], + 'ups.realpower.nominal': ['Nominal Real Power', POWER_WATT, 'mdi:flash'], 'ups.beeper.status': ['Beeper Status', '', 'mdi:information-outline'], 'ups.type': ['UPS Type', '', 'mdi:information-outline'], 'ups.watchdog.status': ['Watchdog Status', '', 'mdi:information-outline'], diff --git a/homeassistant/components/sensor/solaredge.py b/homeassistant/components/sensor/solaredge.py index fa49cdb3bfe..be21316948a 100644 --- a/homeassistant/components/sensor/solaredge.py +++ b/homeassistant/components/sensor/solaredge.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_API_KEY, CONF_MONITORED_CONDITIONS, CONF_NAME) + CONF_API_KEY, CONF_MONITORED_CONDITIONS, CONF_NAME, POWER_WATT) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -36,7 +36,7 @@ SENSOR_TYPES = { 'mdi:solar-power'], 'energy_today': ['lastDayData', "Energy today", 'Wh', 'mdi:solar-power'], - 'current_power': ['currentPower', "Current Power", 'W', + 'current_power': ['currentPower', "Current Power", POWER_WATT, 'mdi:solar-power'] } diff --git a/homeassistant/components/sensor/ted5000.py b/homeassistant/components/sensor/ted5000.py index 82a1ec8bb68..23a20b3e830 100644 --- a/homeassistant/components/sensor/ted5000.py +++ b/homeassistant/components/sensor/ted5000.py @@ -11,7 +11,8 @@ import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (CONF_HOST, CONF_NAME, CONF_PORT) +from homeassistant.const import ( + CONF_HOST, CONF_NAME, CONF_PORT, POWER_WATT) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -46,7 +47,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): dev = [] for mtu in gateway.data: - dev.append(Ted5000Sensor(gateway, name, mtu, 'W')) + dev.append(Ted5000Sensor(gateway, name, mtu, POWER_WATT)) dev.append(Ted5000Sensor(gateway, name, mtu, 'V')) add_entities(dev) @@ -58,7 +59,7 @@ class Ted5000Sensor(Entity): def __init__(self, gateway, name, mtu, unit): """Initialize the sensor.""" - units = {'W': 'power', 'V': 'voltage'} + units = {POWER_WATT: 'power', 'V': 'voltage'} self._gateway = gateway self._name = '{} mtu{} {}'.format(name, mtu, units[unit]) self._mtu = mtu @@ -114,4 +115,4 @@ class Ted5000Gateway: voltage = int(doc["LiveData"]["Voltage"]["MTU%d" % mtu] ["VoltageNow"]) - self.data[mtu] = {'W': power, 'V': voltage / 10} + self.data[mtu] = {POWER_WATT: power, 'V': voltage / 10} diff --git a/homeassistant/components/sensor/volkszaehler.py b/homeassistant/components/sensor/volkszaehler.py index 47aa580e3d4..d81400d300e 100644 --- a/homeassistant/components/sensor/volkszaehler.py +++ b/homeassistant/components/sensor/volkszaehler.py @@ -11,7 +11,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_PORT, CONF_MONITORED_CONDITIONS) + CONF_HOST, CONF_NAME, CONF_PORT, CONF_MONITORED_CONDITIONS, POWER_WATT) from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -31,10 +31,10 @@ DEFAULT_PORT = 80 MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) SENSOR_TYPES = { - 'average': ['Average', 'W', 'mdi:power-off'], + 'average': ['Average', POWER_WATT, 'mdi:power-off'], 'consumption': ['Consumption', 'Wh', 'mdi:power-plug'], - 'max': ['Max', 'W', 'mdi:arrow-up'], - 'min': ['Min', 'W', 'mdi:arrow-down'], + 'max': ['Max', POWER_WATT, 'mdi:arrow-up'], + 'min': ['Min', POWER_WATT, 'mdi:arrow-down'], } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index 67213ab15bf..afe06d83f47 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -4,6 +4,7 @@ from datetime import timedelta from homeassistant.components.smappee import DATA_SMAPPEE from homeassistant.helpers.entity import Entity +from homeassistant.const import POWER_WATT DEPENDENCIES = ['smappee'] @@ -12,9 +13,10 @@ _LOGGER = logging.getLogger(__name__) SENSOR_PREFIX = 'Smappee' SENSOR_TYPES = { 'solar': - ['Solar', 'mdi:white-balance-sunny', 'local', 'W', 'solar'], + ['Solar', 'mdi:white-balance-sunny', 'local', POWER_WATT, 'solar'], 'active_power': - ['Active Power', 'mdi:power-plug', 'local', 'W', 'active_power'], + ['Active Power', 'mdi:power-plug', 'local', POWER_WATT, + 'active_power'], 'current': ['Current', 'mdi:gauge', 'local', 'A', 'current'], 'voltage': diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 50beefdb5b2..680098adc7e 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -5,7 +5,7 @@ from typing import Optional, Sequence from homeassistant.const import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, MASS_KILOGRAMS, - TEMP_CELSIUS, TEMP_FAHRENHEIT) + TEMP_CELSIUS, TEMP_FAHRENHEIT, POWER_WATT) from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN @@ -85,7 +85,7 @@ CAPABILITY_TO_SENSORS = { 'ovenSetpoint': [ Map('ovenSetpoint', "Oven Set Point", None, None)], 'powerMeter': [ - Map('power', "Power Meter", 'W', None)], + Map('power', "Power Meter", POWER_WATT, None)], 'powerSource': [ Map('powerSource', "Power Source", None, None)], 'refrigerationSetpoint': [ diff --git a/homeassistant/components/switch/fritzdect.py b/homeassistant/components/switch/fritzdect.py index a04de7618af..2aefcec9670 100644 --- a/homeassistant/components/switch/fritzdect.py +++ b/homeassistant/components/switch/fritzdect.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_USERNAME) + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, POWER_WATT) import homeassistant.helpers.config_validation as cv from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE @@ -25,7 +25,7 @@ DEFAULT_HOST = 'fritz.box' ATTR_CURRENT_CONSUMPTION = 'current_consumption' ATTR_CURRENT_CONSUMPTION_UNIT = 'current_consumption_unit' -ATTR_CURRENT_CONSUMPTION_UNIT_VALUE = 'W' +ATTR_CURRENT_CONSUMPTION_UNIT_VALUE = POWER_WATT ATTR_TOTAL_CONSUMPTION = 'total_consumption' ATTR_TOTAL_CONSUMPTION_UNIT = 'total_consumption_unit' diff --git a/homeassistant/components/tellduslive/sensor.py b/homeassistant/components/tellduslive/sensor.py index 48133fd69e6..42c93aa52f9 100644 --- a/homeassistant/components/tellduslive/sensor.py +++ b/homeassistant/components/tellduslive/sensor.py @@ -5,7 +5,7 @@ from homeassistant.components import sensor, tellduslive from homeassistant.components.tellduslive.entry import TelldusLiveEntity from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, - TEMP_CELSIUS) + TEMP_CELSIUS, POWER_WATT) from homeassistant.helpers.dispatcher import async_dispatcher_connect _LOGGER = logging.getLogger(__name__) @@ -33,7 +33,7 @@ SENSOR_TYPES = { SENSOR_TYPE_WINDAVERAGE: ['Wind average', 'm/s', '', None], SENSOR_TYPE_WINDGUST: ['Wind gust', 'm/s', '', None], SENSOR_TYPE_UV: ['UV', 'UV', '', None], - SENSOR_TYPE_WATT: ['Power', 'W', '', None], + SENSOR_TYPE_WATT: ['Power', POWER_WATT, '', None], SENSOR_TYPE_LUMINANCE: ['Luminance', 'lx', None, DEVICE_CLASS_ILLUMINANCE], SENSOR_TYPE_DEW_POINT: ['Dew Point', TEMP_CELSIUS, None, DEVICE_CLASS_TEMPERATURE], diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 6dcdbb845dc..16ca8ec3d71 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -7,7 +7,7 @@ at https://home-assistant.io/components/sensor.zha/ import logging from homeassistant.components.sensor import DOMAIN -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import TEMP_CELSIUS, POWER_WATT from homeassistant.helpers.dispatcher import async_dispatcher_connect from .core.const import ( DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, HUMIDITY, TEMPERATURE, @@ -69,8 +69,8 @@ UNIT_REGISTRY = { TEMPERATURE: TEMP_CELSIUS, PRESSURE: 'hPa', ILLUMINANCE: 'lx', - METERING: 'W', - ELECTRICAL_MEASUREMENT: 'W', + METERING: POWER_WATT, + ELECTRICAL_MEASUREMENT: POWER_WATT, GENERIC: None } diff --git a/homeassistant/const.py b/homeassistant/const.py index 54901feb73b..49194c06c17 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -319,6 +319,9 @@ ATTR_DEVICE_CLASS = 'device_class' ATTR_TEMPERATURE = 'temperature' # #### UNITS OF MEASUREMENT #### +# Power units +POWER_WATT = 'W' + # Temperature units TEMP_CELSIUS = '°C' TEMP_FAHRENHEIT = '°F' From 1ad4779443fa69a14849286e4ed884f6d57d45f9 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sat, 2 Mar 2019 10:38:15 +0000 Subject: [PATCH 047/291] Add network throughput statistics to systemmonitor sensor (#21575) * add network throughput * lint --- .../components/sensor/systemmonitor.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/homeassistant/components/sensor/systemmonitor.py b/homeassistant/components/sensor/systemmonitor.py index 70fb1f91051..8eccdc7b3b7 100644 --- a/homeassistant/components/sensor/systemmonitor.py +++ b/homeassistant/components/sensor/systemmonitor.py @@ -1,4 +1,5 @@ """Support for monitoring the local system.""" +from datetime import datetime import logging import os import socket @@ -34,6 +35,10 @@ SENSOR_TYPES = { 'network_out': ['Network out', 'MiB', 'mdi:server-network', None], 'packets_in': ['Packets in', ' ', 'mdi:server-network', None], 'packets_out': ['Packets out', ' ', 'mdi:server-network', None], + 'throughput_network_in': ['Network throughput in', 'MB/s', + 'mdi:server-network', None], + 'throughput_network_out': ['Network throughput out', 'MB/s', + 'mdi:server-network', None], 'process': ['Process', ' ', 'mdi:memory', None], 'processor_use': ['Processor use', '%', 'mdi:memory', None], 'swap_free': ['Swap free', 'MiB', 'mdi:harddisk', None], @@ -54,6 +59,8 @@ IO_COUNTER = { 'network_in': 1, 'packets_out': 2, 'packets_in': 3, + 'throughput_network_out': 0, + 'throughput_network_in': 1, } IF_ADDRS_FAMILY = { @@ -84,6 +91,9 @@ class SystemMonitorSensor(Entity): self.type = sensor_type self._state = None self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + if sensor_type in ['throughput_network_out', 'throughput_network_in']: + self._last_value = None + self._last_update_time = None @property def name(self): @@ -162,6 +172,22 @@ class SystemMonitorSensor(Entity): self._state = counters[self.argument][IO_COUNTER[self.type]] else: self._state = None + elif self.type == 'throughput_network_out' or\ + self.type == 'throughput_network_in': + counters = psutil.net_io_counters(pernic=True) + if self.argument in counters: + counter = counters[self.argument][IO_COUNTER[self.type]] + now = datetime.now() + if self._last_value and self._last_value < counter: + self._state = round( + (counter - self._last_value) / 1000**2 / + (now - self._last_update_time).seconds, 3) + else: + self._state = None + self._last_update_time = now + self._last_value = counter + else: + self._state = None elif self.type == 'ipv4_address' or self.type == 'ipv6_address': addresses = psutil.net_if_addrs() if self.argument in addresses: From 61e4a6be1847127822174621d9cc56de2a212981 Mon Sep 17 00:00:00 2001 From: Joe Trabulsy Date: Sat, 2 Mar 2019 05:57:10 -0500 Subject: [PATCH 048/291] Update for new pyvesyncv_v2 library and vesync switch support (#21449) * Change dependency to pyvesync-v2 for vesync switch * Update requirements_all.txt * Update Version - Wall Switch Support Update required version for vesync outlets and switches. Eliminate API call for energy usage for wall switches that do not have that feature * fix name convention --- homeassistant/components/switch/vesync.py | 9 +++++---- requirements_all.txt | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/switch/vesync.py b/homeassistant/components/switch/vesync.py index 382096ad5e4..d9ffbf9c12d 100644 --- a/homeassistant/components/switch/vesync.py +++ b/homeassistant/components/switch/vesync.py @@ -11,7 +11,7 @@ from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyvesync==0.1.1'] +REQUIREMENTS = ['pyvesync_v2==0.9.6'] _LOGGER = logging.getLogger(__name__) @@ -23,7 +23,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the VeSync switch platform.""" - from pyvesync.vesync import VeSync + from pyvesync_v2.vesync import VeSync switches = [] @@ -104,5 +104,6 @@ class VeSyncSwitchHA(SwitchDevice): def update(self): """Handle data changes for node values.""" self.smartplug.update() - self._current_power_w = self.smartplug.get_power() - self._today_energy_kwh = self.smartplug.get_kwh_today() + if self.smartplug.devtype == 'outlet': + self._current_power_w = self.smartplug.get_power() + self._today_energy_kwh = self.smartplug.get_kwh_today() diff --git a/requirements_all.txt b/requirements_all.txt index 2ef12c7f7be..8ae7c543653 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1439,7 +1439,7 @@ pyuptimerobot==0.0.5 pyvera==0.2.45 # homeassistant.components.switch.vesync -pyvesync==0.1.1 +pyvesync_v2==0.9.6 # homeassistant.components.media_player.vizio pyvizio==0.0.4 From 5eab86986e6c56325e9e70d72a9ddbfca53b19ec Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sat, 2 Mar 2019 12:32:18 +0100 Subject: [PATCH 049/291] Word the tplink deprecation warning more strongly (#21586) --- homeassistant/components/tplink/light.py | 2 +- homeassistant/components/tplink/switch.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tplink/light.py b/homeassistant/components/tplink/light.py index 1e31df98af5..de1a943c33a 100644 --- a/homeassistant/components/tplink/light.py +++ b/homeassistant/components/tplink/light.py @@ -34,7 +34,7 @@ def async_setup_platform(hass, config, add_entities, discovery_info=None): Deprecated. """ - _LOGGER.warning('Loading as a platform is deprecated, ' + _LOGGER.warning('Loading as a platform is no longer supported, ' 'convert to use the tplink component.') diff --git a/homeassistant/components/tplink/switch.py b/homeassistant/components/tplink/switch.py index efff0eb4f51..65b884169c7 100644 --- a/homeassistant/components/tplink/switch.py +++ b/homeassistant/components/tplink/switch.py @@ -29,7 +29,7 @@ def async_setup_platform(hass, config, add_entities, discovery_info=None): Deprecated. """ - _LOGGER.warning('Loading as a platform is deprecated, ' + _LOGGER.warning('Loading as a platform is no longer supported, ' 'convert to use the tplink component.') From 45316f6ed6821729b8f6c0be4a7950c9d2b6da24 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Sat, 2 Mar 2019 14:09:01 -0500 Subject: [PATCH 050/291] ZHA fixes (#21592) * do not report on 0x1000 LightLink cluster * don't flood Zigbee network during configuration or initialization * add lifeline of 60 minutes to lights * use ootb polling --- .../components/zha/core/channels/general.py | 7 +++++++ .../components/zha/core/channels/registry.py | 1 + homeassistant/components/zha/core/device.py | 18 ++++++++++-------- homeassistant/components/zha/core/gateway.py | 4 ++++ homeassistant/components/zha/light.py | 12 ++++++++++++ homeassistant/components/zha/switch.py | 12 ++++++++++-- 6 files changed, 44 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index 621b0ccbee1..cd16fe5d22e 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -64,6 +64,13 @@ class OnOffChannel(ZigbeeChannel): await self.get_attribute_value(self.ON_OFF, from_cache=from_cache)) await super().async_initialize(from_cache) + async def async_update(self): + """Initialize channel.""" + _LOGGER.debug("Attempting to update onoff state") + self._state = bool( + await self.get_attribute_value(self.ON_OFF, from_cache=False)) + await super().async_update() + class LevelControlChannel(ZigbeeChannel): """Channel for the LevelControl Zigbee cluster.""" diff --git a/homeassistant/components/zha/core/channels/registry.py b/homeassistant/components/zha/core/channels/registry.py index f0363ac8330..8f7335d82a9 100644 --- a/homeassistant/components/zha/core/channels/registry.py +++ b/homeassistant/components/zha/core/channels/registry.py @@ -43,4 +43,5 @@ def populate_channel_registry(): zcl.clusters.general.Basic.cluster_id: BasicChannel, zcl.clusters.security.IasZone.cluster_id: IASZoneChannel, zcl.clusters.hvac.Fan.cluster_id: FanChannel, + zcl.clusters.lightlink.LightLink.cluster_id: ZigbeeChannel, }) diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index b0b41a87809..06b33a418fb 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -205,20 +205,22 @@ class ZHADevice: async def _execute_channel_tasks(self, task_name, *args): """Gather and execute a set of CHANNEL tasks.""" channel_tasks = [] + semaphore = asyncio.Semaphore(3) for channel in self.all_channels: channel_tasks.append( - self._async_create_task(channel, task_name, *args)) + self._async_create_task(semaphore, channel, task_name, *args)) await asyncio.gather(*channel_tasks) - async def _async_create_task(self, channel, func_name, *args): + async def _async_create_task(self, semaphore, channel, func_name, *args): """Configure a single channel on this device.""" try: - await getattr(channel, func_name)(*args) - _LOGGER.debug('%s: channel: %s %s stage succeeded', - self.name, - "{}-{}".format( - channel.name, channel.unique_id), - func_name) + async with semaphore: + await getattr(channel, func_name)(*args) + _LOGGER.debug('%s: channel: %s %s stage succeeded', + self.name, + "{}-{}".format( + channel.name, channel.unique_id), + func_name) except Exception as ex: # pylint: disable=broad-except _LOGGER.warning( '%s channel: %s %s stage failed ex: %s', diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index a498e1e8ee1..595d32b1c2b 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -452,8 +452,11 @@ def establish_device_mappings(): NO_SENSOR_CLUSTERS.append(zcl.clusters.general.Basic.cluster_id) NO_SENSOR_CLUSTERS.append( zcl.clusters.general.PowerConfiguration.cluster_id) + NO_SENSOR_CLUSTERS.append(zcl.clusters.lightlink.LightLink.cluster_id) + BINDABLE_CLUSTERS.append(zcl.clusters.general.LevelControl.cluster_id) BINDABLE_CLUSTERS.append(zcl.clusters.general.OnOff.cluster_id) + BINDABLE_CLUSTERS.append(zcl.clusters.lighting.Color.cluster_id) DEVICE_CLASS[zha.PROFILE_ID].update({ zha.DeviceType.ON_OFF_SWITCH: 'binary_sensor', @@ -537,6 +540,7 @@ def establish_device_mappings(): zcl.clusters.general.PollControl.cluster_id: [], zcl.clusters.general.GreenPowerProxy.cluster_id: [], zcl.clusters.general.OnOffConfiguration.cluster_id: [], + zcl.clusters.lightlink.LightLink.cluster_id: [], zcl.clusters.general.OnOff.cluster_id: [{ 'attr': 'on_off', 'config': REPORT_CONFIG_IMMEDIATE diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 740d67db1bd..a87912eb213 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -4,6 +4,7 @@ Lights on Zigbee Home Automation networks. For more details on this platform, please refer to the documentation at https://home-assistant.io/components/light.zha/ """ +from datetime import timedelta import logging from homeassistant.components import light @@ -26,6 +27,7 @@ CAPABILITIES_COLOR_XY = 0x08 CAPABILITIES_COLOR_TEMP = 0x10 UNSUPPORTED_ATTRIBUTE = 0x86 +SCAN_INTERVAL = timedelta(minutes=60) async def async_setup_platform(hass, config, async_add_entities, @@ -92,6 +94,11 @@ class Light(ZhaEntity, light.Light): self._supported_features |= light.SUPPORT_COLOR self._hs_color = (0, 0) + @property + def should_poll(self) -> bool: + """Poll state from device.""" + return True + @property def is_on(self) -> bool: """Return true if entity is on.""" @@ -217,3 +224,8 @@ class Light(ZhaEntity, light.Light): return self._state = False self.async_schedule_update_ha_state() + + async def async_update(self): + """Attempt to retrieve on off state from the light.""" + if self._on_off_channel: + await self._on_off_channel.async_update() diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index bdbdd7a6a76..63a0cad93ab 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -71,11 +71,19 @@ class Switch(ZhaEntity, SwitchDevice): async def async_turn_on(self, **kwargs): """Turn the entity on.""" - await self._on_off_channel.on() + success = await self._on_off_channel.on() + if not success: + return + self._state = True + self.async_schedule_update_ha_state() async def async_turn_off(self, **kwargs): """Turn the entity off.""" - await self._on_off_channel.off() + success = await self._on_off_channel.off() + if not success: + return + self._state = False + self.async_schedule_update_ha_state() def async_set_state(self, state): """Handle state update from channel.""" From b8eebda54111579536a35470b17e3069477c4e15 Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Sat, 2 Mar 2019 23:42:51 +0100 Subject: [PATCH 051/291] Update pyhomematic (#21600) --- homeassistant/components/homematic/__init__.py | 9 +++++---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index dba4add216d..1ee50fa16a4 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyhomematic==0.1.56'] +REQUIREMENTS = ['pyhomematic==0.1.57'] _LOGGER = logging.getLogger(__name__) @@ -74,11 +74,11 @@ HM_DEVICE_TYPES = { 'IPWeatherSensor', 'RotaryHandleSensorIP', 'IPPassageSensor', 'IPKeySwitchPowermeter', 'IPThermostatWall230V', 'IPWeatherSensorPlus', 'IPWeatherSensorBasic', 'IPBrightnessSensor', 'IPGarage', - 'UniversalSensor', 'MotionIPV2', 'IPMultiIO'], + 'UniversalSensor', 'MotionIPV2', 'IPMultiIO', 'IPThermostatWall2'], DISCOVER_CLIMATE: [ 'Thermostat', 'ThermostatWall', 'MAXThermostat', 'ThermostatWall2', 'MAXWallThermostat', 'IPThermostat', 'IPThermostatWall', - 'ThermostatGroup', 'IPThermostatWall230V'], + 'ThermostatGroup', 'IPThermostatWall230V', 'IPThermostatWall2'], DISCOVER_BINARY_SENSORS: [ 'ShutterContact', 'Smoke', 'SmokeV2', 'Motion', 'MotionV2', 'MotionIP', 'RemoteMotion', 'WeatherSensor', 'TiltSensor', @@ -98,7 +98,8 @@ HM_IGNORE_DISCOVERY_NODE = [ HM_IGNORE_DISCOVERY_NODE_EXCEPTIONS = { 'ACTUAL_TEMPERATURE': [ 'IPAreaThermostat', 'IPWeatherSensor', - 'IPWeatherSensorPlus', 'IPWeatherSensorBasic'], + 'IPWeatherSensorPlus', 'IPWeatherSensorBasic', + 'IPThermostatWall', 'IPThermostatWall2'], } HM_ATTRIBUTE_SUPPORT = { diff --git a/requirements_all.txt b/requirements_all.txt index 8ae7c543653..430d22e3700 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1068,7 +1068,7 @@ pyhik==0.2.2 pyhiveapi==0.2.17 # homeassistant.components.homematic -pyhomematic==0.1.56 +pyhomematic==0.1.57 # homeassistant.components.homeworks pyhomeworks==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0fa31ed80fe..7e572e477fd 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -197,7 +197,7 @@ pydeconz==52 pydispatcher==2.0.5 # homeassistant.components.homematic -pyhomematic==0.1.56 +pyhomematic==0.1.57 # homeassistant.components.litejet pylitejet==0.1 From 833f17de04fec6cf0552632bd52f2dca9de654c2 Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Sat, 2 Mar 2019 16:54:03 -0700 Subject: [PATCH 052/291] Add parameter hold_secs for Harmony remote send command (#19650) * Update requirements Updated requirements * Small bump for aioharmony Small version bump increase for aioharmony * Add attributes Add firmware and config version attributes * Add hold for button press on send_command * Fix requirements file For some reason aioharmony ended up in there as a duplicate. Fixed it. * Revert rebase changes Revert some changes that should have been reverted back as part of rebase. * Updated based on review Removed HOLD_SECS from platform schema (configuration) Updated getting kwargs in async_send_command Updated debug log to include delay_secs --- homeassistant/components/harmony/remote.py | 13 ++++++++----- homeassistant/components/remote/__init__.py | 3 +++ homeassistant/components/remote/services.yaml | 4 ++++ 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index 4ea199bdcd1..78f2674243c 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -7,9 +7,8 @@ import voluptuous as vol 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 -) + ATTR_ACTIVITY, ATTR_DELAY_SECS, ATTR_DEVICE, ATTR_HOLD_SECS, + ATTR_NUM_REPEATS, DEFAULT_DELAY_SECS, DOMAIN, PLATFORM_SCHEMA) from homeassistant.const import ( ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_PORT, EVENT_HOMEASSISTANT_STOP ) @@ -340,8 +339,12 @@ class HarmonyRemote(remote.RemoteDevice): _LOGGER.error("%s: Device %s is invalid", self.name, device) return - num_repeats = kwargs.get(ATTR_NUM_REPEATS) + num_repeats = kwargs[ATTR_NUM_REPEATS] delay_secs = kwargs.get(ATTR_DELAY_SECS, self._delay_secs) + hold_secs = kwargs[ATTR_HOLD_SECS] + _LOGGER.debug("Sending commands to device %s holding for %s seconds " + "with a delay of %s seconds", + device, hold_secs, delay_secs) # Creating list of commands to send. snd_cmnd_list = [] @@ -350,7 +353,7 @@ class HarmonyRemote(remote.RemoteDevice): send_command = SendCommandDevice( device=device_id, command=single_command, - delay=0 + delay=hold_secs ) snd_cmnd_list.append(send_command) if delay_secs > 0: diff --git a/homeassistant/components/remote/__init__.py b/homeassistant/components/remote/__init__.py index b7923596039..de79adc9f0e 100644 --- a/homeassistant/components/remote/__init__.py +++ b/homeassistant/components/remote/__init__.py @@ -23,6 +23,7 @@ ATTR_COMMAND = 'command' ATTR_DEVICE = 'device' ATTR_NUM_REPEATS = 'num_repeats' ATTR_DELAY_SECS = 'delay_secs' +ATTR_HOLD_SECS = 'hold_secs' DOMAIN = 'remote' DEPENDENCIES = ['group'] @@ -40,6 +41,7 @@ SERVICE_SYNC = 'sync' DEFAULT_NUM_REPEATS = 1 DEFAULT_DELAY_SECS = 0.4 +DEFAULT_HOLD_SECS = 0 REMOTE_SERVICE_SCHEMA = vol.Schema({ vol.Optional(ATTR_ENTITY_ID): cv.comp_entity_ids, @@ -55,6 +57,7 @@ REMOTE_SERVICE_SEND_COMMAND_SCHEMA = REMOTE_SERVICE_SCHEMA.extend({ vol.Optional( ATTR_NUM_REPEATS, default=DEFAULT_NUM_REPEATS): cv.positive_int, vol.Optional(ATTR_DELAY_SECS): vol.Coerce(float), + vol.Optional(ATTR_HOLD_SECS, default=DEFAULT_HOLD_SECS): vol.Coerce(float), }) diff --git a/homeassistant/components/remote/services.yaml b/homeassistant/components/remote/services.yaml index 1fb4b048707..62615f28714 100644 --- a/homeassistant/components/remote/services.yaml +++ b/homeassistant/components/remote/services.yaml @@ -42,6 +42,10 @@ send_command: delay_secs: description: An optional value that specifies that number of seconds you want to wait in between repeated commands. If not specified, the default of 0.4 seconds will be used. example: '0.75' + hold_secs: + description: An optional value that specifies that number of seconds you want to have it held before the release is send. If not specified, the release will be send immediately after the press. + example: '2.5' + harmony_sync: description: Syncs the remote's configuration. From 9af8c95e83e0d1e4d410d3772c44df7ffedcef30 Mon Sep 17 00:00:00 2001 From: Rohan Kapoor Date: Sat, 2 Mar 2019 18:23:17 -0800 Subject: [PATCH 053/291] Upgrade motorparts to 1.1.0 (#21602) --- homeassistant/components/sensor/mopar.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/mopar.py b/homeassistant/components/sensor/mopar.py index 6b2b3776557..e2dda136244 100644 --- a/homeassistant/components/sensor/mopar.py +++ b/homeassistant/components/sensor/mopar.py @@ -17,7 +17,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['motorparts==1.0.2'] +REQUIREMENTS = ['motorparts==1.1.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 430d22e3700..e04c995e4f5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -699,7 +699,7 @@ millheater==0.3.4 mitemp_bt==0.0.1 # homeassistant.components.sensor.mopar -motorparts==1.0.2 +motorparts==1.1.0 # homeassistant.components.tts mutagen==1.42.0 From 18491c515f528975b1c4fe4263ff276cdb1d32a1 Mon Sep 17 00:00:00 2001 From: Matt White Date: Sat, 2 Mar 2019 20:29:16 -0600 Subject: [PATCH 054/291] Further Yale ZWave lock device mapping cleanup (#21128) * Update device mapping for workarounds from zwave device db * Update comment on old Yale vendor ID --- homeassistant/components/zwave/lock.py | 25 +++++++++++++++++++------ 1 file changed, 19 insertions(+), 6 deletions(-) mode change 100644 => 100755 homeassistant/components/zwave/lock.py diff --git a/homeassistant/components/zwave/lock.py b/homeassistant/components/zwave/lock.py old mode 100644 new mode 100755 index 7c0958e596a..ac7d7308333 --- a/homeassistant/components/zwave/lock.py +++ b/homeassistant/components/zwave/lock.py @@ -34,14 +34,27 @@ DEVICE_MAPPINGS = { # Kwikset 914TRL ZW500 (0x0090, 0x440): WORKAROUND_DEVICE_STATE, (0x0090, 0x446): WORKAROUND_DEVICE_STATE, - # Yale YRD210, Yale YRD240 - (0x0129, 0x0209): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, - (0x0129, 0xAA00): WORKAROUND_DEVICE_STATE, - # Yale YRL220/YRD220 + # Yale Locks + # Yale YRD210, YRD220, YRL220 (0x0129, 0x0000): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, - # Yale YRD120 + # Yale YRD210, YRD220 + (0x0129, 0x0209): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, + # Yale YRL210, YRL220 + (0x0129, 0x0409): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, + # Yale YRD256 + (0x0129, 0x0600): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, + # Yale YRD110, YRD120 (0x0129, 0x0800): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, - # Yale YRD220 (as reported by adrum in PR #17386) + # Yale YRD446 + (0x0129, 0x1000): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, + # Yale YRL220 + (0x0129, 0x2132): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, + (0x0129, 0x3CAC): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, + # Yale YRD210, YRD220 + (0x0129, 0xAA00): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, + # Yale YRD220 + (0x0129, 0xFFFF): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, + # Yale YRD220 (Older Yale products with incorrect vendor ID) (0x0109, 0x0000): WORKAROUND_DEVICE_STATE | WORKAROUND_ALARM_TYPE, # Schlage BE469 (0x003B, 0x5044): WORKAROUND_DEVICE_STATE | WORKAROUND_TRACK_MESSAGE, From 18372ad81b96185e1f0853639780fa598d483580 Mon Sep 17 00:00:00 2001 From: shanbs Date: Sun, 3 Mar 2019 04:51:42 +0100 Subject: [PATCH 055/291] Added support for multiple Netatmo thermostats/valves (#19407) * climate/netatmo: Added support for muletiple thermostats/valves * Adjusted the update interval throttle to 10 seconds * Avoid returning 'homes' without 'therm_schedules' * Requires home to have 'modules' as well as 'therm_schedules'; Using pyatmo 1.7 * Support multiple homes * Fix nest level too deep issue * Fix crashing bug when discovery is true * Fix crashing bug when discovery is true * Modifications according to review comments * Resolve format issue * Fix mode name issue * Revisions according to review's suggestions * Revisions according to review's comments * Revisions according to review's comments --- homeassistant/components/netatmo/climate.py | 385 ++++++++++++++++---- 1 file changed, 314 insertions(+), 71 deletions(-) diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 1e16f2d3e05..2b093ee4bc0 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -3,71 +3,140 @@ import logging from datetime import timedelta import voluptuous as vol -from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE +from homeassistant.const import ( + STATE_OFF, TEMP_CELSIUS, ATTR_TEMPERATURE, STATE_UNKNOWN, CONF_NAME) from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA from homeassistant.components.climate.const import ( - STATE_HEAT, STATE_IDLE, - SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, SUPPORT_AWAY_MODE) + STATE_HEAT, STATE_IDLE, SUPPORT_ON_OFF, SUPPORT_TARGET_TEMPERATURE, + SUPPORT_OPERATION_MODE, SUPPORT_AWAY_MODE, STATE_MANUAL, STATE_AUTO, + STATE_ECO, STATE_COOL) from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv + DEPENDENCIES = ['netatmo'] _LOGGER = logging.getLogger(__name__) -CONF_RELAY = 'relay' -CONF_THERMOSTAT = 'thermostat' +CONF_HOMES = 'homes' +CONF_ROOMS = 'rooms' -DEFAULT_AWAY_TEMPERATURE = 14 -# # The default offset is 2 hours (when you use the thermostat itself) -DEFAULT_TIME_OFFSET = 7200 -# # Return cached results if last scan was less then this time ago -# # NetAtmo Data is uploaded to server every hour -MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=300) +MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) + +HOME_CONFIG_SCHEMA = vol.Schema({ + vol.Required(CONF_NAME): cv.string, + vol.Optional(CONF_ROOMS, default=[]): vol.All(cv.ensure_list, [cv.string]) +}) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_RELAY): cv.string, - vol.Optional(CONF_THERMOSTAT, default=[]): - vol.All(cv.ensure_list, [cv.string]), + vol.Optional(CONF_HOMES): vol.All(cv.ensure_list, [HOME_CONFIG_SCHEMA]) }) +STATE_NETATMO_SCHEDULE = 'schedule' +STATE_NETATMO_HG = 'hg' +STATE_NETATMO_MAX = 'max' +STATE_NETATMO_AWAY = 'away' +STATE_NETATMO_OFF = STATE_OFF +STATE_NETATMO_MANUAL = STATE_MANUAL + +DICT_NETATMO_TO_HA = { + STATE_NETATMO_SCHEDULE: STATE_AUTO, + STATE_NETATMO_HG: STATE_COOL, + STATE_NETATMO_MAX: STATE_HEAT, + STATE_NETATMO_AWAY: STATE_ECO, + STATE_NETATMO_OFF: STATE_OFF, + STATE_NETATMO_MANUAL: STATE_MANUAL +} + +DICT_HA_TO_NETATMO = { + STATE_AUTO: STATE_NETATMO_SCHEDULE, + STATE_COOL: STATE_NETATMO_HG, + STATE_HEAT: STATE_NETATMO_MAX, + STATE_ECO: STATE_NETATMO_AWAY, + STATE_OFF: STATE_NETATMO_OFF, + STATE_MANUAL: STATE_NETATMO_MANUAL +} + SUPPORT_FLAGS = (SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE | SUPPORT_AWAY_MODE) +NA_THERM = 'NATherm1' +NA_VALVE = 'NRV' + def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the NetAtmo Thermostat.""" netatmo = hass.components.netatmo - device = config.get(CONF_RELAY) import pyatmo + homes_conf = config.get(CONF_HOMES) try: - data = ThermostatData(netatmo.NETATMO_AUTH, device) - for module_name in data.get_module_names(): - if CONF_THERMOSTAT in config: - if config[CONF_THERMOSTAT] != [] and \ - module_name not in config[CONF_THERMOSTAT]: - continue - add_entities([NetatmoThermostat(data, module_name)], True) + home_data = HomeData(netatmo.NETATMO_AUTH) except pyatmo.NoDevice: - return None + return + + homes = [] + rooms = {} + if homes_conf is not None: + for home_conf in homes_conf: + home = home_conf[CONF_NAME] + if home_conf[CONF_ROOMS] != []: + rooms[home] = home_conf[CONF_ROOMS] + homes.append(home) + else: + homes = home_data.get_home_names() + + for home in homes: + _LOGGER.debug("Setting up %s ...", home) + try: + room_data = ThermostatData(netatmo.NETATMO_AUTH, home) + except pyatmo.NoDevice: + continue + for room_id in room_data.get_room_ids(): + room_name = room_data.homedata.rooms[home][room_id]['name'] + _LOGGER.debug("Setting up %s (%s) ...", room_name, room_id) + if home in rooms and room_name not in rooms[home]: + _LOGGER.debug("Excluding %s ...", room_name) + continue + _LOGGER.debug("Adding devices for room %s (%s) ...", + room_name, room_id) + add_entities([NetatmoThermostat(room_data, room_id)], True) class NetatmoThermostat(ClimateDevice): """Representation a Netatmo thermostat.""" - def __init__(self, data, module_name, away_temp=None): + def __init__(self, data, room_id): """Initialize the sensor.""" self._data = data self._state = None - self._name = module_name + self._room_id = room_id + room_name = self._data.homedata.rooms[self._data.home][room_id]['name'] + self._name = 'netatmo_{}'.format(room_name) self._target_temperature = None self._away = None + self._module_type = self._data.room_status[room_id]['module_type'] + if self._module_type == NA_VALVE: + self._operation_list = [DICT_NETATMO_TO_HA[STATE_NETATMO_SCHEDULE], + DICT_NETATMO_TO_HA[STATE_NETATMO_MANUAL], + DICT_NETATMO_TO_HA[STATE_NETATMO_AWAY], + DICT_NETATMO_TO_HA[STATE_NETATMO_HG]] + self._support_flags = SUPPORT_FLAGS + elif self._module_type == NA_THERM: + self._operation_list = [DICT_NETATMO_TO_HA[STATE_NETATMO_SCHEDULE], + DICT_NETATMO_TO_HA[STATE_NETATMO_MANUAL], + DICT_NETATMO_TO_HA[STATE_NETATMO_AWAY], + DICT_NETATMO_TO_HA[STATE_NETATMO_HG], + DICT_NETATMO_TO_HA[STATE_NETATMO_MAX], + DICT_NETATMO_TO_HA[STATE_NETATMO_OFF]] + self._support_flags = SUPPORT_FLAGS | SUPPORT_ON_OFF + self._operation_mode = None + self.update_without_throttle = False @property def supported_features(self): """Return the list of supported features.""" - return SUPPORT_FLAGS + return self._support_flags @property def name(self): @@ -82,90 +151,264 @@ class NetatmoThermostat(ClimateDevice): @property def current_temperature(self): """Return the current temperature.""" - return self._data.current_temperature + return self._data.room_status[self._room_id]['current_temperature'] @property def target_temperature(self): """Return the temperature we try to reach.""" - return self._target_temperature + return self._data.room_status[self._room_id]['target_temperature'] @property def current_operation(self): """Return the current state of the thermostat.""" - state = self._data.thermostatdata.relay_cmd - if state == 0: + state = self._data.room_status[self._room_id]['heating_status'] + if state is False: return STATE_IDLE - if state == 100: + if state is True: return STATE_HEAT + return STATE_UNKNOWN + + @property + def operation_list(self): + """Return the operation modes list.""" + return self._operation_list + + @property + def operation_mode(self): + """Return current operation ie. heat, cool, idle.""" + return self._operation_mode + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + module_type = self._data.room_status[self._room_id]['module_type'] + if module_type not in (NA_THERM, NA_VALVE): + return {} + state_attributes = { + "home_id": self._data.homedata.gethomeId(self._data.home), + "room_id": self._room_id, + "setpoint_default_duration": self._data.setpoint_duration, + "away_temperature": self._data.away_temperature, + "hg_temperature": self._data.hg_temperature, + "operation_mode": self._operation_mode, + "module_type": module_type, + "module_id": self._data.room_status[self._room_id]['module_id'] + } + if module_type == NA_THERM: + state_attributes["boiler_status"] = self.current_operation + elif module_type == NA_VALVE: + state_attributes["heating_power_request"] = \ + self._data.room_status[self._room_id]['heating_power_request'] + return state_attributes @property def is_away_mode_on(self): """Return true if away mode is on.""" return self._away + @property + def is_on(self): + """Return true if on.""" + return self.target_temperature > 0 + def turn_away_mode_on(self): """Turn away on.""" - mode = "away" - temp = None - self._data.thermostatdata.setthermpoint(mode, temp, endTimeOffset=None) - self._away = True + self.set_operation_mode(DICT_NETATMO_TO_HA[STATE_NETATMO_AWAY]) def turn_away_mode_off(self): """Turn away off.""" - mode = "program" - temp = None - self._data.thermostatdata.setthermpoint(mode, temp, endTimeOffset=None) - self._away = False + self.set_operation_mode(DICT_NETATMO_TO_HA[STATE_NETATMO_SCHEDULE]) + + def turn_off(self): + """Turn Netatmo off.""" + _LOGGER.debug("Switching off ...") + self.set_operation_mode(DICT_NETATMO_TO_HA[STATE_NETATMO_OFF]) + self.update_without_throttle = True + self.schedule_update_ha_state() + + def turn_on(self): + """Turn Netatmo on.""" + _LOGGER.debug("Switching on ...") + _LOGGER.debug("Setting temperature first to %d ...", + self._data.hg_temperature) + self._data.homestatus.setroomThermpoint( + self._data.homedata.gethomeId(self._data.home), + self._room_id, STATE_NETATMO_MANUAL, self._data.hg_temperature) + _LOGGER.debug("Setting operation mode to schedule ...") + self._data.homestatus.setThermmode( + self._data.homedata.gethomeId(self._data.home), + STATE_NETATMO_SCHEDULE) + self.update_without_throttle = True + self.schedule_update_ha_state() + + def set_operation_mode(self, operation_mode): + """Set HVAC mode (auto, auxHeatOnly, cool, heat, off).""" + if not self.is_on: + self.turn_on() + if operation_mode in [DICT_NETATMO_TO_HA[STATE_NETATMO_MAX], + DICT_NETATMO_TO_HA[STATE_NETATMO_OFF]]: + self._data.homestatus.setroomThermpoint( + self._data.homedata.gethomeId(self._data.home), + self._room_id, DICT_HA_TO_NETATMO[operation_mode]) + elif operation_mode in [DICT_NETATMO_TO_HA[STATE_NETATMO_HG], + DICT_NETATMO_TO_HA[STATE_NETATMO_SCHEDULE], + DICT_NETATMO_TO_HA[STATE_NETATMO_AWAY]]: + self._data.homestatus.setThermmode( + self._data.homedata.gethomeId(self._data.home), + DICT_HA_TO_NETATMO[operation_mode]) + self.update_without_throttle = True + self.schedule_update_ha_state() def set_temperature(self, **kwargs): """Set new target temperature for 2 hours.""" - temperature = kwargs.get(ATTR_TEMPERATURE) - if temperature is None: + temp = kwargs.get(ATTR_TEMPERATURE) + if temp is None: return - mode = "manual" - self._data.thermostatdata.setthermpoint( - mode, temperature, DEFAULT_TIME_OFFSET) - self._target_temperature = temperature - self._away = False + mode = STATE_NETATMO_MANUAL + self._data.homestatus.setroomThermpoint( + self._data.homedata.gethomeId(self._data.home), + self._room_id, DICT_HA_TO_NETATMO[mode], temp) + self.update_without_throttle = True + self.schedule_update_ha_state() - @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Get the latest data from NetAtmo API and updates the states.""" - self._data.update() - self._target_temperature = self._data.thermostatdata.setpoint_temp - self._away = self._data.setpoint_mode == 'away' + try: + if self.update_without_throttle: + self._data.update(no_throttle=True) + self.update_without_throttle = False + else: + self._data.update() + except AttributeError: + _LOGGER.error("NetatmoThermostat::update() " + "got exception.") + return + self._target_temperature = \ + self._data.room_status[self._room_id]['target_temperature'] + self._operation_mode = DICT_NETATMO_TO_HA[ + self._data.room_status[self._room_id]['setpoint_mode']] + self._away = self._operation_mode == DICT_NETATMO_TO_HA[ + STATE_NETATMO_AWAY] + + +class HomeData: + """Representation Netatmo homes.""" + + def __init__(self, auth, home=None): + """Initialize the HomeData object.""" + self.auth = auth + self.homedata = None + self.home_names = [] + self.room_names = [] + self.schedules = [] + self.home = home + self.home_id = None + + def get_home_names(self): + """Get all the home names returned by NetAtmo API.""" + self.setup() + for home in self.homedata.homes: + if 'therm_schedules' in self.homedata.homes[home] and 'modules' \ + in self.homedata.homes[home]: + self.home_names.append(self.homedata.homes[home]['name']) + return self.home_names + + def setup(self): + """Retrieve HomeData by NetAtmo API.""" + import pyatmo + try: + self.homedata = pyatmo.HomeData(self.auth) + self.home_id = self.homedata.gethomeId(self.home) + except TypeError: + _LOGGER.error("Error when getting homedata.") + except pyatmo.NoDevice: + _LOGGER.error("Error when getting homestatus response.") class ThermostatData: """Get the latest data from Netatmo.""" - def __init__(self, auth, device=None): + def __init__(self, auth, home=None): """Initialize the data object.""" self.auth = auth - self.thermostatdata = None - self.module_names = [] - self.device = device - self.current_temperature = None - self.target_temperature = None - self.setpoint_mode = None + self.homedata = None + self.homestatus = None + self.room_ids = [] + self.room_status = {} + self.schedules = [] + self.home = home + self.away_temperature = None + self.hg_temperature = None + self.boilerstatus = None + self.setpoint_duration = None + self.home_id = None - def get_module_names(self): + def get_room_ids(self): """Return all module available on the API as a list.""" - self.update() - if not self.device: - for device in self.thermostatdata.modules: - for module in self.thermostatdata.modules[device].values(): - self.module_names.append(module['module_name']) - else: - for module in self.thermostatdata.modules[self.device].values(): - self.module_names.append(module['module_name']) - return self.module_names + if self.setup(): + for key in self.homestatus.rooms: + self.room_ids.append(key) + return self.room_ids + return [] + + def setup(self): + """Retrieve HomeData and HomeStatus by NetAtmo API.""" + import pyatmo + try: + self.homedata = pyatmo.HomeData(self.auth) + self.homestatus = pyatmo.HomeStatus(self.auth, home=self.home) + self.home_id = self.homedata.gethomeId(self.home) + self.update() + except TypeError: + _LOGGER.error("ThermostatData::setup() got error.") + return False + return True @Throttle(MIN_TIME_BETWEEN_UPDATES) def update(self): """Call the NetAtmo API to update the data.""" 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 + try: + self.homestatus = pyatmo.HomeStatus(self.auth, home=self.home) + except TypeError: + _LOGGER.error("Error when getting homestatus.") + return + _LOGGER.debug("Following is the debugging output for homestatus:") + _LOGGER.debug(self.homestatus.rawData) + for key in self.homestatus.rooms: + roomstatus = {} + homestatus_room = self.homestatus.rooms[key] + homedata_room = self.homedata.rooms[self.home][key] + roomstatus['roomID'] = homestatus_room['id'] + roomstatus['roomname'] = homedata_room['name'] + roomstatus['target_temperature'] = \ + homestatus_room['therm_setpoint_temperature'] + roomstatus['setpoint_mode'] = \ + homestatus_room['therm_setpoint_mode'] + roomstatus['current_temperature'] = \ + homestatus_room['therm_measured_temperature'] + roomstatus['module_type'] = \ + self.homestatus.thermostatType(self.home, key) + roomstatus['module_id'] = None + roomstatus['heating_status'] = None + roomstatus['heating_power_request'] = None + for module_id in homedata_room['module_ids']: + if self.homedata.modules[self.home][module_id]['type'] == \ + NA_THERM or roomstatus['module_id'] is None: + roomstatus['module_id'] = module_id + if roomstatus['module_type'] == NA_THERM: + self.boilerstatus = self.homestatus.boilerStatus( + rid=roomstatus['module_id']) + roomstatus['heating_status'] = self.boilerstatus + elif roomstatus['module_type'] == NA_VALVE: + roomstatus['heating_power_request'] = \ + homestatus_room['heating_power_request'] + roomstatus['heating_status'] = \ + roomstatus['heating_power_request'] > 0 + if self.boilerstatus is not None: + roomstatus['heating_status'] = \ + self.boilerstatus and roomstatus['heating_status'] + self.room_status[key] = roomstatus + self.away_temperature = self.homestatus.getAwaytemp(self.home) + self.hg_temperature = self.homestatus.getHgtemp(self.home) + self.setpoint_duration = self.homedata.setpoint_duration[self.home] From 1e60993aa7e3e7c22affe83f7c614770a2ee01d9 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sat, 2 Mar 2019 21:57:57 -0600 Subject: [PATCH 056/291] Handle when installed app has already been removed (#21595) --- homeassistant/components/smartthings/__init__.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index e64988b2697..64e717cbc92 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -48,10 +48,20 @@ async def async_migrate_entry(hass: HomeAssistantType, entry: ConfigEntry): """ from pysmartthings import SmartThings - # Delete the installed app + # Remove the installed_app, which if already removed raises a 403 error. api = SmartThings(async_get_clientsession(hass), entry.data[CONF_ACCESS_TOKEN]) - await api.delete_installed_app(entry.data[CONF_INSTALLED_APP_ID]) + installed_app_id = entry.data[CONF_INSTALLED_APP_ID] + try: + await api.delete_installed_app(installed_app_id) + except ClientResponseError as ex: + if ex.status == 403: + _LOGGER.exception("Installed app %s has already been removed", + installed_app_id) + else: + raise + _LOGGER.debug("Removed installed app %s", installed_app_id) + # Delete the entry hass.async_create_task( hass.config_entries.async_remove(entry.entry_id)) From b985223603271601ac59732c5c505d35c4e94144 Mon Sep 17 00:00:00 2001 From: Maikel Punie Date: Sun, 3 Mar 2019 13:37:36 +0100 Subject: [PATCH 057/291] Add the velbus sync clock service (#21308) * Add the velbus sync clock service * Fixed houndci-bot commants * Fix lint and pylint * fixed all comments * Hound bot comments * Fix for flake8 --- homeassistant/components/velbus/__init__.py | 8 +++++++- homeassistant/components/velbus/services.yaml | 2 ++ requirements_all.txt | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) create mode 100644 homeassistant/components/velbus/services.yaml diff --git a/homeassistant/components/velbus/__init__.py b/homeassistant/components/velbus/__init__.py index 38d8b6c3f1c..4e808dc21ca 100644 --- a/homeassistant/components/velbus/__init__.py +++ b/homeassistant/components/velbus/__init__.py @@ -7,7 +7,7 @@ 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.21'] +REQUIREMENTS = ['python-velbus==2.0.22'] _LOGGER = logging.getLogger(__name__) @@ -62,7 +62,13 @@ async def async_setup(hass, config): load_platform(hass, 'sensor', DOMAIN, discovery_info['sensor'], config) + def syn_clock(self, service=None): + controller.sync_clock() + controller.scan(callback) + hass.services.async_register( + DOMAIN, 'sync_clock', syn_clock, + schema=vol.Schema({})) return True diff --git a/homeassistant/components/velbus/services.yaml b/homeassistant/components/velbus/services.yaml new file mode 100644 index 00000000000..40916a08418 --- /dev/null +++ b/homeassistant/components/velbus/services.yaml @@ -0,0 +1,2 @@ +sync_clock: + description: Sync the velbus modules clock to the HASS clock, this is the same as the 'sync clock' from VelbusLink diff --git a/requirements_all.txt b/requirements_all.txt index e04c995e4f5..a2cd5936eae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1385,7 +1385,7 @@ python-telegram-bot==11.1.0 python-twitch-client==0.6.0 # homeassistant.components.velbus -python-velbus==2.0.21 +python-velbus==2.0.22 # homeassistant.components.media_player.vlc python-vlc==1.1.2 From 0f6e0aa355af3e25a0426e768825bef8a3e6fcfd Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Sun, 3 Mar 2019 18:49:29 +0100 Subject: [PATCH 058/291] Upgrade pysonos to 0.0.8 (#21624) --- homeassistant/components/sonos/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sonos/__init__.py b/homeassistant/components/sonos/__init__.py index bcac4ce272c..e9f297e4f07 100644 --- a/homeassistant/components/sonos/__init__.py +++ b/homeassistant/components/sonos/__init__.py @@ -4,7 +4,7 @@ from homeassistant.helpers import config_entry_flow DOMAIN = 'sonos' -REQUIREMENTS = ['pysonos==0.0.7'] +REQUIREMENTS = ['pysonos==0.0.8'] async def async_setup(hass, config): diff --git a/requirements_all.txt b/requirements_all.txt index a2cd5936eae..7a077bb0445 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1267,7 +1267,7 @@ pysmartthings==0.6.3 pysnmp==4.4.8 # homeassistant.components.sonos -pysonos==0.0.7 +pysonos==0.0.8 # homeassistant.components.spc pyspcwebgw==0.4.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7e572e477fd..5b6a8d11cd8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -230,7 +230,7 @@ pysmartapp==0.3.0 pysmartthings==0.6.3 # homeassistant.components.sonos -pysonos==0.0.7 +pysonos==0.0.8 # homeassistant.components.spc pyspcwebgw==0.4.0 From 3e0459cef9f0b7f75a7c4db5cc441478dde886ba Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sun, 3 Mar 2019 13:47:25 -0600 Subject: [PATCH 059/291] SmartThings remove SmartApp/Automation on integration removal (#21594) * Add clean-up logic upon entry removal * Removed unecessary app removal from migration * Change log level and clarified code --- .../components/smartthings/__init__.py | 59 ++++++--- .../components/smartthings/sensor.py | 2 +- tests/components/smartthings/test_init.py | 115 +++++++++++++++++- 3 files changed, 155 insertions(+), 21 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 64e717cbc92..c1c8b12ccaa 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -46,23 +46,7 @@ async def async_migrate_entry(hass: HomeAssistantType, entry: ConfigEntry): integration setup again so we can properly retrieve the needed data elements. Force this by removing the entry and triggering a new flow. """ - from pysmartthings import SmartThings - - # Remove the installed_app, which if already removed raises a 403 error. - api = SmartThings(async_get_clientsession(hass), - entry.data[CONF_ACCESS_TOKEN]) - installed_app_id = entry.data[CONF_INSTALLED_APP_ID] - try: - await api.delete_installed_app(installed_app_id) - except ClientResponseError as ex: - if ex.status == 403: - _LOGGER.exception("Installed app %s has already been removed", - installed_app_id) - else: - raise - _LOGGER.debug("Removed installed app %s", installed_app_id) - - # Delete the entry + # Remove the entry which will invoke the callback to delete the app. hass.async_create_task( hass.config_entries.async_remove(entry.entry_id)) # only create new flow if there isn't a pending one for SmartThings. @@ -194,6 +178,47 @@ async def async_unload_entry(hass: HomeAssistantType, entry: ConfigEntry): return all(await asyncio.gather(*tasks)) +async def async_remove_entry( + hass: HomeAssistantType, entry: ConfigEntry) -> None: + """Perform clean-up when entry is being removed.""" + from pysmartthings import SmartThings + + api = SmartThings(async_get_clientsession(hass), + entry.data[CONF_ACCESS_TOKEN]) + + # Remove the installed_app, which if already removed raises a 403 error. + installed_app_id = entry.data[CONF_INSTALLED_APP_ID] + try: + await api.delete_installed_app(installed_app_id) + except ClientResponseError as ex: + if ex.status == 403: + _LOGGER.debug("Installed app %s has already been removed", + installed_app_id, exc_info=True) + else: + raise + _LOGGER.debug("Removed installed app %s", installed_app_id) + + # Remove the app if not referenced by other entries, which if already + # removed raises a 403 error. + app_id = entry.data[CONF_APP_ID] + app_count = sum(1 for entry in hass.config_entries.async_entries(DOMAIN) + if entry.data[CONF_APP_ID] == app_id) + if app_count > 1: + _LOGGER.debug("App %s was not removed because it is in use by other" + "config entries", app_id) + return + # Remove the app + try: + await api.delete_app(app_id) + except ClientResponseError as ex: + if ex.status == 403: + _LOGGER.debug("App %s has already been removed", + app_id, exc_info=True) + else: + raise + _LOGGER.debug("Removed app %s", app_id) + + class DeviceBroker: """Manages an individual SmartThings config entry.""" diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 680098adc7e..2c5a8b7ef39 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -5,7 +5,7 @@ from typing import Optional, Sequence from homeassistant.const import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, MASS_KILOGRAMS, - TEMP_CELSIUS, TEMP_FAHRENHEIT, POWER_WATT) + POWER_WATT, TEMP_CELSIUS, TEMP_FAHRENHEIT) from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index ec0b3982517..67b83ec0ca9 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -13,7 +13,7 @@ from homeassistant.components.smartthings.const import ( from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_connect -from tests.common import mock_coro +from tests.common import MockConfigEntry, mock_coro async def test_migration_creates_new_flow( @@ -22,12 +22,14 @@ async def test_migration_creates_new_flow( config_entry.version = 1 setattr(hass.config_entries, '_entries', [config_entry]) api = smartthings_mock.return_value - api.delete_installed_app.return_value = mock_coro() + api.delete_installed_app.side_effect = lambda _: mock_coro() + api.delete_app.side_effect = lambda _: mock_coro() await smartthings.async_migrate_entry(hass, config_entry) + await hass.async_block_till_done() assert api.delete_installed_app.call_count == 1 - await hass.async_block_till_done() + assert api.delete_app.call_count == 1 assert not hass.config_entries.async_entries(DOMAIN) flows = hass.config_entries.flow.async_progress() assert len(flows) == 1 @@ -209,6 +211,113 @@ async def test_unload_entry(hass, config_entry): assert forward_mock.call_count == len(SUPPORTED_PLATFORMS) +async def test_remove_entry(hass, config_entry, smartthings_mock): + """Test that the installed app and app are removed up.""" + # Arrange + api = smartthings_mock.return_value + api.delete_installed_app.side_effect = lambda _: mock_coro() + api.delete_app.side_effect = lambda _: mock_coro() + # Act + await smartthings.async_remove_entry(hass, config_entry) + # Assert + assert api.delete_installed_app.call_count == 1 + assert api.delete_app.call_count == 1 + + +async def test_remove_entry_app_in_use(hass, config_entry, smartthings_mock): + """Test app is not removed if in use by another config entry.""" + # Arrange + data = config_entry.data.copy() + data[CONF_INSTALLED_APP_ID] = str(uuid4()) + entry2 = MockConfigEntry(version=2, domain=DOMAIN, data=data) + setattr(hass.config_entries, '_entries', [config_entry, entry2]) + api = smartthings_mock.return_value + api.delete_installed_app.side_effect = lambda _: mock_coro() + # Act + await smartthings.async_remove_entry(hass, config_entry) + # Assert + assert api.delete_installed_app.call_count == 1 + assert api.delete_app.call_count == 0 + + +async def test_remove_entry_already_deleted( + hass, config_entry, smartthings_mock): + """Test handles when the apps have already been removed.""" + # Arrange + api = smartthings_mock.return_value + api.delete_installed_app.side_effect = lambda _: mock_coro( + exception=ClientResponseError(None, None, status=403)) + api.delete_app.side_effect = lambda _: mock_coro( + exception=ClientResponseError(None, None, status=403)) + # Act + await smartthings.async_remove_entry(hass, config_entry) + # Assert + assert api.delete_installed_app.call_count == 1 + assert api.delete_app.call_count == 1 + + +async def test_remove_entry_installedapp_api_error( + hass, config_entry, smartthings_mock): + """Test raises exceptions removing the installed app.""" + # Arrange + api = smartthings_mock.return_value + api.delete_installed_app.side_effect = lambda _: mock_coro( + exception=ClientResponseError(None, None, status=500)) + # Act + with pytest.raises(ClientResponseError): + await smartthings.async_remove_entry(hass, config_entry) + # Assert + assert api.delete_installed_app.call_count == 1 + assert api.delete_app.call_count == 0 + + +async def test_remove_entry_installedapp_unknown_error( + hass, config_entry, smartthings_mock): + """Test raises exceptions removing the installed app.""" + # Arrange + api = smartthings_mock.return_value + api.delete_installed_app.side_effect = lambda _: mock_coro( + exception=Exception) + # Act + with pytest.raises(Exception): + await smartthings.async_remove_entry(hass, config_entry) + # Assert + assert api.delete_installed_app.call_count == 1 + assert api.delete_app.call_count == 0 + + +async def test_remove_entry_app_api_error( + hass, config_entry, smartthings_mock): + """Test raises exceptions removing the app.""" + # Arrange + api = smartthings_mock.return_value + api.delete_installed_app.side_effect = lambda _: mock_coro() + api.delete_app.side_effect = lambda _: mock_coro( + exception=ClientResponseError(None, None, status=500)) + # Act + with pytest.raises(ClientResponseError): + await smartthings.async_remove_entry(hass, config_entry) + # Assert + assert api.delete_installed_app.call_count == 1 + assert api.delete_app.call_count == 1 + + +async def test_remove_entry_app_unknown_error( + hass, config_entry, smartthings_mock): + """Test raises exceptions removing the app.""" + # Arrange + api = smartthings_mock.return_value + api.delete_installed_app.side_effect = lambda _: mock_coro() + api.delete_app.side_effect = lambda _: mock_coro( + exception=Exception) + # Act + with pytest.raises(Exception): + await smartthings.async_remove_entry(hass, config_entry) + # Assert + assert api.delete_installed_app.call_count == 1 + assert api.delete_app.call_count == 1 + + async def test_broker_regenerates_token( hass, config_entry): """Test the device broker regenerates the refresh token.""" From 1308ead8d642c28aa5011e57aa27c386aff3f641 Mon Sep 17 00:00:00 2001 From: kennedyshead Date: Sun, 3 Mar 2019 21:31:09 +0100 Subject: [PATCH 060/291] Bumping aioasuswrt (#21627) --- homeassistant/components/asuswrt/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/asuswrt/__init__.py b/homeassistant/components/asuswrt/__init__.py index 3fc0e9d6476..9b004b5bc04 100644 --- a/homeassistant/components/asuswrt/__init__.py +++ b/homeassistant/components/asuswrt/__init__.py @@ -9,7 +9,7 @@ from homeassistant.const import ( from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform -REQUIREMENTS = ['aioasuswrt==1.1.20'] +REQUIREMENTS = ['aioasuswrt==1.1.21'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 7a077bb0445..f07763d45d6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -97,7 +97,7 @@ afsapi==0.0.4 aioambient==0.1.3 # homeassistant.components.asuswrt -aioasuswrt==1.1.20 +aioasuswrt==1.1.21 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.5 From 3032283b99e8da50e3107806a98eef811d4b39d2 Mon Sep 17 00:00:00 2001 From: Markus Jankowski Date: Sun, 3 Mar 2019 21:33:48 +0100 Subject: [PATCH 061/291] Add device HMIP-eTRV-C to HomematicIP (#21612) * Update dependencies * Add additional device HMIP-eTRV-C add valveActualTemperature to HeatingThermostats (HMIP-eTRV-C, HMIP-eTRV, HMIP-eTRV-2) * Removed HomematicipThermostatTemperatureSensor already in climate --- homeassistant/components/homematicip_cloud/__init__.py | 2 +- homeassistant/components/homematicip_cloud/sensor.py | 8 +++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index fd07356d7fb..7bc5f33d42f 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -15,7 +15,7 @@ from .const import ( from .device import HomematicipGenericDevice # noqa: F401 from .hap import HomematicipAuth, HomematicipHAP # noqa: F401 -REQUIREMENTS = ['homematicip==0.10.5'] +REQUIREMENTS = ['homematicip==0.10.6'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index f1a20e46922..9ded8fe65d2 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -5,7 +5,7 @@ from homeassistant.components.homematicip_cloud import ( DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice) from homeassistant.const import ( DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, - TEMP_CELSIUS, POWER_WATT) + POWER_WATT, TEMP_CELSIUS) _LOGGER = logging.getLogger(__name__) @@ -27,7 +27,8 @@ async def async_setup_platform( async def async_setup_entry(hass, config_entry, async_add_entities): """Set up the HomematicIP Cloud sensors from a config entry.""" from homematicip.aio.device import ( - AsyncHeatingThermostat, AsyncTemperatureHumiditySensorWithoutDisplay, + AsyncHeatingThermostat, AsyncHeatingThermostatCompact, + AsyncTemperatureHumiditySensorWithoutDisplay, AsyncTemperatureHumiditySensorDisplay, AsyncMotionDetectorIndoor, AsyncTemperatureHumiditySensorOutdoor, AsyncMotionDetectorPushButton, AsyncLightSensor, @@ -37,7 +38,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [HomematicipAccesspointStatus(home)] for device in home.devices: - if isinstance(device, AsyncHeatingThermostat): + if isinstance(device, (AsyncHeatingThermostat, + AsyncHeatingThermostatCompact)): devices.append(HomematicipHeatingThermostat(home, device)) if isinstance(device, (AsyncTemperatureHumiditySensorDisplay, AsyncTemperatureHumiditySensorWithoutDisplay, diff --git a/requirements_all.txt b/requirements_all.txt index f07763d45d6..c8cb20a8390 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -548,7 +548,7 @@ homeassistant-pyozw==0.1.2 homekit==0.12.2 # homeassistant.components.homematicip_cloud -homematicip==0.10.5 +homematicip==0.10.6 # homeassistant.components.google # homeassistant.components.remember_the_milk diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5b6a8d11cd8..fe77712e7e5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -126,7 +126,7 @@ home-assistant-frontend==20190228.0 homekit==0.12.2 # homeassistant.components.homematicip_cloud -homematicip==0.10.5 +homematicip==0.10.6 # homeassistant.components.influxdb # homeassistant.components.sensor.influxdb From fa938f5628d74ec4019294ccf5837c79d748a0b0 Mon Sep 17 00:00:00 2001 From: Jeff Irion Date: Sun, 3 Mar 2019 12:39:39 -0800 Subject: [PATCH 062/291] Add 'app_name' property and 'apps' config entry to Fire TV (#21601) * Add 'app_name' property and 'apps' config entry to Fire TV * Define 'CONF_APPS', don't import it * Address reviewer comments --- .../components/firetv/media_player.py | 21 +++++++++++++++---- 1 file changed, 17 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/firetv/media_player.py b/homeassistant/components/firetv/media_player.py index 880e1c918a9..ca7f1de4246 100644 --- a/homeassistant/components/firetv/media_player.py +++ b/homeassistant/components/firetv/media_player.py @@ -31,12 +31,14 @@ SUPPORT_FIRETV = SUPPORT_PAUSE | SUPPORT_PLAY | \ CONF_ADBKEY = 'adbkey' CONF_ADB_SERVER_IP = 'adb_server_ip' CONF_ADB_SERVER_PORT = 'adb_server_port' +CONF_APPS = 'apps' CONF_GET_SOURCES = 'get_sources' DEFAULT_NAME = 'Amazon Fire TV' DEFAULT_PORT = 5555 DEFAULT_ADB_SERVER_PORT = 5037 DEFAULT_GET_SOURCES = True +DEFAULT_APPS = {} SERVICE_ADB_COMMAND = 'adb_command' @@ -62,7 +64,9 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_ADB_SERVER_IP): cv.string, vol.Optional( CONF_ADB_SERVER_PORT, default=DEFAULT_ADB_SERVER_PORT): cv.port, - vol.Optional(CONF_GET_SOURCES, default=DEFAULT_GET_SOURCES): cv.boolean + vol.Optional(CONF_GET_SOURCES, default=DEFAULT_GET_SOURCES): cv.boolean, + vol.Optional( + CONF_APPS, default=DEFAULT_APPS): vol.Schema({cv.string: cv.string}) }) # Translate from `FireTV` reported state to HA state. @@ -102,11 +106,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): name = config[CONF_NAME] get_sources = config[CONF_GET_SOURCES] + apps = config[CONF_APPS] if host in hass.data[FIRETV_DOMAIN]: _LOGGER.warning("Platform already setup on %s, skipping", host) else: - device = FireTVDevice(ftv, name, get_sources) + device = FireTVDevice(ftv, name, get_sources, apps) add_entities([device]) _LOGGER.debug("Setup Fire TV at %s%s", host, adb_log) hass.data[FIRETV_DOMAIN][host] = device @@ -161,11 +166,14 @@ def adb_decorator(override_available=False): class FireTVDevice(MediaPlayerDevice): """Representation of an Amazon Fire TV device on the network.""" - def __init__(self, ftv, name, get_sources): + def __init__(self, ftv, name, get_sources, apps): """Initialize the FireTV device.""" - from firetv import KEYS + from firetv import APPS, KEYS + self.apps = APPS self.keys = KEYS + self.apps.update(apps) + self.firetv = ftv self._name = name @@ -222,6 +230,11 @@ class FireTVDevice(MediaPlayerDevice): """Return the current app.""" return self._current_app + @property + def app_name(self): + """Return the friendly name of the current app.""" + return self.apps.get(self._current_app, self._current_app) + @property def source(self): """Return the current app.""" From 818776d2b4f11e4f51992dc88bc0a6f9055833b2 Mon Sep 17 00:00:00 2001 From: srirams <638940+srirams@users.noreply.github.com> Date: Sun, 3 Mar 2019 15:44:40 -0600 Subject: [PATCH 063/291] Add optional sender name for SendGrid (#21610) * Set "Home Assistant" as email sender name for SendGrid * make sender name configurable * sendgrid tweaks * fix config --- homeassistant/components/notify/sendgrid.py | 23 ++++++++++++--------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/notify/sendgrid.py b/homeassistant/components/notify/sendgrid.py index 231a17455d1..e72dcbbed36 100644 --- a/homeassistant/components/notify/sendgrid.py +++ b/homeassistant/components/notify/sendgrid.py @@ -18,33 +18,35 @@ REQUIREMENTS = ['sendgrid==5.6.0'] _LOGGER = logging.getLogger(__name__) +CONF_SENDER_NAME = 'sender_name' + +DEFAULT_SENDER_NAME = 'Home Assistant' + # pylint: disable=no-value-for-parameter PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_API_KEY): cv.string, vol.Required(CONF_SENDER): vol.Email(), vol.Required(CONF_RECIPIENT): vol.Email(), + vol.Optional(CONF_SENDER_NAME, default=DEFAULT_SENDER_NAME): cv.string, }) def get_service(hass, config, discovery_info=None): """Get the SendGrid notification service.""" - api_key = config.get(CONF_API_KEY) - sender = config.get(CONF_SENDER) - recipient = config.get(CONF_RECIPIENT) - - return SendgridNotificationService(api_key, sender, recipient) + return SendgridNotificationService(config) class SendgridNotificationService(BaseNotificationService): """Implementation the notification service for email via Sendgrid.""" - def __init__(self, api_key, sender, recipient): + def __init__(self, config): """Initialize the service.""" from sendgrid import SendGridAPIClient - self.api_key = api_key - self.sender = sender - self.recipient = recipient + self.api_key = config[CONF_API_KEY] + self.sender = config[CONF_SENDER] + self.sender_name = config[CONF_SENDER_NAME] + self.recipient = config[CONF_RECIPIENT] self._sg = SendGridAPIClient(apikey=self.api_key) @@ -64,7 +66,8 @@ class SendgridNotificationService(BaseNotificationService): } ], "from": { - "email": self.sender + "email": self.sender, + "name": self.sender_name }, "content": [ { From 2017e45d7848f464220d4a4f30c66e9e3c662b86 Mon Sep 17 00:00:00 2001 From: Willem Burgers Date: Sun, 3 Mar 2019 23:42:52 +0100 Subject: [PATCH 064/291] fix derived rate, fixes #20097 (#21620) * fix derived rate, fixes #20097 * fix derived rate, fixes #20097 * Fix typo thnx @amelchio * Make the test more realistic Took values from my own smart meter for the test * Update test to ignore rounding issues --- homeassistant/components/sensor/dsmr.py | 3 ++- tests/components/sensor/test_dsmr.py | 12 ++++++------ 2 files changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index 1bb7b44cab6..8a24793a157 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -350,7 +350,8 @@ class DerivativeDSMREntity(DSMREntity): else: # Recalculate the rate diff = current_reading - self._previous_reading - self._state = diff + timediff = timestamp - self._previous_timestamp + self._state = diff / timediff * 3600 self._previous_reading = current_reading self._previous_timestamp = timestamp diff --git a/tests/components/sensor/test_dsmr.py b/tests/components/sensor/test_dsmr.py index dbf1e1fe7dd..69e05df1d92 100644 --- a/tests/components/sensor/test_dsmr.py +++ b/tests/components/sensor/test_dsmr.py @@ -104,8 +104,8 @@ def test_derivative(): entity.telegram = { '1.0.0': MBusObject([ - {'value': 1}, - {'value': 1, 'unit': 'm3'}, + {'value': 1551642213}, + {'value': 745.695, 'unit': 'm3'}, ]) } yield from entity.async_update() @@ -115,14 +115,14 @@ def test_derivative(): entity.telegram = { '1.0.0': MBusObject([ - {'value': 2}, - {'value': 2, 'unit': 'm3'}, + {'value': 1551642543}, + {'value': 745.698, 'unit': 'm3'}, ]) } yield from entity.async_update() - assert entity.state == 1, \ - 'state should be difference between first and second update' + assert abs(entity.state - 0.03272) < 0.00001, \ + 'state should be hourly usage calculated from first and second update' assert entity.unit_of_measurement == 'm3/h' From 31bcf6c35fcd07c5c3421ec2edaf627584f8cd5f Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Sun, 3 Mar 2019 18:39:13 -0700 Subject: [PATCH 065/291] Bump pyflunearyou to 1.0.3 (#21634) --- homeassistant/components/sensor/flunearyou.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/flunearyou.py b/homeassistant/components/sensor/flunearyou.py index e534461211f..8dfb330cf5c 100644 --- a/homeassistant/components/sensor/flunearyou.py +++ b/homeassistant/components/sensor/flunearyou.py @@ -18,7 +18,7 @@ from homeassistant.helpers import aiohttp_client from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle -REQUIREMENTS = ['pyflunearyou==1.0.2'] +REQUIREMENTS = ['pyflunearyou==1.0.3'] _LOGGER = logging.getLogger(__name__) ATTR_CITY = 'city' diff --git a/requirements_all.txt b/requirements_all.txt index c8cb20a8390..8bfdefab3ae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1034,7 +1034,7 @@ pyflexit==0.3 pyflic-homeassistant==0.4.dev0 # homeassistant.components.sensor.flunearyou -pyflunearyou==1.0.2 +pyflunearyou==1.0.3 # homeassistant.components.light.futurenow pyfnip==0.2 From c25cbccca973e812cc5d4b33f5513c4f635d7421 Mon Sep 17 00:00:00 2001 From: shanbs Date: Mon, 4 Mar 2019 02:42:29 +0100 Subject: [PATCH 066/291] Return Netatmo climate operation_mode instead of boiler status (#21633) * Merge the devices into one list and add into entries at once; Return operation_mode instead of boiler status. * Removing property operation_mode --- homeassistant/components/netatmo/climate.py | 20 ++++++-------------- 1 file changed, 6 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 2b093ee4bc0..8ccfe9be709 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -4,10 +4,10 @@ from datetime import timedelta import voluptuous as vol from homeassistant.const import ( - STATE_OFF, TEMP_CELSIUS, ATTR_TEMPERATURE, STATE_UNKNOWN, CONF_NAME) + STATE_OFF, TEMP_CELSIUS, ATTR_TEMPERATURE, CONF_NAME) from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA from homeassistant.components.climate.const import ( - STATE_HEAT, STATE_IDLE, SUPPORT_ON_OFF, SUPPORT_TARGET_TEMPERATURE, + STATE_HEAT, SUPPORT_ON_OFF, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, SUPPORT_AWAY_MODE, STATE_MANUAL, STATE_AUTO, STATE_ECO, STATE_COOL) from homeassistant.util import Throttle @@ -92,6 +92,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): room_data = ThermostatData(netatmo.NETATMO_AUTH, home) except pyatmo.NoDevice: continue + devices = [] for room_id in room_data.get_room_ids(): room_name = room_data.homedata.rooms[home][room_id]['name'] _LOGGER.debug("Setting up %s (%s) ...", room_name, room_id) @@ -100,7 +101,8 @@ def setup_platform(hass, config, add_entities, discovery_info=None): continue _LOGGER.debug("Adding devices for room %s (%s) ...", room_name, room_id) - add_entities([NetatmoThermostat(room_data, room_id)], True) + devices.append(NetatmoThermostat(room_data, room_id)) + add_entities(devices, True) class NetatmoThermostat(ClimateDevice): @@ -161,23 +163,13 @@ class NetatmoThermostat(ClimateDevice): @property def current_operation(self): """Return the current state of the thermostat.""" - state = self._data.room_status[self._room_id]['heating_status'] - if state is False: - return STATE_IDLE - if state is True: - return STATE_HEAT - return STATE_UNKNOWN + return self._operation_mode @property def operation_list(self): """Return the operation modes list.""" return self._operation_list - @property - def operation_mode(self): - """Return current operation ie. heat, cool, idle.""" - return self._operation_mode - @property def device_state_attributes(self): """Return device specific state attributes.""" From f5ed6432ebee6e4ae7ae9d7016a93e1a0603a619 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 3 Mar 2019 19:03:49 -0800 Subject: [PATCH 067/291] Expose create/delete cloudhook (#21606) * Expose create/delete cloudhook * Make sure we dont publish cloudhooks when not connected --- homeassistant/components/cloud/__init__.py | 48 +++++++++++++- homeassistant/components/cloud/cloudhooks.py | 3 + homeassistant/components/cloud/const.py | 4 ++ homeassistant/components/cloud/iot.py | 9 ++- tests/components/cloud/test_cloudhooks.py | 26 ++++++++ tests/components/cloud/test_init.py | 69 ++++++++++++++++++++ 6 files changed, 153 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index c427657c76d..4b1a60133db 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -6,17 +6,21 @@ import os import voluptuous as vol +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.const import ( EVENT_HOMEASSISTANT_START, CLOUD_NEVER_EXPOSED_ENTITIES, CONF_REGION, CONF_MODE, CONF_NAME) from homeassistant.helpers import entityfilter, config_validation as cv +from homeassistant.loader import bind_hass from homeassistant.util import dt as dt_util +from homeassistant.util.aiohttp import MockRequest from homeassistant.components.alexa import smart_home as alexa_sh from homeassistant.components.google_assistant import helpers as ga_h from homeassistant.components.google_assistant import const as ga_c from . import http_api, iot, auth_api, prefs, cloudhooks -from .const import CONFIG_DIR, DOMAIN, SERVERS +from .const import CONFIG_DIR, DOMAIN, SERVERS, STATE_CONNECTED REQUIREMENTS = ['warrant==0.6.1'] @@ -81,6 +85,43 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) +class CloudNotAvailable(HomeAssistantError): + """Raised when an action requires the cloud but it's not available.""" + + +@bind_hass +@callback +def async_is_logged_in(hass): + """Test if user is logged in.""" + return DOMAIN in hass.data and hass.data[DOMAIN].is_logged_in + + +@bind_hass +async def async_create_cloudhook(hass, webhook_id): + """Create a cloudhook.""" + if not async_is_logged_in(hass): + raise CloudNotAvailable + + return await hass.data[DOMAIN].cloudhooks.async_create(webhook_id) + + +@bind_hass +async def async_delete_cloudhook(hass, webhook_id): + """Delete a cloudhook.""" + if not async_is_logged_in(hass): + raise CloudNotAvailable + + return await hass.data[DOMAIN].cloudhooks.async_delete(webhook_id) + + +def is_cloudhook_request(request): + """Test if a request came from a cloudhook. + + Async friendly. + """ + return isinstance(request, MockRequest) + + async def async_setup(hass, config): """Initialize the Home Assistant cloud.""" if DOMAIN in config: @@ -152,6 +193,11 @@ class Cloud: """Get if cloud is logged in.""" return self.id_token is not None + @property + def is_connected(self): + """Get if cloud is connected.""" + return self.iot.state == STATE_CONNECTED + @property def subscription_expired(self): """Return a boolean if the subscription has expired.""" diff --git a/homeassistant/components/cloud/cloudhooks.py b/homeassistant/components/cloud/cloudhooks.py index 3c638d29166..1bec3cb4b01 100644 --- a/homeassistant/components/cloud/cloudhooks.py +++ b/homeassistant/components/cloud/cloudhooks.py @@ -14,6 +14,9 @@ class Cloudhooks: async def async_publish_cloudhooks(self): """Inform the Relayer of the cloudhooks that we support.""" + if not self.cloud.is_connected: + return + cloudhooks = self.cloud.prefs.cloudhooks await self.cloud.iot.async_send_message('webhook-register', { 'cloudhook_ids': [info['cloudhook_id'] for info diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index a5019efaa8e..192ccd8ac67 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -32,3 +32,7 @@ You have been logged out of Home Assistant Cloud because we have been unable to verify your credentials. Please [log in](/config/cloud) again to continue using the service. """ + +STATE_CONNECTING = 'connecting' +STATE_CONNECTED = 'connected' +STATE_DISCONNECTED = 'disconnected' diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index 055c4dbaa64..4a7215305b2 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -16,15 +16,14 @@ from homeassistant.util.aiohttp import MockRequest from homeassistant.helpers.aiohttp_client import async_get_clientsession from . import auth_api from . import utils -from .const import MESSAGE_EXPIRATION, MESSAGE_AUTH_FAIL +from .const import ( + MESSAGE_EXPIRATION, MESSAGE_AUTH_FAIL, STATE_CONNECTED, STATE_CONNECTING, + STATE_DISCONNECTED +) HANDLERS = Registry() _LOGGER = logging.getLogger(__name__) -STATE_CONNECTING = 'connecting' -STATE_CONNECTED = 'connected' -STATE_DISCONNECTED = 'disconnected' - class UnknownHandler(Exception): """Exception raised when trying to handle unknown handler.""" diff --git a/tests/components/cloud/test_cloudhooks.py b/tests/components/cloud/test_cloudhooks.py index 9306a6c6ef3..e98b697e6ab 100644 --- a/tests/components/cloud/test_cloudhooks.py +++ b/tests/components/cloud/test_cloudhooks.py @@ -68,3 +68,29 @@ async def test_disable(mock_cloudhooks): assert publish_calls[0][1][1] == { 'cloudhook_ids': [] } + + +async def test_create_without_connected(mock_cloudhooks, aioclient_mock): + """Test we don't publish a hook if not connected.""" + mock_cloudhooks.cloud.is_connected = False + # Make sure we fail test when we send a message. + mock_cloudhooks.cloud.iot.async_send_message.side_effect = ValueError + + aioclient_mock.post('https://webhook-create.url', json={ + 'cloudhook_id': 'mock-cloud-id', + 'url': 'https://hooks.nabu.casa/ZXCZCXZ', + }) + + hook = { + 'webhook_id': 'mock-webhook-id', + 'cloudhook_id': 'mock-cloud-id', + 'cloudhook_url': 'https://hooks.nabu.casa/ZXCZCXZ', + } + + assert hook == await mock_cloudhooks.async_create('mock-webhook-id') + + assert mock_cloudhooks.cloud.prefs.cloudhooks == { + 'mock-webhook-id': hook + } + + assert len(mock_cloudhooks.cloud.iot.async_send_message.mock_calls) == 0 diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index baf6747aead..2418e091740 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -5,6 +5,7 @@ from unittest.mock import patch, MagicMock, mock_open import pytest +from homeassistant.setup import async_setup_component from homeassistant.components import cloud from homeassistant.util.dt import utcnow @@ -175,3 +176,71 @@ def test_subscription_not_expired(hass): patch('homeassistant.util.dt.utcnow', return_value=utcnow().replace(year=2017, month=11, day=9)): assert not cl.subscription_expired + + +async def test_create_cloudhook_no_login(hass): + """Test create cloudhook when not logged in.""" + assert await async_setup_component(hass, 'cloud', {}) + coro = mock_coro({'yo': 'hey'}) + with patch('homeassistant.components.cloud.cloudhooks.' + 'Cloudhooks.async_create', return_value=coro) as mock_create, \ + pytest.raises(cloud.CloudNotAvailable): + await hass.components.cloud.async_create_cloudhook('hello') + + assert len(mock_create.mock_calls) == 0 + + +async def test_delete_cloudhook_no_login(hass): + """Test delete cloudhook when not logged in.""" + assert await async_setup_component(hass, 'cloud', {}) + coro = mock_coro({'yo': 'hey'}) + with patch('homeassistant.components.cloud.cloudhooks.' + 'Cloudhooks.async_delete', return_value=coro) as mock_delete, \ + pytest.raises(cloud.CloudNotAvailable): + await hass.components.cloud.async_delete_cloudhook('hello') + + assert len(mock_delete.mock_calls) == 0 + + +async def test_create_cloudhook(hass): + """Test create cloudhook.""" + assert await async_setup_component(hass, 'cloud', {}) + coro = mock_coro({'yo': 'hey'}) + with patch('homeassistant.components.cloud.cloudhooks.' + 'Cloudhooks.async_create', return_value=coro) as mock_create, \ + patch('homeassistant.components.cloud.async_is_logged_in', + return_value=True): + result = await hass.components.cloud.async_create_cloudhook('hello') + + assert result == {'yo': 'hey'} + assert len(mock_create.mock_calls) == 1 + + +async def test_delete_cloudhook(hass): + """Test delete cloudhook.""" + assert await async_setup_component(hass, 'cloud', {}) + coro = mock_coro({'yo': 'hey'}) + with patch('homeassistant.components.cloud.cloudhooks.' + 'Cloudhooks.async_delete', return_value=coro) as mock_delete, \ + patch('homeassistant.components.cloud.async_is_logged_in', + return_value=True): + result = await hass.components.cloud.async_delete_cloudhook('hello') + + assert result == {'yo': 'hey'} + assert len(mock_delete.mock_calls) == 1 + + +async def test_async_logged_in(hass): + """Test if is_logged_in works.""" + # Cloud not loaded + assert hass.components.cloud.async_is_logged_in() is False + + assert await async_setup_component(hass, 'cloud', {}) + + # Cloud loaded, not logged in + assert hass.components.cloud.async_is_logged_in() is False + + hass.data['cloud'].id_token = "some token" + + # Cloud loaded, logged in + assert hass.components.cloud.async_is_logged_in() is True From 48a2e50f849008c424811833c6adbddabdb9765f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 3 Mar 2019 20:36:13 -0800 Subject: [PATCH 068/291] Fix calc next (#21630) --- homeassistant/helpers/event.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/homeassistant/helpers/event.py b/homeassistant/helpers/event.py index b55c259f503..5e262a47565 100644 --- a/homeassistant/helpers/event.py +++ b/homeassistant/helpers/event.py @@ -370,9 +370,7 @@ def async_track_utc_time_change(hass, action, last_now = now if next_time <= now: - if local: - now = dt_util.as_local(now) - hass.async_run_job(action, now) + hass.async_run_job(action, dt_util.as_local(now) if local else now) calculate_next(now + timedelta(seconds=1)) # We can't use async_track_point_in_utc_time here because it would From ee6f09dd2932f335c6401ddde150ceaa74b824af Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Sun, 3 Mar 2019 21:22:22 -0800 Subject: [PATCH 069/291] Log exception occurred in WS service call command (#21584) --- homeassistant/components/websocket_api/commands.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 33a41dc8511..3313971e79e 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -151,9 +151,11 @@ async def handle_call_service(hass, connection, msg): connection.send_message(messages.error_message( msg['id'], const.ERR_NOT_FOUND, 'Service not found.')) except HomeAssistantError as err: + connection.logger.exception(err) connection.send_message(messages.error_message( msg['id'], const.ERR_HOME_ASSISTANT_ERROR, '{}'.format(err))) except Exception as err: # pylint: disable=broad-except + connection.logger.exception(err) connection.send_message(messages.error_message( msg['id'], const.ERR_UNKNOWN_ERROR, '{}'.format(err))) From fc07d3a1599a49ec306abf45d9cc81b563a32c44 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Mon, 4 Mar 2019 00:22:42 -0500 Subject: [PATCH 070/291] Add storage helper to ZHA and use it for the device node descriptor (#21500) * node descriptor implementation add info to device info disable pylint rule check for success * review comments * send manufacturer code for get attr value for mfg clusters * ST report configs * do zdo task first * add guard * use faster reporting config * disable false positive pylint --- .../components/zha/core/channels/__init__.py | 65 +++++++- homeassistant/components/zha/core/const.py | 3 + homeassistant/components/zha/core/device.py | 54 ++++--- homeassistant/components/zha/core/gateway.py | 31 +++- homeassistant/components/zha/core/store.py | 146 ++++++++++++++++++ 5 files changed, 273 insertions(+), 26 deletions(-) create mode 100644 homeassistant/components/zha/core/store.py diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index a070343b775..59b433c5f61 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -18,8 +18,13 @@ from ..helpers import ( safe_read, get_attr_id_by_name) from ..const import ( CLUSTER_REPORT_CONFIGS, REPORT_CONFIG_DEFAULT, SIGNAL_ATTR_UPDATED, - ATTRIBUTE_CHANNEL, EVENT_RELAY_CHANNEL + ATTRIBUTE_CHANNEL, EVENT_RELAY_CHANNEL, ZDO_CHANNEL ) +from ..store import async_get_registry + +NODE_DESCRIPTOR_REQUEST = 0x0002 +MAINS_POWERED = 1 +BATTERY_OR_UNKNOWN = 0 ZIGBEE_CHANNEL_REGISTRY = {} _LOGGER = logging.getLogger(__name__) @@ -181,11 +186,16 @@ class ZigbeeChannel: async def get_attribute_value(self, attribute, from_cache=True): """Get the value for an attribute.""" + manufacturer = None + manufacturer_code = self._zha_device.manufacturer_code + if self.cluster.cluster_id >= 0xfc00 and manufacturer_code: + manufacturer = manufacturer_code result = await safe_read( self._cluster, [attribute], allow_cache=from_cache, - only_cache=from_cache + only_cache=from_cache, + manufacturer=manufacturer ) return result.get(attribute) @@ -235,14 +245,21 @@ class AttributeListeningChannel(ZigbeeChannel): class ZDOChannel: """Channel for ZDO events.""" + POWER_SOURCES = { + MAINS_POWERED: 'Mains', + BATTERY_OR_UNKNOWN: 'Battery or Unknown' + } + def __init__(self, cluster, device): """Initialize ZDOChannel.""" - self.name = 'zdo' + self.name = ZDO_CHANNEL self._cluster = cluster self._zha_device = device self._status = ChannelStatus.CREATED self._unique_id = "{}_ZDO".format(device.name) self._cluster.add_listener(self) + self.power_source = None + self.manufacturer_code = None @property def unique_id(self): @@ -271,10 +288,52 @@ class ZDOChannel: async def async_initialize(self, from_cache): """Initialize channel.""" + entry = (await async_get_registry( + self._zha_device.hass)).async_get_or_create(self._zha_device) + _LOGGER.debug("entry loaded from storage: %s", entry) + if entry is not None: + self.power_source = entry.power_source + self.manufacturer_code = entry.manufacturer_code + + if self.power_source is None: + self.power_source = BATTERY_OR_UNKNOWN + + if self.manufacturer_code is None and not from_cache: + # this should always be set. This is from us not doing + # this previously so lets set it up so users don't have + # to reconfigure every device. + await self.async_get_node_descriptor(False) + entry = (await async_get_registry( + self._zha_device.hass)).async_update(self._zha_device) + _LOGGER.debug("entry after getting node desc in init: %s", entry) self._status = ChannelStatus.INITIALIZED + async def async_get_node_descriptor(self, from_cache): + """Request the node descriptor from the device.""" + from zigpy.zdo.types import Status + + if from_cache: + return + + node_descriptor = await self._cluster.request( + NODE_DESCRIPTOR_REQUEST, + self._cluster.device.nwk, tries=3, delay=2) + + def get_bit(byteval, idx): + return int(((byteval & (1 << idx)) != 0)) + + if node_descriptor is not None and\ + node_descriptor[0] == Status.SUCCESS: + mac_capability_flags = node_descriptor[2].mac_capability_flags + + self.power_source = get_bit(mac_capability_flags, 2) + self.manufacturer_code = node_descriptor[2].manufacturer_code + + _LOGGER.debug("node descriptor: %s", node_descriptor) + async def async_configure(self): """Configure channel.""" + await self.async_get_node_descriptor(False) self._status = ChannelStatus.CONFIGURED diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 757ffbaa328..ecaa1c9bd20 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -71,6 +71,7 @@ OCCUPANCY = 'occupancy' ATTR_LEVEL = 'level' +ZDO_CHANNEL = 'zdo' ON_OFF_CHANNEL = 'on_off' ATTRIBUTE_CHANNEL = 'attribute' BASIC_CHANNEL = 'basic' @@ -91,6 +92,8 @@ SIGNAL_REMOVE = 'remove' QUIRK_APPLIED = 'quirk_applied' QUIRK_CLASS = 'quirk_class' +MANUFACTURER_CODE = 'manufacturer_code' +POWER_SOURCE = 'power_source' class RadioType(enum.Enum): diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 06b33a418fb..fb57b0dbf39 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -17,10 +17,10 @@ from .const import ( ATTR_CLUSTER_ID, ATTR_ATTRIBUTE, ATTR_VALUE, ATTR_COMMAND, SERVER, ATTR_COMMAND_TYPE, ATTR_ARGS, CLIENT_COMMANDS, SERVER_COMMANDS, ATTR_ENDPOINT_ID, IEEE, MODEL, NAME, UNKNOWN, QUIRK_APPLIED, - QUIRK_CLASS, BASIC_CHANNEL + QUIRK_CLASS, ZDO_CHANNEL, MANUFACTURER_CODE, POWER_SOURCE ) -from .channels import EventRelayChannel -from .channels.general import BasicChannel +from .channels import EventRelayChannel, ZDOChannel +from .store import async_get_registry _LOGGER = logging.getLogger(__name__) @@ -69,7 +69,6 @@ class ZHADevice: self._zigpy_device.__class__.__module__, self._zigpy_device.__class__.__name__ ) - self.power_source = None self.status = DeviceStatus.CREATED @property @@ -84,12 +83,12 @@ class ZHADevice: @property def manufacturer(self): - """Return ieee address for device.""" + """Return manufacturer for device.""" return self._manufacturer @property def model(self): - """Return ieee address for device.""" + """Return model for device.""" return self._model @property @@ -115,7 +114,15 @@ class ZHADevice: @property def manufacturer_code(self): """Return manufacturer code for device.""" - # will eventually get this directly from Zigpy + if ZDO_CHANNEL in self.cluster_channels: + return self.cluster_channels.get(ZDO_CHANNEL).manufacturer_code + return None + + @property + def power_source(self): + """Return True if sensor is available.""" + if ZDO_CHANNEL in self.cluster_channels: + return self.cluster_channels.get(ZDO_CHANNEL).power_source return None @property @@ -164,7 +171,9 @@ class ZHADevice: MODEL: self.model, NAME: self.name or ieee, QUIRK_APPLIED: self.quirk_applied, - QUIRK_CLASS: self.quirk_class + QUIRK_CLASS: self.quirk_class, + MANUFACTURER_CODE: self.manufacturer_code, + POWER_SOURCE: ZDOChannel.POWER_SOURCES.get(self.power_source) } def add_cluster_channel(self, cluster_channel): @@ -186,19 +195,19 @@ class ZHADevice: _LOGGER.debug('%s: started configuration', self.name) await self._execute_channel_tasks('async_configure') _LOGGER.debug('%s: completed configuration', self.name) + entry = (await async_get_registry( + self.hass)).async_create_or_update(self) + _LOGGER.debug('%s: stored in registry: %s', self.name, entry) async def async_initialize(self, from_cache=False): """Initialize channels.""" _LOGGER.debug('%s: started initialization', self.name) await self._execute_channel_tasks('async_initialize', from_cache) - if BASIC_CHANNEL in self.cluster_channels: - self.power_source = self.cluster_channels.get( - BASIC_CHANNEL).get_power_source() - _LOGGER.debug( - '%s: power source: %s', - self.name, - BasicChannel.POWER_SOURCES.get(self.power_source) - ) + _LOGGER.debug( + '%s: power source: %s', + self.name, + ZDOChannel.POWER_SOURCES.get(self.power_source) + ) self.status = DeviceStatus.INITIALIZED _LOGGER.debug('%s: completed initialization', self.name) @@ -206,9 +215,18 @@ class ZHADevice: """Gather and execute a set of CHANNEL tasks.""" channel_tasks = [] semaphore = asyncio.Semaphore(3) + zdo_task = None for channel in self.all_channels: - channel_tasks.append( - self._async_create_task(semaphore, channel, task_name, *args)) + if channel.name == ZDO_CHANNEL: + # pylint: disable=E1111 + zdo_task = self._async_create_task( + semaphore, channel, task_name, *args) + else: + channel_tasks.append( + self._async_create_task( + semaphore, channel, task_name, *args)) + if zdo_task is not None: + await zdo_task await asyncio.gather(*channel_tasks) async def _async_create_task(self, semaphore, channel, func_name, *args): diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 595d32b1c2b..dcaf0d4a3ba 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -27,9 +27,8 @@ from .const import ( from .device import ZHADevice, DeviceStatus from ..device_entity import ZhaDeviceEntity from .channels import ( - AttributeListeningChannel, EventRelayChannel, ZDOChannel + AttributeListeningChannel, EventRelayChannel, ZDOChannel, MAINS_POWERED ) -from .channels.general import BasicChannel from .channels.registry import ZIGBEE_CHANNEL_REGISTRY from .helpers import convert_ieee @@ -38,6 +37,7 @@ _LOGGER = logging.getLogger(__name__) SENSOR_TYPES = {} BINARY_SENSOR_TYPES = {} SMARTTHINGS_HUMIDITY_CLUSTER = 64581 +SMARTTHINGS_ACCELERATION_CLUSTER = 64514 EntityReference = collections.namedtuple( 'EntityReference', 'reference_id zha_device cluster_channels device_info') @@ -163,15 +163,14 @@ class ZHAGateway: # configure the device await zha_device.async_configure() elif not zha_device.available and zha_device.power_source is not None\ - and zha_device.power_source != BasicChannel.BATTERY\ - and zha_device.power_source != BasicChannel.UNKNOWN: + and zha_device.power_source == MAINS_POWERED: # the device is currently marked unavailable and it isn't a battery # powered device so we should be able to update it now _LOGGER.debug( "attempting to request fresh state for %s %s", zha_device.name, "with power source: {}".format( - BasicChannel.POWER_SOURCES.get(zha_device.power_source) + ZDOChannel.POWER_SOURCES.get(zha_device.power_source) ) ) await zha_device.async_initialize(from_cache=False) @@ -453,6 +452,7 @@ def establish_device_mappings(): NO_SENSOR_CLUSTERS.append( zcl.clusters.general.PowerConfiguration.cluster_id) NO_SENSOR_CLUSTERS.append(zcl.clusters.lightlink.LightLink.cluster_id) + NO_SENSOR_CLUSTERS.append(SMARTTHINGS_ACCELERATION_CLUSTER) BINDABLE_CLUSTERS.append(zcl.clusters.general.LevelControl.cluster_id) BINDABLE_CLUSTERS.append(zcl.clusters.general.OnOff.cluster_id) @@ -575,6 +575,27 @@ def establish_device_mappings(): 50 ) }], + SMARTTHINGS_ACCELERATION_CLUSTER: [{ + 'attr': 'acceleration', + 'config': REPORT_CONFIG_ASAP + }, { + 'attr': 'x_axis', + 'config': REPORT_CONFIG_ASAP + }, { + 'attr': 'y_axis', + 'config': REPORT_CONFIG_ASAP + }, { + 'attr': 'z_axis', + 'config': REPORT_CONFIG_ASAP + }], + SMARTTHINGS_HUMIDITY_CLUSTER: [{ + 'attr': 'measured_value', + 'config': ( + REPORT_CONFIG_MIN_INT, + REPORT_CONFIG_MAX_INT, + 50 + ) + }], zcl.clusters.measurement.PressureMeasurement.cluster_id: [{ 'attr': 'measured_value', 'config': REPORT_CONFIG_DEFAULT diff --git a/homeassistant/components/zha/core/store.py b/homeassistant/components/zha/core/store.py new file mode 100644 index 00000000000..b13b6d8fd80 --- /dev/null +++ b/homeassistant/components/zha/core/store.py @@ -0,0 +1,146 @@ +"""Data storage helper for ZHA.""" +import logging +from collections import OrderedDict +# pylint: disable=W0611 +from typing import MutableMapping # noqa: F401 +from typing import cast + +import attr + +from homeassistant.core import callback +from homeassistant.loader import bind_hass +from homeassistant.helpers.typing import HomeAssistantType + +_LOGGER = logging.getLogger(__name__) + +DATA_REGISTRY = 'zha_storage' + +STORAGE_KEY = 'zha.storage' +STORAGE_VERSION = 1 +SAVE_DELAY = 10 + + +@attr.s(slots=True, frozen=True) +class ZhaDeviceEntry: + """Zha Device storage Entry.""" + + name = attr.ib(type=str, default=None) + ieee = attr.ib(type=str, default=None) + power_source = attr.ib(type=int, default=None) + manufacturer_code = attr.ib(type=int, default=None) + + +class ZhaDeviceStorage: + """Class to hold a registry of zha devices.""" + + def __init__(self, hass: HomeAssistantType) -> None: + """Initialize the zha device storage.""" + self.hass = hass + self.devices = {} # type: MutableMapping[str, ZhaDeviceEntry] + self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + + @callback + def async_create(self, device) -> ZhaDeviceEntry: + """Create a new ZhaDeviceEntry.""" + device_entry = ZhaDeviceEntry( + name=device.name, + ieee=str(device.ieee), + power_source=device.power_source, + manufacturer_code=device.manufacturer_code + + ) + self.devices[device_entry.ieee] = device_entry + + return self.async_update(device) + + @callback + def async_get_or_create(self, device) -> ZhaDeviceEntry: + """Create a new ZhaDeviceEntry.""" + ieee_str = str(device.ieee) + if ieee_str in self.devices: + return self.devices[ieee_str] + return self.async_create(device) + + @callback + def async_create_or_update(self, device) -> ZhaDeviceEntry: + """Create or update a ZhaDeviceEntry.""" + if str(device.ieee) in self.devices: + return self.async_update(device) + return self.async_create(device) + + async def async_delete(self, ieee: str) -> None: + """Delete ZhaDeviceEntry.""" + del self.devices[ieee] + self.async_schedule_save() + + @callback + def async_update(self, device) -> ZhaDeviceEntry: + """Update name of ZhaDeviceEntry.""" + ieee_str = str(device.ieee) + old = self.devices[ieee_str] + + changes = {} + + if device.power_source != old.power_source: + changes['power_source'] = device.power_source + + if device.manufacturer_code != old.manufacturer_code: + changes['manufacturer_code'] = device.manufacturer_code + + new = self.devices[ieee_str] = attr.evolve(old, **changes) + self.async_schedule_save() + return new + + async def async_load(self) -> None: + """Load the registry of zha device entries.""" + data = await self._store.async_load() + + devices = OrderedDict() # type: OrderedDict[str, ZhaDeviceEntry] + + if data is not None: + for device in data['devices']: + devices[device['ieee']] = ZhaDeviceEntry( + name=device['name'], + ieee=device['ieee'], + power_source=device['power_source'], + manufacturer_code=device['manufacturer_code'] + ) + + self.devices = devices + + @callback + def async_schedule_save(self) -> None: + """Schedule saving the registry of zha devices.""" + self._store.async_delay_save(self._data_to_save, SAVE_DELAY) + + @callback + def _data_to_save(self) -> dict: + """Return data for the registry of zha devices to store in a file.""" + data = {} + + data['devices'] = [ + { + 'name': entry.name, + 'ieee': entry.ieee, + 'power_source': entry.power_source, + 'manufacturer_code': entry.manufacturer_code, + } for entry in self.devices.values() + ] + + return data + + +@bind_hass +async def async_get_registry(hass: HomeAssistantType) -> ZhaDeviceStorage: + """Return zha device storage instance.""" + task = hass.data.get(DATA_REGISTRY) + + if task is None: + async def _load_reg() -> ZhaDeviceStorage: + registry = ZhaDeviceStorage(hass) + await registry.async_load() + return registry + + task = hass.data[DATA_REGISTRY] = hass.async_create_task(_load_reg()) + + return cast(ZhaDeviceStorage, await task) From de9e6e8d1a2c42092a39daad7ae0538327aff1de Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 3 Mar 2019 21:53:45 -0800 Subject: [PATCH 071/291] Update translations --- .../components/daikin/.translations/fr.json | 19 +++++++++++ .../components/esphome/.translations/da.json | 6 +++- .../components/esphome/.translations/fr.json | 11 +++++- .../components/esphome/.translations/no.json | 4 +++ .../components/esphome/.translations/pl.json | 6 +++- .../components/esphome/.translations/sv.json | 4 +++ .../components/ps4/.translations/fr.json | 20 +++++++++++ .../tellduslive/.translations/fr.json | 3 +- .../components/toon/.translations/da.json | 13 +++++++ .../components/toon/.translations/fr.json | 32 +++++++++++++++++ .../components/toon/.translations/no.json | 34 +++++++++++++++++++ .../components/toon/.translations/pl.json | 24 +++++++++++++ .../components/toon/.translations/sv.json | 34 +++++++++++++++++++ .../components/tplink/.translations/fr.json | 15 ++++++++ script/translations_download | 1 + 15 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 homeassistant/components/daikin/.translations/fr.json create mode 100644 homeassistant/components/ps4/.translations/fr.json create mode 100644 homeassistant/components/toon/.translations/da.json create mode 100644 homeassistant/components/toon/.translations/fr.json create mode 100644 homeassistant/components/toon/.translations/no.json create mode 100644 homeassistant/components/toon/.translations/pl.json create mode 100644 homeassistant/components/toon/.translations/sv.json create mode 100644 homeassistant/components/tplink/.translations/fr.json diff --git a/homeassistant/components/daikin/.translations/fr.json b/homeassistant/components/daikin/.translations/fr.json new file mode 100644 index 00000000000..cfd4b7442d6 --- /dev/null +++ b/homeassistant/components/daikin/.translations/fr.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "L'appareil est d\u00e9j\u00e0 configur\u00e9", + "device_fail": "Erreur inattendue lors de la cr\u00e9ation du p\u00e9riph\u00e9rique.", + "device_timeout": "D\u00e9lai de connexion au p\u00e9riph\u00e9rique expir\u00e9." + }, + "step": { + "user": { + "data": { + "host": "H\u00f4te" + }, + "description": "Entrez l'adresse IP de votre Daikin AC.", + "title": "Configurer Daikin AC" + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/da.json b/homeassistant/components/esphome/.translations/da.json index 20224ec0d15..76389c45149 100644 --- a/homeassistant/components/esphome/.translations/da.json +++ b/homeassistant/components/esphome/.translations/da.json @@ -13,9 +13,13 @@ "data": { "password": "Adgangskode" }, - "description": "Indtast venligst den adgangskode, du har angivet i din konfiguration.", + "description": "Indtast venligst den adgangskode du har angivet i din konfiguration for {name}.", "title": "Indtast adgangskode" }, + "discovery_confirm": { + "description": "Vil du tilf\u00f8je ESPHome node `{name}` til Home Assistant?", + "title": "Fandt ESPHome node" + }, "user": { "data": { "host": "V\u00e6rt", diff --git a/homeassistant/components/esphome/.translations/fr.json b/homeassistant/components/esphome/.translations/fr.json index cebe6848f1b..a52f6159797 100644 --- a/homeassistant/components/esphome/.translations/fr.json +++ b/homeassistant/components/esphome/.translations/fr.json @@ -1,7 +1,12 @@ { "config": { + "abort": { + "already_configured": "ESP est d\u00e9j\u00e0 configur\u00e9" + }, "error": { - "invalid_password": "Mot de passe invalide !" + "connection_error": "Impossible de se connecter \u00e0 ESP. Assurez-vous que votre fichier YAML contient une ligne 'api:'.", + "invalid_password": "Mot de passe invalide !", + "resolve_error": "Impossible de r\u00e9soudre l'adresse de l'ESP. Si cette erreur persiste, veuillez d\u00e9finir une adresse IP statique: https://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "step": { "authenticate": { @@ -11,6 +16,10 @@ "description": "Veuillez saisir le mot de passe que vous avez d\u00e9fini dans votre configuration.", "title": "Entrer votre mot de passe" }, + "discovery_confirm": { + "description": "Voulez-vous ajouter le n\u0153ud ESPHome ` {name} ` \u00e0 Home Assistant?", + "title": "N\u0153ud ESPHome d\u00e9couvert" + }, "user": { "data": { "host": "H\u00f4te", diff --git a/homeassistant/components/esphome/.translations/no.json b/homeassistant/components/esphome/.translations/no.json index 5f166eac74a..095e8825fbd 100644 --- a/homeassistant/components/esphome/.translations/no.json +++ b/homeassistant/components/esphome/.translations/no.json @@ -16,6 +16,10 @@ "description": "Vennligst skriv inn passordet du har angitt i din konfigurasjon.", "title": "Skriv Inn Passord" }, + "discovery_confirm": { + "description": "\u00d8nsker du \u00e5 legge ESPHome noden `{name}` til Home Assistant?", + "title": "Oppdaget ESPHome node" + }, "user": { "data": { "host": "Vert", diff --git a/homeassistant/components/esphome/.translations/pl.json b/homeassistant/components/esphome/.translations/pl.json index 19fb581eb3f..697fbf0311e 100644 --- a/homeassistant/components/esphome/.translations/pl.json +++ b/homeassistant/components/esphome/.translations/pl.json @@ -13,9 +13,13 @@ "data": { "password": "Has\u0142o" }, - "description": "Wprowad\u017a has\u0142o ustawione w konfiguracji.", + "description": "Wprowad\u017a has\u0142o ustawione w konfiguracji dla {nazwa}.", "title": "Wprowad\u017a has\u0142o" }, + "discovery_confirm": { + "description": "Czy chcesz doda\u0107 w\u0119ze\u0142 ESPHome ` {name} ` do Home Assistant?", + "title": "Znaleziono w\u0119ze\u0142 ESPHome " + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/esphome/.translations/sv.json b/homeassistant/components/esphome/.translations/sv.json index 6eadcb4e18e..f727e8a54df 100644 --- a/homeassistant/components/esphome/.translations/sv.json +++ b/homeassistant/components/esphome/.translations/sv.json @@ -16,6 +16,10 @@ "description": "Ange det l\u00f6senord du angav i din konfiguration.", "title": "Ange l\u00f6senord" }, + "discovery_confirm": { + "description": "Vill du l\u00e4gga till ESPHome noden ` {name} ` till Home Assistant?", + "title": "Uppt\u00e4ckt ESPHome-nod" + }, "user": { "data": { "host": "V\u00e4rddatorn", diff --git a/homeassistant/components/ps4/.translations/fr.json b/homeassistant/components/ps4/.translations/fr.json new file mode 100644 index 00000000000..d7983448417 --- /dev/null +++ b/homeassistant/components/ps4/.translations/fr.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "creds": { + "title": "PlayStation 4" + }, + "link": { + "data": { + "code": "PIN", + "ip_address": "Adresse IP", + "name": "Nom", + "region": "R\u00e9gion" + }, + "description": "Entrez vos informations PlayStation 4. Pour \"Code PIN\", acc\u00e9dez \u00e0 \"Param\u00e8tres\" sur votre console PlayStation 4. Ensuite, acc\u00e9dez \u00e0 \"Param\u00e8tres de connexion de l'application mobile\" et s\u00e9lectionnez \"Ajouter un p\u00e9riph\u00e9rique\". Entrez le code PIN qui est affich\u00e9.", + "title": "PlayStation 4" + } + }, + "title": "PlayStation 4" + } +} \ No newline at end of file diff --git a/homeassistant/components/tellduslive/.translations/fr.json b/homeassistant/components/tellduslive/.translations/fr.json index 2dd1c03022a..a20e6bff2b5 100644 --- a/homeassistant/components/tellduslive/.translations/fr.json +++ b/homeassistant/components/tellduslive/.translations/fr.json @@ -10,6 +10,7 @@ "user": { "description": "Vide" } - } + }, + "title": "Telldus Live" } } \ No newline at end of file diff --git a/homeassistant/components/toon/.translations/da.json b/homeassistant/components/toon/.translations/da.json new file mode 100644 index 00000000000..52bb867d113 --- /dev/null +++ b/homeassistant/components/toon/.translations/da.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "authenticate": { + "data": { + "password": "Adgangskode", + "username": "Brugernavn" + } + } + }, + "title": "Toon" + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/.translations/fr.json b/homeassistant/components/toon/.translations/fr.json new file mode 100644 index 00000000000..5bf0c60199e --- /dev/null +++ b/homeassistant/components/toon/.translations/fr.json @@ -0,0 +1,32 @@ +{ + "config": { + "abort": { + "no_agreements": "Ce compte n'a pas d'affichages Toon.", + "no_app": "Vous devez configurer Toon avant de pouvoir vous authentifier avec celui-ci. [Veuillez lire les instructions] (https://www.home-assistant.io/components/toon/).", + "unknown_auth_fail": "Une erreur inattendue s'est produite lors de l'authentification." + }, + "error": { + "credentials": "Les informations d'identification fournies ne sont pas valides.", + "display_exists": "L'affichage s\u00e9lectionn\u00e9 est d\u00e9j\u00e0 configur\u00e9." + }, + "step": { + "authenticate": { + "data": { + "password": "Mot de passe", + "tenant": "Locataire", + "username": "Nom d'utilisateur" + }, + "description": "Authentifiez-vous avec votre compte Eneco Toon (pas le compte d\u00e9veloppeur).", + "title": "Lier un compte Toon" + }, + "display": { + "data": { + "display": "Choisissez l'affichage" + }, + "description": "S\u00e9lectionnez l'affichage Toon avec lequel vous connecter.", + "title": "S\u00e9lectionnez l'affichage" + } + }, + "title": "Toon" + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/.translations/no.json b/homeassistant/components/toon/.translations/no.json new file mode 100644 index 00000000000..37dcd8ac22f --- /dev/null +++ b/homeassistant/components/toon/.translations/no.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "client_id": "Klient ID fra konfigurasjonen er ugyldig.", + "client_secret": "Klient hemmeligheten fra konfigurasjonen er ugyldig.", + "no_agreements": "Denne kontoen har ingen Toon skjermer.", + "no_app": "Du m\u00e5 konfigurere Toon f\u00f8r du kan autentisere den. [Vennligst les instruksjonene](https://www.home-assistant.io/components/toon/).", + "unknown_auth_fail": "Uventet feil oppstod under autentisering." + }, + "error": { + "credentials": "De oppgitte legitimasjonene er ugyldige.", + "display_exists": "Den valgte skjermen er allerede konfigurert." + }, + "step": { + "authenticate": { + "data": { + "password": "Passord", + "tenant": "Leietaker", + "username": "Brukernavn" + }, + "description": "Godkjen med Eneco Toon kontoen din (ikke utviklerkontoen).", + "title": "Linken din Toon konto" + }, + "display": { + "data": { + "display": "Velg skjerm" + }, + "description": "Velg Toon skjerm \u00e5 koble til.", + "title": "Velg skjerm" + } + }, + "title": "Toon" + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/.translations/pl.json b/homeassistant/components/toon/.translations/pl.json new file mode 100644 index 00000000000..83c9574c2f0 --- /dev/null +++ b/homeassistant/components/toon/.translations/pl.json @@ -0,0 +1,24 @@ +{ + "config": { + "abort": { + "client_id": "Identyfikator klienta z konfiguracji jest nieprawid\u0142owy.", + "client_secret": "Tajny klucz klienta z konfiguracji jest nieprawid\u0142owy.", + "no_agreements": "To konto nie posiada wy\u015bwietlaczy Toon.", + "no_app": "Musisz skonfigurowa\u0107 Toon zanim b\u0119dziesz m\u00f3g\u0142 si\u0119 z nim uwierzytelni\u0107. Prosz\u0119 przeczyta\u0107 instrukcj\u0119] (https://www.home-assistant.io/components/toon/).", + "unknown_auth_fail": "Wyst\u0105pi\u0142 nieoczekiwany b\u0142\u0105d podczas uwierzytelniania." + }, + "error": { + "credentials": "Wprowadzone dane logowania s\u0105 nieprawid\u0142owe.", + "display_exists": "Wybrany ekran jest ju\u017c skonfigurowany." + }, + "step": { + "authenticate": { + "data": { + "password": "Has\u0142o", + "tenant": "Najemca", + "username": "Nazwa u\u017cytkownika" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/.translations/sv.json b/homeassistant/components/toon/.translations/sv.json new file mode 100644 index 00000000000..4427b90ab9c --- /dev/null +++ b/homeassistant/components/toon/.translations/sv.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "client_id": "Client ID fr\u00e5n konfiguration \u00e4r ogiltig.", + "client_secret": "Client secret fr\u00e5n konfigurationen \u00e4r ogiltig.", + "no_agreements": "Det h\u00e4r kontot har inga Toon-sk\u00e4rmar.", + "no_app": "Du m\u00e5ste konfigurera Toon innan du kan autentisera med den. [L\u00e4s instruktioner] (https://www.home-assistant.io/components/toon/).", + "unknown_auth_fail": "Ov\u00e4ntat fel uppstod under autentisering." + }, + "error": { + "credentials": "De angivna uppgifterna \u00e4r ogiltiga.", + "display_exists": "Den valda sk\u00e4rmen \u00e4r redan konfigurerad" + }, + "step": { + "authenticate": { + "data": { + "password": "L\u00f6senord", + "tenant": "Hyresg\u00e4st", + "username": "Anv\u00e4ndarnamn" + }, + "description": "Autentisera med ditt Eneco Toon-konto (inte developer-kontot).", + "title": "L\u00e4nk ditt Toon-konto" + }, + "display": { + "data": { + "display": "V\u00e4lj sk\u00e4rm" + }, + "description": "V\u00e4lj Toon-sk\u00e4rm att ansluta till.", + "title": "V\u00e4lj sk\u00e4rm" + } + }, + "title": "Toon" + } +} \ No newline at end of file diff --git a/homeassistant/components/tplink/.translations/fr.json b/homeassistant/components/tplink/.translations/fr.json new file mode 100644 index 00000000000..7351825398f --- /dev/null +++ b/homeassistant/components/tplink/.translations/fr.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Aucun p\u00e9riph\u00e9rique TP-Link trouv\u00e9 sur le r\u00e9seau.", + "single_instance_allowed": "Une seule configuration est n\u00e9cessaire." + }, + "step": { + "confirm": { + "description": "Voulez-vous configurer TP-Link smart devices?", + "title": "TP-Link Smart Home" + } + }, + "title": "TP-Link Smart Home" + } +} \ No newline at end of file diff --git a/script/translations_download b/script/translations_download index 9363bc425ae..2fa16604af1 100755 --- a/script/translations_download +++ b/script/translations_download @@ -28,6 +28,7 @@ mkdir -p ${LOCAL_DIR} docker run \ -v ${LOCAL_DIR}:/opt/dest/locale \ + --rm \ lokalise/lokalise-cli@sha256:b8329d20280263cad04f65b843e54b9e8e6909a348a678eac959550b5ef5c75f lokalise \ --token ${LOKALISE_TOKEN} \ export ${PROJECT_ID} \ From c03116291e04bd7725fbfcbbca55e394f8990809 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 3 Mar 2019 21:55:51 -0800 Subject: [PATCH 072/291] Updated frontend to 20190303.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 614b5228f60..fbbea13f026 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -21,7 +21,7 @@ from homeassistant.loader import bind_hass from .storage import async_setup_frontend_storage -REQUIREMENTS = ['home-assistant-frontend==20190228.0'] +REQUIREMENTS = ['home-assistant-frontend==20190303.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 8bfdefab3ae..9662f71c14a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -539,7 +539,7 @@ hole==0.3.0 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190228.0 +home-assistant-frontend==20190303.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fe77712e7e5..873845c0df5 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -120,7 +120,7 @@ hdate==0.8.7 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190228.0 +home-assistant-frontend==20190303.0 # homeassistant.components.homekit_controller homekit==0.12.2 From 10e334cbf0ac21c2d57307e2b5f52a8c792d1b27 Mon Sep 17 00:00:00 2001 From: Nick Whyte Date: Mon, 4 Mar 2019 19:05:44 +1100 Subject: [PATCH 073/291] Allow configuration of update interval for ness_alarm (#21415) * ness_alarm: Allow configuration of update_interval * requirements * update_interval -> scan_interval * Consistent config validation * requirements * don't touch dependency version --- homeassistant/components/ness_alarm/__init__.py | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ness_alarm/__init__.py b/homeassistant/components/ness_alarm/__init__.py index c7175a0c3c7..7d8cd2c3c44 100644 --- a/homeassistant/components/ness_alarm/__init__.py +++ b/homeassistant/components/ness_alarm/__init__.py @@ -1,11 +1,14 @@ """Support for Ness D8X/D16X devices.""" -from collections import namedtuple +import datetime import logging +from collections import namedtuple import voluptuous as vol from homeassistant.components.binary_sensor import DEVICE_CLASSES -from homeassistant.const import ATTR_CODE, ATTR_STATE, EVENT_HOMEASSISTANT_STOP +from homeassistant.const import (ATTR_CODE, ATTR_STATE, + EVENT_HOMEASSISTANT_STOP, + CONF_SCAN_INTERVAL) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send @@ -25,6 +28,7 @@ CONF_ZONE_TYPE = 'type' CONF_ZONE_ID = 'id' ATTR_OUTPUT_ID = 'output_id' DEFAULT_ZONES = [] +DEFAULT_SCAN_INTERVAL = datetime.timedelta(minutes=1) SIGNAL_ZONE_CHANGED = 'ness_alarm.zone_changed' SIGNAL_ARMING_STATE_CHANGED = 'ness_alarm.arming_state_changed' @@ -42,6 +46,8 @@ CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Required(CONF_DEVICE_HOST): cv.string, vol.Required(CONF_DEVICE_PORT): cv.port, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): + vol.All(cv.time_period, cv.positive_timedelta), vol.Optional(CONF_ZONES, default=DEFAULT_ZONES): vol.All(cv.ensure_list, [ZONE_SCHEMA]), }), @@ -67,8 +73,10 @@ async def async_setup(hass, config): zones = conf[CONF_ZONES] host = conf[CONF_DEVICE_HOST] port = conf[CONF_DEVICE_PORT] + scan_interval = conf[CONF_SCAN_INTERVAL] - client = Client(host=host, port=port, loop=hass.loop) + client = Client(host=host, port=port, loop=hass.loop, + update_interval=scan_interval.total_seconds()) hass.data[DATA_NESS] = client async def _close(event): From 5efcbc50431bc0c4b9352a00960ea95cec33175a Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 4 Mar 2019 09:59:35 +0100 Subject: [PATCH 074/291] Replace travis (#21641) --- .github/main.workflow | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) create mode 100644 .github/main.workflow diff --git a/.github/main.workflow b/.github/main.workflow new file mode 100644 index 00000000000..e1677572f5b --- /dev/null +++ b/.github/main.workflow @@ -0,0 +1,16 @@ +workflow "Tox" { + on = "push" + resolves = ["Python 3.7", "Python 3.6", "Python 3.5"] +} + +action "Python 3.7" { + uses = "home-assistant/actions/py37-tox@master" +} + +action "Python 3.6" { + uses = "home-assistant/actions/py36-tox@master" +} + +action "Python 3.5" { + uses = "home-assistant/actions/py35-tox@master" +} From 5c9f266672f07a419ddae2ad9e3213c208a88aba Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 4 Mar 2019 10:23:19 +0100 Subject: [PATCH 075/291] Fix actions with tox (#21642) * Fix actions with tox * Update main.workflow --- .github/main.workflow | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.github/main.workflow b/.github/main.workflow index e1677572f5b..cec2d030b8a 100644 --- a/.github/main.workflow +++ b/.github/main.workflow @@ -5,12 +5,15 @@ workflow "Tox" { action "Python 3.7" { uses = "home-assistant/actions/py37-tox@master" + args = "-e lint,pylint,typing,cov" } action "Python 3.6" { uses = "home-assistant/actions/py36-tox@master" + args = "-e lint,pylint,typing" } action "Python 3.5" { uses = "home-assistant/actions/py35-tox@master" + args = "-e lint,pylint,typing" } From c60627c699afc6963ea3d6cac114ade59c87c041 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 4 Mar 2019 12:36:50 +0100 Subject: [PATCH 076/291] GitHub Workflow (#21643) * Fix tox.ini * Update main.workflow * Update tox.ini * Update main.workflow * Update main.workflow * Update tox.ini * Try only with one * Update main.workflow * Update main.workflow * Update main.workflow * Update main.workflow * Update main.workflow --- .github/main.workflow | 42 +++++++++++++++++++++++++++++++++--------- tox.ini | 30 ++++++------------------------ 2 files changed, 39 insertions(+), 33 deletions(-) diff --git a/.github/main.workflow b/.github/main.workflow index cec2d030b8a..fed9496abd5 100644 --- a/.github/main.workflow +++ b/.github/main.workflow @@ -1,19 +1,43 @@ -workflow "Tox" { +workflow "Python 3.7 - tox" { on = "push" - resolves = ["Python 3.7", "Python 3.6", "Python 3.5"] + resolves = ["Python 3.7 - tests"] } -action "Python 3.7" { +action "Python 3.7 - tests" { uses = "home-assistant/actions/py37-tox@master" - args = "-e lint,pylint,typing,cov" + args = "-e py37" } -action "Python 3.6" { +workflow "Python 3.6 - tox" { + on = "push" + resolves = ["Python 3.6 - tests"] +} + +action "Python 3.6 - tests" { uses = "home-assistant/actions/py36-tox@master" - args = "-e lint,pylint,typing" + args = "-e py36" } -action "Python 3.5" { - uses = "home-assistant/actions/py35-tox@master" - args = "-e lint,pylint,typing" +workflow "Python 3.5 - tox" { + on = "push" + resolves = [ + "Pyton 3.5 - typing,cov", + ] +} + +action "Python 3.5 - tests" { + uses = "home-assistant/actions/py35-tox@master" + args = "-e py35" +} + +action "Python 3.5 - lints" { + uses = "home-assistant/actions/py35-tox@master" + needs = ["Python 3.5 - tests"] + args = "-e lint,pylint -p all" +} + +action "Pyton 3.5 - typing,cov" { + uses = "home-assistant/actions/py35-tox@master" + args = "-e typing,cov -p all" + needs = ["Python 3.5 - lints"] } diff --git a/tox.ini b/tox.ini index 1dfa77c14f1..471deea0253 100644 --- a/tox.ini +++ b/tox.ini @@ -3,14 +3,7 @@ envlist = py35, py36, py37, py38, lint, pylint, typing, cov skip_missing_interpreters = True [testenv] -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} +basepython = {env:PYTHON3_PATH:python3} commands = pytest --timeout=9 --duration=10 {posargs} {toxinidir}/script/check_dirty @@ -19,15 +12,6 @@ deps = -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} {toxinidir}/script/check_dirty @@ -36,7 +20,6 @@ deps = -c{toxinidir}/homeassistant/package_constraints.txt [testenv:pylint] -basepython = {env:PYTHON3_PATH:python3} ignore_errors = True deps = -r{toxinidir}/requirements_all.txt @@ -46,18 +29,17 @@ commands = pylint {posargs} homeassistant [testenv:lint] -basepython = {env:PYTHON3_PATH:python3} deps = -r{toxinidir}/requirements_test.txt commands = - python script/gen_requirements_all.py validate - flake8 {posargs} - pydocstyle {posargs:homeassistant tests} + python script/gen_requirements_all.py validate + flake8 {posargs} + pydocstyle {posargs:homeassistant tests} [testenv:typing] -basepython = {env:PYTHON3_PATH:python3} whitelist_externals=/bin/bash deps = -r{toxinidir}/requirements_test.txt + -c{toxinidir}/homeassistant/package_constraints.txt commands = - /bin/bash -c 'mypy homeassistant/*.py homeassistant/{auth,util}/ homeassistant/helpers/{__init__,aiohttp_client,area_registry,condition,deprecation,dispatcher,entity_values,entityfilter,icon,intent,json,location,signal,state,sun,temperature,translation,typing}.py' + /bin/bash -c 'mypy homeassistant/*.py homeassistant/{auth,util}/ homeassistant/helpers/{__init__,aiohttp_client,area_registry,condition,deprecation,dispatcher,entity_values,entityfilter,icon,intent,json,location,signal,state,sun,temperature,translation,typing}.py' From eb1d7be67cd6f3902e3dd11458bc3cccdf2cf69c Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Mon, 4 Mar 2019 18:30:10 +0530 Subject: [PATCH 077/291] Upgrade youtube_dl to 2019.03.01 (#21647) --- homeassistant/components/media_extractor/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_extractor/__init__.py b/homeassistant/components/media_extractor/__init__.py index d0b3c9e3c00..ff6277242ca 100644 --- a/homeassistant/components/media_extractor/__init__.py +++ b/homeassistant/components/media_extractor/__init__.py @@ -12,7 +12,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID) from homeassistant.helpers import config_validation as cv -REQUIREMENTS = ['youtube_dl==2019.02.18'] +REQUIREMENTS = ['youtube_dl==2019.03.01'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 9662f71c14a..63931513439 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1805,7 +1805,7 @@ yeelight==0.4.3 yeelightsunflower==0.0.10 # homeassistant.components.media_extractor -youtube_dl==2019.02.18 +youtube_dl==2019.03.01 # homeassistant.components.light.zengge zengge==0.2 From 72b6e80d029a8a7fb154691be9bfa246548ad25f Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 4 Mar 2019 14:06:05 +0100 Subject: [PATCH 078/291] Better output of workflow --- .github/main.workflow | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/main.workflow b/.github/main.workflow index fed9496abd5..1c8a94c89f8 100644 --- a/.github/main.workflow +++ b/.github/main.workflow @@ -33,11 +33,11 @@ action "Python 3.5 - tests" { action "Python 3.5 - lints" { uses = "home-assistant/actions/py35-tox@master" needs = ["Python 3.5 - tests"] - args = "-e lint,pylint -p all" + args = "-e lint,pylint -p auto --parallel-live" } action "Pyton 3.5 - typing,cov" { uses = "home-assistant/actions/py35-tox@master" - args = "-e typing,cov -p all" + args = "-e typing,cov -p auto --parallel-live" needs = ["Python 3.5 - lints"] } From 158e25562b7d3adfd07c19d63cf7b3b5d717c830 Mon Sep 17 00:00:00 2001 From: Gijs Reichert Date: Mon, 4 Mar 2019 15:25:28 +0100 Subject: [PATCH 079/291] Cast displaytime to int for JSON RPC (#21649) --- homeassistant/components/notify/kodi.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/notify/kodi.py b/homeassistant/components/notify/kodi.py index 74bfe61d3f2..50d2246cd29 100644 --- a/homeassistant/components/notify/kodi.py +++ b/homeassistant/components/notify/kodi.py @@ -90,7 +90,7 @@ class KodiNotificationService(BaseNotificationService): try: data = kwargs.get(ATTR_DATA) or {} - displaytime = data.get(ATTR_DISPLAYTIME, 10000) + displaytime = int(data.get(ATTR_DISPLAYTIME, 10000)) icon = data.get(ATTR_ICON, "info") title = kwargs.get(ATTR_TITLE, ATTR_TITLE_DEFAULT) await self._server.GUI.ShowNotification( From f62eb22ef8ea4bb90b2c04c2b0cf40565ec3e24d Mon Sep 17 00:00:00 2001 From: Nate Clark Date: Mon, 4 Mar 2019 10:56:41 -0500 Subject: [PATCH 080/291] Add support for DHT and DS18B20 sensors via Konnected firmware (#21189) * mvp basic temperature sensor support * support for DHT temperature & humidity * add support for ds18b20 sensors * improve resolution of device settings * update requirements_all.txt * re-organize new file * don't use filter(lambda: syntax * set unique_id on entities to allow renaming in the UI * leverage base Entity module to do C to F conversion * add option for setting poll_interval * use handler pattern to handle updates from Konnected device * cleanups from code review --- .../components/konnected/__init__.py | 228 ++++++++++-------- .../components/konnected/binary_sensor.py | 10 +- homeassistant/components/konnected/const.py | 27 +++ .../components/konnected/handlers.py | 62 +++++ homeassistant/components/konnected/sensor.py | 124 ++++++++++ homeassistant/components/konnected/switch.py | 13 +- requirements_all.txt | 2 +- 7 files changed, 358 insertions(+), 108 deletions(-) create mode 100644 homeassistant/components/konnected/const.py create mode 100644 homeassistant/components/konnected/handlers.py create mode 100644 homeassistant/components/konnected/sensor.py diff --git a/homeassistant/components/konnected/__init__.py b/homeassistant/components/konnected/__init__.py index e3f9a46743d..3a2dc3b2417 100644 --- a/homeassistant/components/konnected/__init__.py +++ b/homeassistant/components/konnected/__init__.py @@ -14,34 +14,25 @@ from homeassistant.components.discovery import SERVICE_KONNECTED from homeassistant.components.http import HomeAssistantView from homeassistant.const import ( EVENT_HOMEASSISTANT_START, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, - 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, STATE_ON) -from homeassistant.helpers.dispatcher import ( - async_dispatcher_send, dispatcher_send) + HTTP_UNAUTHORIZED, CONF_DEVICES, CONF_BINARY_SENSORS, CONF_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, + STATE_ON) +from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers import discovery from homeassistant.helpers import config_validation as cv +from .const import ( + CONF_ACTIVATION, CONF_API_HOST, + CONF_MOMENTARY, CONF_PAUSE, CONF_POLL_INTERVAL, CONF_REPEAT, + CONF_INVERSE, CONF_BLINK, CONF_DISCOVERY, CONF_DHT_SENSORS, + CONF_DS18B20_SENSORS, DOMAIN, STATE_LOW, STATE_HIGH, PIN_TO_ZONE, + ZONE_TO_PIN, ENDPOINT_ROOT, UPDATE_ENDPOINT, SIGNAL_SENSOR_UPDATE) +from .handlers import HANDLERS + _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['konnected==0.1.4'] - -DOMAIN = 'konnected' - -CONF_ACTIVATION = 'activation' -CONF_API_HOST = 'api_host' -CONF_MOMENTARY = 'momentary' -CONF_PAUSE = 'pause' -CONF_REPEAT = 'repeat' -CONF_INVERSE = 'inverse' -CONF_BLINK = 'blink' -CONF_DISCOVERY = 'discovery' - -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()} +REQUIREMENTS = ['konnected==0.1.5'] _BINARY_SENSOR_SCHEMA = vol.All( vol.Schema({ @@ -53,6 +44,18 @@ _BINARY_SENSOR_SCHEMA = vol.All( }), cv.has_at_least_one_key(CONF_PIN, CONF_ZONE) ) +_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): + vol.All(vol.Lower, vol.In(['dht', 'ds18b20'])), + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_POLL_INTERVAL): + vol.All(vol.Coerce(int), vol.Range(min=1)), + }), 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), @@ -79,6 +82,8 @@ CONFIG_SCHEMA = vol.Schema( vol.Required(CONF_ID): cv.matches_regex("[0-9a-f]{12}"), vol.Optional(CONF_BINARY_SENSORS): vol.All( cv.ensure_list, [_BINARY_SENSOR_SCHEMA]), + vol.Optional(CONF_SENSORS): vol.All( + cv.ensure_list, [_SENSOR_SCHEMA]), vol.Optional(CONF_SWITCHES): vol.All( cv.ensure_list, [_SWITCH_SCHEMA]), vol.Optional(CONF_HOST): cv.string, @@ -93,10 +98,6 @@ CONFIG_SCHEMA = vol.Schema( DEPENDENCIES = ['http'] -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.""" @@ -180,30 +181,30 @@ class ConfiguredDevice: def save_data(self): """Save the device configuration to `hass.data`.""" - sensors = {} + binary_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] - sensors[pin] = { + binary_sensors[pin] = { CONF_TYPE: entity[CONF_TYPE], CONF_NAME: entity.get(CONF_NAME, 'Konnected {} Zone {}'.format( self.device_id[6:], PIN_TO_ZONE[pin])), CONF_INVERSE: entity.get(CONF_INVERSE), ATTR_STATE: None } - _LOGGER.debug('Set up sensor %s (initial state: %s)', - sensors[pin].get('name'), - sensors[pin].get(ATTR_STATE)) + _LOGGER.debug('Set up binary_sensor %s (initial state: %s)', + binary_sensors[pin].get('name'), + binary_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']] + if CONF_ZONE in entity: + pin = ZONE_TO_PIN[entity[CONF_ZONE]] else: - pin = entity['pin'] + pin = entity[CONF_PIN] act = { CONF_PIN: pin, @@ -216,10 +217,32 @@ class ConfiguredDevice: CONF_PAUSE: entity.get(CONF_PAUSE), CONF_REPEAT: entity.get(CONF_REPEAT)} actuators.append(act) - _LOGGER.debug('Set up actuator %s', act) + _LOGGER.debug('Set up switch %s', act) + + sensors = [] + for entity in self.config.get(CONF_SENSORS) or []: + if CONF_ZONE in entity: + pin = ZONE_TO_PIN[entity[CONF_ZONE]] + else: + pin = entity[CONF_PIN] + + sensor = { + CONF_PIN: pin, + CONF_NAME: entity.get( + CONF_NAME, 'Konnected {} Sensor {}'.format( + self.device_id[6:], PIN_TO_ZONE[pin])), + CONF_TYPE: entity[CONF_TYPE], + CONF_POLL_INTERVAL: entity.get(CONF_POLL_INTERVAL) + } + sensors.append(sensor) + _LOGGER.debug('Set up %s sensor %s (initial state: %s)', + sensor.get(CONF_TYPE), + sensor.get(CONF_NAME), + sensor.get(ATTR_STATE)) device_data = { - CONF_BINARY_SENSORS: sensors, + CONF_BINARY_SENSORS: binary_sensors, + CONF_SENSORS: sensors, CONF_SWITCHES: actuators, CONF_BLINK: self.config.get(CONF_BLINK), CONF_DISCOVERY: self.config.get(CONF_DISCOVERY) @@ -232,12 +255,10 @@ class ConfiguredDevice: DOMAIN, CONF_DEVICES, self.device_id, device_data) self.hass.data[DOMAIN][CONF_DEVICES][self.device_id] = device_data - discovery.load_platform( - self.hass, 'binary_sensor', DOMAIN, - {'device_id': self.device_id}, self.hass_config) - discovery.load_platform( - self.hass, 'switch', DOMAIN, - {'device_id': self.device_id}, self.hass_config) + for platform in ['binary_sensor', 'sensor', 'switch']: + discovery.load_platform( + self.hass, platform, DOMAIN, + {'device_id': self.device_id}, self.hass_config) class DiscoveredDevice: @@ -283,8 +304,8 @@ class DiscoveredDevice: """Return the configuration stored in `hass.data` for this device.""" return self.hass.data[DOMAIN][CONF_DEVICES].get(self.device_id) - def sensor_configuration(self): - """Return the configuration map for syncing sensors.""" + def binary_sensor_configuration(self): + """Return the configuration map for syncing binary sensors.""" return [{'pin': p} for p in self.stored_configuration[CONF_BINARY_SENSORS]] @@ -295,6 +316,19 @@ class DiscoveredDevice: else 1)} for data in self.stored_configuration[CONF_SWITCHES]] + def dht_sensor_configuration(self): + """Return the configuration map for syncing DHT sensors.""" + return [{CONF_PIN: sensor[CONF_PIN], + CONF_POLL_INTERVAL: sensor[CONF_POLL_INTERVAL]} for sensor + in self.stored_configuration[CONF_SENSORS] + if sensor[CONF_TYPE] == 'dht'] + + def ds18b20_sensor_configuration(self): + """Return the configuration map for syncing DS18B20 sensors.""" + return [{'pin': sensor[CONF_PIN]} for sensor + in self.stored_configuration[CONF_SENSORS] + if sensor[CONF_TYPE] == 'ds18b20'] + def update_initial_states(self): """Update the initial state of each sensor from status poll.""" for sensor_data in self.status.get('sensors'): @@ -311,57 +345,55 @@ class DiscoveredDevice: SIGNAL_SENSOR_UPDATE.format(entity_id), state) - 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) - + def desired_settings_payload(self): + """Return a dict representing the desired device configuration.""" 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) + return { + 'sensors': self.binary_sensor_configuration(), + 'actuators': self.actuator_configuration(), + 'dht_sensors': self.dht_sensor_configuration(), + 'ds18b20_sensors': self.ds18b20_sensor_configuration(), + 'auth_token': self.hass.data[DOMAIN].get(CONF_ACCESS_TOKEN), + 'endpoint': desired_api_endpoint, + 'blink': self.stored_configuration.get(CONF_BLINK), + 'discovery': self.stored_configuration.get(CONF_DISCOVERY) + } - if (desired_sensor_configuration != current_sensor_configuration) or \ - (current_actuator_config != desired_actuator_config) or \ - (current_api_endpoint != desired_api_endpoint) or \ - (self.status.get(CONF_BLINK) != - self.stored_configuration.get(CONF_BLINK)) or \ - (self.status.get(CONF_DISCOVERY) != - self.stored_configuration.get(CONF_DISCOVERY)): + def current_settings_payload(self): + """Return a dict of configuration currently stored on the device.""" + settings = self.status['settings'] + if not settings: + settings = {} + + return { + 'sensors': [ + {'pin': s[CONF_PIN]} for s in self.status.get('sensors')], + 'actuators': self.status.get('actuators'), + 'dht_sensors': self.status.get(CONF_DHT_SENSORS), + 'ds18b20_sensors': self.status.get(CONF_DS18B20_SENSORS), + 'auth_token': settings.get('token'), + 'endpoint': settings.get('apiUrl'), + 'blink': settings.get(CONF_BLINK), + 'discovery': settings.get(CONF_DISCOVERY) + } + + def sync_device_config(self): + """Sync the new pin configuration to the Konnected device if needed.""" + _LOGGER.debug('Device %s settings payload: %s', self.device_id, + self.desired_settings_payload()) + if self.desired_settings_payload() != self.current_settings_payload(): _LOGGER.info('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, - blink=self.stored_configuration.get(CONF_BLINK), - discovery=self.stored_configuration.get(CONF_DISCOVERY) - ) + self.client.put_settings(**self.desired_settings_payload()) 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 @@ -406,8 +438,7 @@ class KonnectedView(HomeAssistantView): hass.states.get(pin[ATTR_ENTITY_ID]).state, pin[CONF_ACTIVATION])}) - async def put(self, request: Request, device_id, - pin_num=None, state=None) -> Response: + async def put(self, request: Request, device_id) -> Response: """Receive a sensor update via PUT request and async set state.""" hass = request.app['hass'] data = hass.data[DOMAIN] @@ -415,11 +446,10 @@ class KonnectedView(HomeAssistantView): 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.")) + _LOGGER.error(("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): @@ -430,20 +460,20 @@ class KonnectedView(HomeAssistantView): if device is None: return self.json_message('unregistered device', status_code=HTTP_BAD_REQUEST) - pin_data = device[CONF_BINARY_SENSORS].get(pin_num) + pin_data = device[CONF_BINARY_SENSORS].get(pin_num) or \ + next((s for s in device[CONF_SENSORS] if s[CONF_PIN] == pin_num), + None) 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_NOT_FOUND) - state = bool(int(state)) - if pin_data.get(CONF_INVERSE): - state = not state + pin_data['device_id'] = device_id + + for attr in ['state', 'temp', 'humi', 'addr']: + value = payload.get(attr) + handler = HANDLERS.get(attr) + if value is not None and handler: + hass.async_create_task(handler(hass, pin_data, payload)) - async_dispatcher_send( - hass, SIGNAL_SENSOR_UPDATE.format(entity_id), state) return self.json_message('ok') diff --git a/homeassistant/components/konnected/binary_sensor.py b/homeassistant/components/konnected/binary_sensor.py index cb15e44e798..a47f81b9556 100644 --- a/homeassistant/components/konnected/binary_sensor.py +++ b/homeassistant/components/konnected/binary_sensor.py @@ -39,9 +39,13 @@ class KonnectedBinarySensor(BinarySensorDevice): 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) + self._unique_id = '{}-{}'.format(device_id, PIN_TO_ZONE[pin_num]) + self._name = self._data.get(CONF_NAME) + + @property + def unique_id(self) -> str: + """Return the unique id.""" + return self._unique_id @property def name(self): diff --git a/homeassistant/components/konnected/const.py b/homeassistant/components/konnected/const.py new file mode 100644 index 00000000000..88293adfc81 --- /dev/null +++ b/homeassistant/components/konnected/const.py @@ -0,0 +1,27 @@ +"""Konnected constants.""" + +DOMAIN = 'konnected' + +CONF_ACTIVATION = 'activation' +CONF_API_HOST = 'api_host' +CONF_MOMENTARY = 'momentary' +CONF_PAUSE = 'pause' +CONF_POLL_INTERVAL = 'poll_interval' +CONF_PRECISION = 'precision' +CONF_REPEAT = 'repeat' +CONF_INVERSE = 'inverse' +CONF_BLINK = 'blink' +CONF_DISCOVERY = 'discovery' +CONF_DHT_SENSORS = 'dht_sensors' +CONF_DS18B20_SENSORS = 'ds18b20_sensors' + +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()} + +ENDPOINT_ROOT = '/api/konnected' +UPDATE_ENDPOINT = (ENDPOINT_ROOT + r'/device/{device_id:[a-zA-Z0-9]+}') +SIGNAL_SENSOR_UPDATE = 'konnected.{}.update' +SIGNAL_DS18B20_NEW = 'konnected.ds18b20.new' diff --git a/homeassistant/components/konnected/handlers.py b/homeassistant/components/konnected/handlers.py new file mode 100644 index 00000000000..6e92e7f20c8 --- /dev/null +++ b/homeassistant/components/konnected/handlers.py @@ -0,0 +1,62 @@ +"""Handle Konnected messages.""" +import logging + +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.util import decorator +from homeassistant.const import ( + ATTR_ENTITY_ID, ATTR_STATE, + DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_HUMIDITY) + +from .const import (CONF_INVERSE, SIGNAL_SENSOR_UPDATE, SIGNAL_DS18B20_NEW) + +_LOGGER = logging.getLogger(__name__) +HANDLERS = decorator.Registry() + + +@HANDLERS.register('state') +async def async_handle_state_update(hass, context, msg): + """Handle a binary sensor state update.""" + _LOGGER.debug("[state handler] context: %s msg: %s", context, msg) + entity_id = context.get(ATTR_ENTITY_ID) + state = bool(int(msg.get(ATTR_STATE))) + if msg.get(CONF_INVERSE): + state = not state + + async_dispatcher_send( + hass, SIGNAL_SENSOR_UPDATE.format(entity_id), state) + + +@HANDLERS.register('temp') +async def async_handle_temp_update(hass, context, msg): + """Handle a temperature sensor state update.""" + _LOGGER.debug("[temp handler] context: %s msg: %s", context, msg) + entity_id, temp = context.get(DEVICE_CLASS_TEMPERATURE), msg.get('temp') + if entity_id: + async_dispatcher_send( + hass, SIGNAL_SENSOR_UPDATE.format(entity_id), temp) + + +@HANDLERS.register('humi') +async def async_handle_humi_update(hass, context, msg): + """Handle a humidity sensor state update.""" + _LOGGER.debug("[humi handler] context: %s msg: %s", context, msg) + entity_id, humi = context.get(DEVICE_CLASS_HUMIDITY), msg.get('humi') + if entity_id: + async_dispatcher_send( + hass, SIGNAL_SENSOR_UPDATE.format(entity_id), humi) + + +@HANDLERS.register('addr') +async def async_handle_addr_update(hass, context, msg): + """Handle an addressable sensor update.""" + _LOGGER.debug("[addr handler] context: %s msg: %s", context, msg) + addr, temp = msg.get('addr'), msg.get('temp') + entity_id = context.get(addr) + if entity_id: + async_dispatcher_send( + hass, SIGNAL_SENSOR_UPDATE.format(entity_id), temp) + else: + msg['device_id'] = context.get('device_id') + msg['temperature'] = temp + msg['addr'] = addr + async_dispatcher_send(hass, SIGNAL_DS18B20_NEW, msg) diff --git a/homeassistant/components/konnected/sensor.py b/homeassistant/components/konnected/sensor.py new file mode 100644 index 00000000000..eb3f5511346 --- /dev/null +++ b/homeassistant/components/konnected/sensor.py @@ -0,0 +1,124 @@ +"""Support for DHT and DS18B20 sensors attached to a Konnected device.""" +import logging + +from homeassistant.components.konnected.const import ( + DOMAIN as KONNECTED_DOMAIN, SIGNAL_DS18B20_NEW, SIGNAL_SENSOR_UPDATE) +from homeassistant.const import ( + CONF_DEVICES, CONF_PIN, CONF_TYPE, CONF_NAME, CONF_SENSORS, + DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_TEMPERATURE, TEMP_CELSIUS) +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['konnected'] + +SENSOR_TYPES = { + DEVICE_CLASS_TEMPERATURE: ['Temperature', TEMP_CELSIUS], + DEVICE_CLASS_HUMIDITY: ['Humidity', '%'] +} + + +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): + """Set up sensors attached to a Konnected device.""" + if discovery_info is None: + return + + data = hass.data[KONNECTED_DOMAIN] + device_id = discovery_info['device_id'] + sensors = [] + + # Initialize all DHT sensors. + dht_sensors = [sensor for sensor + in data[CONF_DEVICES][device_id][CONF_SENSORS] + if sensor[CONF_TYPE] == 'dht'] + for sensor in dht_sensors: + sensors.append( + KonnectedSensor(device_id, sensor, DEVICE_CLASS_TEMPERATURE)) + sensors.append( + KonnectedSensor(device_id, sensor, DEVICE_CLASS_HUMIDITY)) + + async_add_entities(sensors) + + @callback + def async_add_ds18b20(attrs): + """Add new KonnectedSensor representing a ds18b20 sensor.""" + sensor_config = next((s for s + in data[CONF_DEVICES][device_id][CONF_SENSORS] + if s[CONF_TYPE] == 'ds18b20' + and s[CONF_PIN] == attrs.get(CONF_PIN)), None) + + async_add_entities([ + KonnectedSensor(device_id, sensor_config, DEVICE_CLASS_TEMPERATURE, + addr=attrs.get('addr'), + initial_state=attrs.get('temp')) + ], True) + + # DS18B20 sensors entities are initialized when they report for the first + # time. Set up a listener for that signal from the Konnected component. + async_dispatcher_connect(hass, SIGNAL_DS18B20_NEW, async_add_ds18b20) + + +class KonnectedSensor(Entity): + """Represents a Konnected DHT Sensor.""" + + def __init__(self, device_id, data, sensor_type, addr=None, + initial_state=None): + """Initialize the entity for a single sensor_type.""" + self._addr = addr + self._data = data + self._device_id = device_id + self._type = sensor_type + self._pin_num = self._data.get(CONF_PIN) + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + self._unique_id = addr or '{}-{}-{}'.format( + device_id, self._pin_num, sensor_type) + + # set initial state if known at initialization + self._state = initial_state + if self._state: + self._state = round(float(self._state), 1) + + # set entity name if given + self._name = self._data.get(CONF_NAME) + if self._name: + self._name += ' ' + SENSOR_TYPES[sensor_type][0] + + @property + def unique_id(self) -> str: + """Return the unique id.""" + return self._unique_id + + @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.""" + return self._unit_of_measurement + + async def async_added_to_hass(self): + """Store entity_id and register state change callback.""" + entity_id_key = self._addr or self._type + self._data[entity_id_key] = 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.""" + if self._type == DEVICE_CLASS_HUMIDITY: + self._state = int(float(state)) + else: + self._state = round(float(state), 1) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/konnected/switch.py b/homeassistant/components/konnected/switch.py index 897933e6d80..1a4b495297e 100644 --- a/homeassistant/components/konnected/switch.py +++ b/homeassistant/components/konnected/switch.py @@ -6,7 +6,7 @@ from homeassistant.components.konnected import ( CONF_PAUSE, CONF_REPEAT, STATE_LOW, STATE_HIGH) from homeassistant.helpers.entity import ToggleEntity from homeassistant.const import ( - CONF_DEVICES, CONF_SWITCHES, CONF_PIN, ATTR_STATE) + ATTR_STATE, CONF_DEVICES, CONF_NAME, CONF_PIN, CONF_SWITCHES) _LOGGER = logging.getLogger(__name__) @@ -40,10 +40,13 @@ class KonnectedSwitch(ToggleEntity): self._pause = self._data.get(CONF_PAUSE) self._repeat = self._data.get(CONF_REPEAT) 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])) - _LOGGER.debug("Created new switch: %s", self._name) + self._unique_id = '{}-{}'.format(device_id, PIN_TO_ZONE[pin_num]) + self._name = self._data.get(CONF_NAME) + + @property + def unique_id(self) -> str: + """Return the unique id.""" + return self._unique_id @property def name(self): diff --git a/requirements_all.txt b/requirements_all.txt index 63931513439..691ad6d1539 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -607,7 +607,7 @@ keyrings.alt==3.1.1 kiwiki-client==0.1.1 # homeassistant.components.konnected -konnected==0.1.4 +konnected==0.1.5 # homeassistant.components.eufy lakeside==0.12 From 8213016eaf06035884d76565f62ed5b32298e7a1 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 4 Mar 2019 09:51:12 -0800 Subject: [PATCH 081/291] Allow targeting areas in service calls (#21472) * Allow targeting areas in service calls * Lint + Type * Address comments --- homeassistant/components/__init__.py | 4 +- homeassistant/components/alert/__init__.py | 2 +- .../components/automation/__init__.py | 6 +- homeassistant/components/group/__init__.py | 4 +- .../components/image_processing/__init__.py | 2 +- homeassistant/components/light/__init__.py | 2 +- homeassistant/components/scene/__init__.py | 2 +- homeassistant/components/script/__init__.py | 11 ++- homeassistant/const.py | 3 + homeassistant/helpers/area_registry.py | 5 + homeassistant/helpers/device_registry.py | 9 ++ homeassistant/helpers/entity_component.py | 8 +- homeassistant/helpers/entity_registry.py | 10 +- homeassistant/helpers/service.py | 64 ++++++++++--- tests/helpers/test_entity_component.py | 15 +-- tests/helpers/test_service.py | 95 +++++++++++++++---- 16 files changed, 180 insertions(+), 62 deletions(-) diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index 8715f0baa96..f3045df6a12 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -17,7 +17,7 @@ import voluptuous as vol import homeassistant.core as ha import homeassistant.config as conf_util from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.service import extract_entity_ids +from homeassistant.helpers.service import async_extract_entity_ids from homeassistant.helpers import intent from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, SERVICE_TOGGLE, @@ -70,7 +70,7 @@ async def async_setup(hass: ha.HomeAssistant, config: dict) -> Awaitable[bool]: """Set up general services related to Home Assistant.""" async def async_handle_turn_service(service): """Handle calls to homeassistant.turn_on/off.""" - entity_ids = extract_entity_ids(hass, service) + entity_ids = await async_extract_entity_ids(hass, service) # Generic turn on/off method requires entity id if not entity_ids: diff --git a/homeassistant/components/alert/__init__.py b/homeassistant/components/alert/__init__.py index f92fd6b187b..4c990d62d4b 100644 --- a/homeassistant/components/alert/__init__.py +++ b/homeassistant/components/alert/__init__.py @@ -89,7 +89,7 @@ async def async_setup(hass, config): async def async_handle_alert_service(service_call): """Handle calls to alert services.""" - alert_ids = service.extract_entity_ids(hass, service_call) + alert_ids = await service.async_extract_entity_ids(hass, service_call) for alert_id in alert_ids: for alert in entities: diff --git a/homeassistant/components/automation/__init__.py b/homeassistant/components/automation/__init__.py index ad231a2a348..5a7b19ce4e3 100644 --- a/homeassistant/components/automation/__init__.py +++ b/homeassistant/components/automation/__init__.py @@ -120,7 +120,7 @@ async def async_setup(hass, config): async def trigger_service_handler(service_call): """Handle automation triggers.""" tasks = [] - for entity in component.async_extract_from_service(service_call): + for entity in await component.async_extract_from_service(service_call): tasks.append(entity.async_trigger( service_call.data.get(ATTR_VARIABLES), skip_condition=True, @@ -133,7 +133,7 @@ async def async_setup(hass, config): """Handle automation turn on/off service calls.""" tasks = [] method = 'async_{}'.format(service_call.service) - for entity in component.async_extract_from_service(service_call): + for entity in await component.async_extract_from_service(service_call): tasks.append(getattr(entity, method)()) if tasks: @@ -142,7 +142,7 @@ async def async_setup(hass, config): async def toggle_service_handler(service_call): """Handle automation toggle service calls.""" tasks = [] - for entity in component.async_extract_from_service(service_call): + for entity in await component.async_extract_from_service(service_call): if entity.is_on: tasks.append(entity.async_turn_off()) else: diff --git a/homeassistant/components/group/__init__.py b/homeassistant/components/group/__init__.py index e0315209ba1..80ac01a78ac 100644 --- a/homeassistant/components/group/__init__.py +++ b/homeassistant/components/group/__init__.py @@ -300,8 +300,8 @@ async def async_setup(hass, config): visible = service.data.get(ATTR_VISIBLE) tasks = [] - for group in component.async_extract_from_service(service, - expand_group=False): + for group in await component.async_extract_from_service( + service, expand_group=False): group.visible = visible tasks.append(group.async_update_ha_state()) diff --git a/homeassistant/components/image_processing/__init__.py b/homeassistant/components/image_processing/__init__.py index f854384bb03..aa3b2db7369 100644 --- a/homeassistant/components/image_processing/__init__.py +++ b/homeassistant/components/image_processing/__init__.py @@ -75,7 +75,7 @@ async def async_setup(hass, config): async def async_scan_service(service): """Service handler for scan.""" - image_entities = component.async_extract_from_service(service) + image_entities = await component.async_extract_from_service(service) update_tasks = [] for entity in image_entities: diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index 93d7a67c6f0..ef82167b222 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -256,7 +256,7 @@ async def async_setup(hass, config): params = service.data.copy() # Convert the entity ids to valid light ids - target_lights = component.async_extract_from_service(service) + target_lights = await component.async_extract_from_service(service) params.pop(ATTR_ENTITY_ID, None) if service.context.user_id: diff --git a/homeassistant/components/scene/__init__.py b/homeassistant/components/scene/__init__.py index 8a7934bd694..35eedabd58a 100644 --- a/homeassistant/components/scene/__init__.py +++ b/homeassistant/components/scene/__init__.py @@ -68,7 +68,7 @@ async def async_setup(hass, config): async def async_handle_scene_service(service): """Handle calls to the switch services.""" - target_scenes = component.async_extract_from_service(service) + target_scenes = await component.async_extract_from_service(service) tasks = [scene.async_activate() for scene in target_scenes] if tasks: diff --git a/homeassistant/components/script/__init__.py b/homeassistant/components/script/__init__.py index fceedb57428..873a18120ac 100644 --- a/homeassistant/components/script/__init__.py +++ b/homeassistant/components/script/__init__.py @@ -74,20 +74,21 @@ async def async_setup(hass, config): # We could turn on script directly here, but we only want to offer # one way to do it. Otherwise no easy way to detect invocations. var = service.data.get(ATTR_VARIABLES) - for script in component.async_extract_from_service(service): + for script in await component.async_extract_from_service(service): await hass.services.async_call(DOMAIN, script.object_id, var, context=service.context) async def turn_off_service(service): """Cancel a script.""" # Stopping a script is ok to be done in parallel - await asyncio.wait( - [script.async_turn_off() for script - in component.async_extract_from_service(service)], loop=hass.loop) + await asyncio.wait([ + script.async_turn_off() for script + in await component.async_extract_from_service(service) + ], loop=hass.loop) async def toggle_service(service): """Toggle a script.""" - for script in component.async_extract_from_service(service): + for script in await component.async_extract_from_service(service): await script.async_toggle(context=service.context) hass.services.async_register(DOMAIN, SERVICE_RELOAD, reload_service, diff --git a/homeassistant/const.py b/homeassistant/const.py index 49194c06c17..f24fbcc97ac 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -245,6 +245,9 @@ ATTR_NAME = 'name' # Contains one string or a list of strings, each being an entity id ATTR_ENTITY_ID = 'entity_id' +# Contains one string or a list of strings, each being an area id +ATTR_AREA_ID = 'area_id' + # String with a friendly name for the entity ATTR_FRIENDLY_NAME = 'friendly_name' diff --git a/homeassistant/helpers/area_registry.py b/homeassistant/helpers/area_registry.py index 3fa820f8350..644d14cf869 100644 --- a/homeassistant/helpers/area_registry.py +++ b/homeassistant/helpers/area_registry.py @@ -37,6 +37,11 @@ class AreaRegistry: self.areas = {} # type: MutableMapping[str, AreaEntry] self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + @callback + def async_get_area(self, area_id: str) -> Optional[AreaEntry]: + """Get all areas.""" + return self.areas.get(area_id) + @callback def async_list_areas(self) -> Iterable[AreaEntry]: """Get all areas.""" diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 21c3b0d0209..9c8ee27d0d2 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1,6 +1,7 @@ """Provide a way to connect entities belonging to one device.""" import logging import uuid +from typing import List from collections import OrderedDict @@ -280,3 +281,11 @@ async def async_get_registry(hass) -> DeviceRegistry: task = hass.data[DATA_REGISTRY] = hass.async_create_task(_load_reg()) return await task + + +@callback +def async_entries_for_area(registry: DeviceRegistry, area_id: str) \ + -> List[DeviceEntry]: + """Return entries that match an area.""" + return [device for device in registry.devices.values() + if device.area_id == area_id] diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index 44213e6d7c8..744cf36ea66 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -12,7 +12,7 @@ from homeassistant.const import ( from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_per_platform, discovery -from homeassistant.helpers.service import extract_entity_ids +from homeassistant.helpers.service import async_extract_entity_ids from homeassistant.loader import bind_hass from homeassistant.util import slugify from .entity_platform import EntityPlatform @@ -153,8 +153,7 @@ class EntityComponent: await platform.async_reset() return True - @callback - def async_extract_from_service(self, service, expand_group=True): + async def async_extract_from_service(self, service, expand_group=True): """Extract all known and available entities from a service call. Will return all entities if no entities specified in call. @@ -174,7 +173,8 @@ class EntityComponent: return [entity for entity in self.entities if entity.available] - entity_ids = set(extract_entity_ids(self.hass, service, expand_group)) + entity_ids = await async_extract_entity_ids( + self.hass, service, expand_group) return [entity for entity in self.entities if entity.available and entity.entity_id in entity_ids] diff --git a/homeassistant/helpers/entity_registry.py b/homeassistant/helpers/entity_registry.py index 6ee32f642bc..c0a0dfaa7d9 100644 --- a/homeassistant/helpers/entity_registry.py +++ b/homeassistant/helpers/entity_registry.py @@ -10,7 +10,7 @@ timer. from collections import OrderedDict from itertools import chain import logging -from typing import Optional +from typing import Optional, List import weakref import attr @@ -292,6 +292,14 @@ async def async_get_registry(hass) -> EntityRegistry: return await task +@callback +def async_entries_for_device(registry: EntityRegistry, device_id: str) \ + -> List[RegistryEntry]: + """Return entries that match a device.""" + return [entry for entry in registry.entities.values() + if entry.device_id == device_id] + + async def _async_migrate(entities): """Migrate the YAML config file to storage helper format.""" return { diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index 22138d7c2aa..b685e0d67c7 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -6,7 +6,8 @@ from os import path import voluptuous as vol from homeassistant.auth.permissions.const import POLICY_CONTROL -from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL +from homeassistant.const import ( + ATTR_ENTITY_ID, ENTITY_MATCH_ALL, ATTR_AREA_ID) import homeassistant.core as ha from homeassistant.exceptions import TemplateError, Unauthorized, UnknownUser from homeassistant.helpers import template @@ -89,30 +90,64 @@ async def async_call_from_config(hass, config, blocking=False, variables=None, def extract_entity_ids(hass, service_call, expand_group=True): """Extract a list of entity ids from a service call. + Will convert group entity ids to the entity ids it represents. + """ + return run_coroutine_threadsafe( + async_extract_entity_ids(hass, service_call, expand_group), hass.loop + ).result() + + +@bind_hass +async def async_extract_entity_ids(hass, service_call, expand_group=True): + """Extract a list of entity ids from a service call. + Will convert group entity ids to the entity ids it represents. Async friendly. """ - if not (service_call.data and ATTR_ENTITY_ID in service_call.data): + entity_ids = service_call.data.get(ATTR_ENTITY_ID) + area_ids = service_call.data.get(ATTR_AREA_ID) + + if not entity_ids and not area_ids: return [] - group = hass.components.group + extracted = set() - # Entity ID attr can be a list or a string - service_ent_id = service_call.data[ATTR_ENTITY_ID] + if entity_ids: + # Entity ID attr can be a list or a string + if isinstance(entity_ids, str): + entity_ids = [entity_ids] - if expand_group: + if expand_group: + entity_ids = \ + hass.components.group.expand_entity_ids(entity_ids) - if isinstance(service_ent_id, str): - return group.expand_entity_ids([service_ent_id]) + extracted.update(entity_ids) - return [ent_id for ent_id in - group.expand_entity_ids(service_ent_id)] + if area_ids: + if isinstance(area_ids, str): + area_ids = [area_ids] - if isinstance(service_ent_id, str): - return [service_ent_id] + dev_reg, ent_reg = await asyncio.gather( + hass.helpers.device_registry.async_get_registry(), + hass.helpers.entity_registry.async_get_registry(), + ) + devices = [ + device + for area_id in area_ids + for device in + hass.helpers.device_registry.async_entries_for_area( + dev_reg, area_id) + ] + extracted.update( + entry.entity_id + for device in devices + for entry in + hass.helpers.entity_registry.async_entries_for_device( + ent_reg, device.id) + ) - return service_ent_id + return extracted @bind_hass @@ -213,8 +248,7 @@ async def entity_service_call(hass, platforms, func, call, service_name=''): if not target_all_entities: # A set of entities we're trying to target. - entity_ids = set( - extract_entity_ids(hass, call, True)) + entity_ids = await async_extract_entity_ids(hass, call, True) # If the service function is a string, we'll pass it the service call data if isinstance(func, str): diff --git a/tests/helpers/test_entity_component.py b/tests/helpers/test_entity_component.py index 27e33a4fe7d..163261a4b81 100644 --- a/tests/helpers/test_entity_component.py +++ b/tests/helpers/test_entity_component.py @@ -206,7 +206,7 @@ def test_extract_from_service_available_device(hass): assert ['test_domain.test_1', 'test_domain.test_3'] == \ sorted(ent.entity_id for ent in - component.async_extract_from_service(call_1)) + (yield from component.async_extract_from_service(call_1))) call_2 = ha.ServiceCall('test', 'service', data={ 'entity_id': ['test_domain.test_3', 'test_domain.test_4'], @@ -214,7 +214,7 @@ def test_extract_from_service_available_device(hass): assert ['test_domain.test_3'] == \ sorted(ent.entity_id for ent in - component.async_extract_from_service(call_2)) + (yield from component.async_extract_from_service(call_2))) @asyncio.coroutine @@ -275,7 +275,7 @@ def test_extract_from_service_returns_all_if_no_entity_id(hass): assert ['test_domain.test_1', 'test_domain.test_2'] == \ sorted(ent.entity_id for ent in - component.async_extract_from_service(call)) + (yield from component.async_extract_from_service(call))) @asyncio.coroutine @@ -293,7 +293,7 @@ def test_extract_from_service_filter_out_non_existing_entities(hass): assert ['test_domain.test_2'] == \ [ent.entity_id for ent - in component.async_extract_from_service(call)] + in (yield from component.async_extract_from_service(call))] @asyncio.coroutine @@ -308,7 +308,8 @@ def test_extract_from_service_no_group_expand(hass): 'entity_id': ['group.test_group'] }) - extracted = component.async_extract_from_service(call, expand_group=False) + extracted = yield from component.async_extract_from_service( + call, expand_group=False) assert extracted == [test_group] @@ -466,7 +467,7 @@ async def test_extract_all_omit_entity_id(hass, caplog): assert ['test_domain.test_1', 'test_domain.test_2'] == \ sorted(ent.entity_id for ent in - component.async_extract_from_service(call)) + await component.async_extract_from_service(call)) assert ('Not passing an entity ID to a service to target all entities is ' 'deprecated') in caplog.text @@ -483,6 +484,6 @@ async def test_extract_all_use_match_all(hass, caplog): assert ['test_domain.test_1', 'test_domain.test_2'] == \ sorted(ent.entity_id for ent in - component.async_extract_from_service(call)) + await component.async_extract_from_service(call)) assert ('Not passing an entity ID to a service to target all entities is ' 'deprecated') not in caplog.text diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 35e89fc5218..854ee9c74f6 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -15,8 +15,11 @@ from homeassistant.helpers import service, template from homeassistant.setup import async_setup_component import homeassistant.helpers.config_validation as cv from homeassistant.auth.permissions import PolicyPermissions - -from tests.common import get_test_home_assistant, mock_service, mock_coro +from homeassistant.helpers import ( + device_registry as dev_reg, entity_registry as ent_reg) +from tests.common import ( + get_test_home_assistant, mock_service, mock_coro, mock_registry, + mock_device_registry) @pytest.fixture @@ -163,29 +166,83 @@ class TestServiceHelpers(unittest.TestCase): }) assert 3 == mock_log.call_count - def test_extract_entity_ids(self): - """Test extract_entity_ids method.""" - self.hass.states.set('light.Bowl', STATE_ON) - self.hass.states.set('light.Ceiling', STATE_OFF) - self.hass.states.set('light.Kitchen', STATE_OFF) - loader.get_component(self.hass, 'group').Group.create_group( - self.hass, 'test', ['light.Ceiling', 'light.Kitchen']) +async def test_extract_entity_ids(hass): + """Test extract_entity_ids method.""" + hass.states.async_set('light.Bowl', STATE_ON) + hass.states.async_set('light.Ceiling', STATE_OFF) + hass.states.async_set('light.Kitchen', STATE_OFF) - call = ha.ServiceCall('light', 'turn_on', - {ATTR_ENTITY_ID: 'light.Bowl'}) + await loader.get_component(hass, 'group').Group.async_create_group( + hass, 'test', ['light.Ceiling', 'light.Kitchen']) - assert ['light.bowl'] == \ - service.extract_entity_ids(self.hass, call) + call = ha.ServiceCall('light', 'turn_on', + {ATTR_ENTITY_ID: 'light.Bowl'}) - call = ha.ServiceCall('light', 'turn_on', - {ATTR_ENTITY_ID: 'group.test'}) + assert {'light.bowl'} == \ + await service.async_extract_entity_ids(hass, call) - assert ['light.ceiling', 'light.kitchen'] == \ - service.extract_entity_ids(self.hass, call) + call = ha.ServiceCall('light', 'turn_on', + {ATTR_ENTITY_ID: 'group.test'}) - assert ['group.test'] == service.extract_entity_ids( - self.hass, call, expand_group=False) + assert {'light.ceiling', 'light.kitchen'} == \ + await service.async_extract_entity_ids(hass, call) + + assert {'group.test'} == await service.async_extract_entity_ids( + hass, call, expand_group=False) + + +async def test_extract_entity_ids_from_area(hass): + """Test extract_entity_ids method with areas.""" + hass.states.async_set('light.Bowl', STATE_ON) + hass.states.async_set('light.Ceiling', STATE_OFF) + hass.states.async_set('light.Kitchen', STATE_OFF) + + device_in_area = dev_reg.DeviceEntry(area_id='test-area') + device_no_area = dev_reg.DeviceEntry() + device_diff_area = dev_reg.DeviceEntry(area_id='diff-area') + + mock_device_registry(hass, { + device_in_area.id: device_in_area, + device_no_area.id: device_no_area, + device_diff_area.id: device_diff_area, + }) + + entity_in_area = ent_reg.RegistryEntry( + entity_id='light.in_area', + unique_id='in-area-id', + platform='test', + device_id=device_in_area.id, + ) + entity_no_area = ent_reg.RegistryEntry( + entity_id='light.no_area', + unique_id='no-area-id', + platform='test', + device_id=device_no_area.id, + ) + entity_diff_area = ent_reg.RegistryEntry( + entity_id='light.diff_area', + unique_id='diff-area-id', + platform='test', + device_id=device_diff_area.id, + ) + mock_registry(hass, { + entity_in_area.entity_id: entity_in_area, + entity_no_area.entity_id: entity_no_area, + entity_diff_area.entity_id: entity_diff_area, + }) + + call = ha.ServiceCall('light', 'turn_on', + {'area_id': 'test-area'}) + + assert {'light.in_area'} == \ + await service.async_extract_entity_ids(hass, call) + + call = ha.ServiceCall('light', 'turn_on', + {'area_id': ['test-area', 'diff-area']}) + + assert {'light.in_area', 'light.diff_area'} == \ + await service.async_extract_entity_ids(hass, call) @asyncio.coroutine From 53755105351151277e1843caf2a48efe1ac8441a Mon Sep 17 00:00:00 2001 From: Alok Saboo Date: Mon, 4 Mar 2019 14:06:28 -0600 Subject: [PATCH 082/291] Add camera name to logs (#21653) --- homeassistant/components/camera/generic.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/camera/generic.py b/homeassistant/components/camera/generic.py index f89e5ff29c2..ae7e849c234 100644 --- a/homeassistant/components/camera/generic.py +++ b/homeassistant/components/camera/generic.py @@ -128,7 +128,7 @@ class GenericCamera(Camera): url, auth=self._auth) self._last_image = await response.read() except asyncio.TimeoutError: - _LOGGER.error("Timeout getting camera image") + _LOGGER.error("Timeout getting image from: %s", self._name) return self._last_image except aiohttp.ClientError as err: _LOGGER.error("Error getting new camera image: %s", err) From 27e8a6ee80c33b446e5b6ee929982eca074cd5f9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 4 Mar 2019 13:18:16 -0800 Subject: [PATCH 083/291] Rename Google Assistant evenets (#21655) --- homeassistant/components/google_assistant/const.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/google_assistant/const.py b/homeassistant/components/google_assistant/const.py index b7d3a398ef2..220ed6dd58c 100644 --- a/homeassistant/components/google_assistant/const.py +++ b/homeassistant/components/google_assistant/const.py @@ -44,6 +44,6 @@ ERR_UNKNOWN_ERROR = 'unknownError' ERR_FUNCTION_NOT_SUPPORTED = 'functionNotSupported' # Event types -EVENT_COMMAND_RECEIVED = 'google_assistant_command_received' -EVENT_QUERY_RECEIVED = 'google_assistant_query_received' -EVENT_SYNC_RECEIVED = 'google_assistant_sync_received' +EVENT_COMMAND_RECEIVED = 'google_assistant_command' +EVENT_QUERY_RECEIVED = 'google_assistant_query' +EVENT_SYNC_RECEIVED = 'google_assistant_sync' From 641138a986f0f3a61ce75e8939281764f63e3b68 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 5 Mar 2019 00:51:15 +0100 Subject: [PATCH 084/291] Upgrade toonapilib to 3.1.0 (#21661) --- homeassistant/components/toon/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/toon/__init__.py b/homeassistant/components/toon/__init__.py index fce0bc4ed2a..aea9415151c 100644 --- a/homeassistant/components/toon/__init__.py +++ b/homeassistant/components/toon/__init__.py @@ -15,7 +15,7 @@ from .const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DISPLAY, CONF_TENANT, DATA_TOON_CLIENT, DATA_TOON_CONFIG, DOMAIN) -REQUIREMENTS = ['toonapilib==3.0.9'] +REQUIREMENTS = ['toonapilib==3.1.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 691ad6d1539..8166a1e9699 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1689,7 +1689,7 @@ tikteck==0.4 todoist-python==7.0.17 # homeassistant.components.toon -toonapilib==3.0.9 +toonapilib==3.1.0 # homeassistant.components.alarm_control_panel.totalconnect total_connect_client==0.22 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 873845c0df5..30a303ac871 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -295,7 +295,7 @@ srpenergy==1.0.5 statsd==3.2.1 # homeassistant.components.toon -toonapilib==3.0.9 +toonapilib==3.1.0 # homeassistant.components.camera.uvc uvcclient==0.11.0 From 73b100d3af19deca9178bf4cbd3985805172a3a5 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 5 Mar 2019 00:52:00 +0100 Subject: [PATCH 085/291] :ambulance: Fixes Toon doing I/O in coroutines (#21657) --- homeassistant/components/toon/__init__.py | 10 ++++++---- .../components/toon/binary_sensor.py | 2 +- homeassistant/components/toon/climate.py | 2 +- homeassistant/components/toon/config_flow.py | 20 +++++++++---------- homeassistant/components/toon/sensor.py | 2 +- 5 files changed, 18 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/toon/__init__.py b/homeassistant/components/toon/__init__.py index aea9415151c..12cae9ac801 100644 --- a/homeassistant/components/toon/__init__.py +++ b/homeassistant/components/toon/__init__.py @@ -1,6 +1,7 @@ """Support for Toon van Eneco devices.""" import logging from typing import Any, Dict +from functools import partial import voluptuous as vol @@ -48,10 +49,11 @@ async def async_setup_entry(hass: HomeAssistantType, conf = hass.data.get(DATA_TOON_CONFIG) - toon = Toon(entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], - conf[CONF_CLIENT_ID], conf[CONF_CLIENT_SECRET], - tenant_id=entry.data[CONF_TENANT], - display_common_name=entry.data[CONF_DISPLAY]) + toon = await hass.async_add_executor_job(partial( + Toon, entry.data[CONF_USERNAME], entry.data[CONF_PASSWORD], + conf[CONF_CLIENT_ID], conf[CONF_CLIENT_SECRET], + tenant_id=entry.data[CONF_TENANT], + display_common_name=entry.data[CONF_DISPLAY])) hass.data.setdefault(DATA_TOON_CLIENT, {})[entry.entry_id] = toon diff --git a/homeassistant/components/toon/binary_sensor.py b/homeassistant/components/toon/binary_sensor.py index 891a72daeed..a50a67085ec 100644 --- a/homeassistant/components/toon/binary_sensor.py +++ b/homeassistant/components/toon/binary_sensor.py @@ -102,7 +102,7 @@ class ToonBinarySensor(ToonEntity, BinarySensorDevice): return value - async def async_update(self) -> None: + def update(self) -> None: """Get the latest data from the binary sensor.""" section = getattr(self.toon, self.section) self._state = getattr(section, self.measurement) diff --git a/homeassistant/components/toon/climate.py b/homeassistant/components/toon/climate.py index 2e564b8457a..13f1c1269a1 100644 --- a/homeassistant/components/toon/climate.py +++ b/homeassistant/components/toon/climate.py @@ -117,7 +117,7 @@ class ToonThermostatDevice(ToonDisplayDeviceEntity, ClimateDevice): """Set new operation mode.""" self.toon.thermostat_state = HA_TOON[operation_mode] - async def async_update(self) -> None: + def update(self) -> None: """Update local state.""" if self.toon.thermostat_state is None: self._state = None diff --git a/homeassistant/components/toon/config_flow.py b/homeassistant/components/toon/config_flow.py index cdb8a0f2257..a09b3dd49a7 100644 --- a/homeassistant/components/toon/config_flow.py +++ b/homeassistant/components/toon/config_flow.py @@ -1,6 +1,7 @@ """Config flow to configure the Toon component.""" from collections import OrderedDict import logging +from functools import partial import voluptuous as vol @@ -75,11 +76,10 @@ class ToonFlowHandler(config_entries.ConfigFlow): app = self.hass.data.get(DATA_TOON_CONFIG, {}) try: - toon = Toon(user_input[CONF_USERNAME], - user_input[CONF_PASSWORD], - app[CONF_CLIENT_ID], - app[CONF_CLIENT_SECRET], - tenant_id=user_input[CONF_TENANT]) + toon = await self.hass.async_add_executor_job(partial( + Toon, user_input[CONF_USERNAME], user_input[CONF_PASSWORD], + app[CONF_CLIENT_ID], app[CONF_CLIENT_SECRET], + tenant_id=user_input[CONF_TENANT])) displays = toon.display_names @@ -136,12 +136,10 @@ class ToonFlowHandler(config_entries.ConfigFlow): app = self.hass.data.get(DATA_TOON_CONFIG, {}) try: - Toon(self.username, - self.password, - app[CONF_CLIENT_ID], - app[CONF_CLIENT_SECRET], - tenant_id=self.tenant, - display_common_name=user_input[CONF_DISPLAY]) + await self.hass.async_add_executor_job(partial( + Toon, self.username, self.password, app[CONF_CLIENT_ID], + app[CONF_CLIENT_SECRET], tenant_id=self.tenant, + display_common_name=user_input[CONF_DISPLAY])) except Exception: # pylint: disable=broad-except _LOGGER.exception("Unexpected error while authenticating") diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py index 2a5921b78eb..e263bda9fc7 100644 --- a/homeassistant/components/toon/sensor.py +++ b/homeassistant/components/toon/sensor.py @@ -134,7 +134,7 @@ class ToonSensor(ToonEntity): """Return the unit this state is expressed in.""" return self._unit_of_measurement - async def async_update(self) -> None: + def update(self) -> None: """Get the latest data from the sensor.""" section = getattr(self.toon, self.section) value = None From 955b71c44bdc11e8fd93141739e6917de29aae83 Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 5 Mar 2019 00:53:16 +0100 Subject: [PATCH 086/291] :shirt: Corrects unit of measurement symbol for Watt (#21654) --- homeassistant/components/toon/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/toon/const.py b/homeassistant/components/toon/const.py index 762374eb41c..29b58fbfff9 100644 --- a/homeassistant/components/toon/const.py +++ b/homeassistant/components/toon/const.py @@ -14,7 +14,7 @@ DEFAULT_MAX_TEMP = 30.0 DEFAULT_MIN_TEMP = 6.0 CURRENCY_EUR = 'EUR' -POWER_WATT = 'Watt' +POWER_WATT = 'W' POWER_KWH = 'kWh' RATIO_PERCENT = '%' VOLUME_CM3 = 'CM3' From 7a7080055e3cbfe213e7868200e8ca2c66725328 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Daniel=20H=C3=B8yer=20Iversen?= Date: Tue, 5 Mar 2019 00:54:21 +0100 Subject: [PATCH 087/291] Netatmo, handle missing thermostat devices (#21651) --- homeassistant/components/netatmo/climate.py | 30 +++++++++++---------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 8ccfe9be709..409358c2f04 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -1,18 +1,18 @@ """Support for Netatmo Smart thermostats.""" import logging from datetime import timedelta + import voluptuous as vol -from homeassistant.const import ( - STATE_OFF, TEMP_CELSIUS, ATTR_TEMPERATURE, CONF_NAME) +import homeassistant.helpers.config_validation as cv from homeassistant.components.climate import ClimateDevice, PLATFORM_SCHEMA from homeassistant.components.climate.const import ( STATE_HEAT, SUPPORT_ON_OFF, SUPPORT_TARGET_TEMPERATURE, SUPPORT_OPERATION_MODE, SUPPORT_AWAY_MODE, STATE_MANUAL, STATE_AUTO, STATE_ECO, STATE_COOL) +from homeassistant.const import ( + STATE_OFF, TEMP_CELSIUS, ATTR_TEMPERATURE, CONF_NAME) from homeassistant.util import Throttle -import homeassistant.helpers.config_validation as cv - DEPENDENCIES = ['netatmo'] @@ -86,13 +86,13 @@ def setup_platform(hass, config, add_entities, discovery_info=None): else: homes = home_data.get_home_names() + devices = [] for home in homes: _LOGGER.debug("Setting up %s ...", home) try: room_data = ThermostatData(netatmo.NETATMO_AUTH, home) except pyatmo.NoDevice: continue - devices = [] for room_id in room_data.get_room_ids(): room_name = room_data.homedata.rooms[home][room_id]['name'] _LOGGER.debug("Setting up %s (%s) ...", room_name, room_id) @@ -102,7 +102,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.debug("Adding devices for room %s (%s) ...", room_name, room_id) devices.append(NetatmoThermostat(room_data, room_id)) - add_entities(devices, True) + add_entities(devices, True) class NetatmoThermostat(ClimateDevice): @@ -142,7 +142,7 @@ class NetatmoThermostat(ClimateDevice): @property def name(self): - """Return the name of the sensor.""" + """Return the name of the thermostat.""" return self._name @property @@ -299,6 +299,8 @@ class HomeData: def get_home_names(self): """Get all the home names returned by NetAtmo API.""" self.setup() + if self.homedata is None: + return [] for home in self.homedata.homes: if 'therm_schedules' in self.homedata.homes[home] and 'modules' \ in self.homedata.homes[home]: @@ -312,9 +314,9 @@ class HomeData: self.homedata = pyatmo.HomeData(self.auth) self.home_id = self.homedata.gethomeId(self.home) except TypeError: - _LOGGER.error("Error when getting homedata.") + _LOGGER.error("Error when getting home data.") except pyatmo.NoDevice: - _LOGGER.error("Error when getting homestatus response.") + _LOGGER.debug("No thermostat devices available.") class ThermostatData: @@ -337,11 +339,11 @@ class ThermostatData: def get_room_ids(self): """Return all module available on the API as a list.""" - if self.setup(): - for key in self.homestatus.rooms: - self.room_ids.append(key) - return self.room_ids - return [] + if not self.setup(): + return [] + for key in self.homestatus.rooms: + self.room_ids.append(key) + return self.room_ids def setup(self): """Retrieve HomeData and HomeStatus by NetAtmo API.""" From 4a3b4cf3460e539bd213d7b4593c51e4fffb67a1 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Mon, 4 Mar 2019 15:55:26 -0800 Subject: [PATCH 088/291] Resolve race condition when HA auth provider is loading (#21619) * Resolve race condition when HA auth provider is loading * Fix * Add more tests * Lint --- homeassistant/auth/mfa_modules/notify.py | 20 +++++++++----- homeassistant/auth/mfa_modules/totp.py | 14 +++++++--- homeassistant/auth/providers/homeassistant.py | 17 ++++++++---- tests/auth/mfa_modules/test_notify.py | 24 +++++++++++++++++ tests/auth/mfa_modules/test_totp.py | 24 +++++++++++++++++ tests/auth/providers/test_homeassistant.py | 27 +++++++++++++++++++ 6 files changed, 110 insertions(+), 16 deletions(-) diff --git a/homeassistant/auth/mfa_modules/notify.py b/homeassistant/auth/mfa_modules/notify.py index 3c26f8b4bde..310abff9484 100644 --- a/homeassistant/auth/mfa_modules/notify.py +++ b/homeassistant/auth/mfa_modules/notify.py @@ -2,6 +2,7 @@ Sending HOTP through notify service """ +import asyncio import logging from collections import OrderedDict from typing import Any, Dict, Optional, List @@ -90,6 +91,7 @@ class NotifyAuthModule(MultiFactorAuthModule): self._include = config.get(CONF_INCLUDE, []) self._exclude = config.get(CONF_EXCLUDE, []) self._message_template = config[CONF_MESSAGE] + self._init_lock = asyncio.Lock() @property def input_schema(self) -> vol.Schema: @@ -98,15 +100,19 @@ class NotifyAuthModule(MultiFactorAuthModule): async def _async_load(self) -> None: """Load stored data.""" - data = await self._user_store.async_load() + async with self._init_lock: + if self._user_settings is not None: + return - if data is None: - data = {STORAGE_USERS: {}} + data = await self._user_store.async_load() - self._user_settings = { - user_id: NotifySetting(**setting) - for user_id, setting in data.get(STORAGE_USERS, {}).items() - } + if data is None: + data = {STORAGE_USERS: {}} + + self._user_settings = { + user_id: NotifySetting(**setting) + for user_id, setting in data.get(STORAGE_USERS, {}).items() + } async def _async_save(self) -> None: """Save data.""" diff --git a/homeassistant/auth/mfa_modules/totp.py b/homeassistant/auth/mfa_modules/totp.py index 68f4e1d0596..dc51152f565 100644 --- a/homeassistant/auth/mfa_modules/totp.py +++ b/homeassistant/auth/mfa_modules/totp.py @@ -1,4 +1,5 @@ """Time-based One Time Password auth module.""" +import asyncio import logging from io import BytesIO from typing import Any, Dict, Optional, Tuple # noqa: F401 @@ -68,6 +69,7 @@ class TotpAuthModule(MultiFactorAuthModule): self._users = None # type: Optional[Dict[str, str]] self._user_store = hass.helpers.storage.Store( STORAGE_VERSION, STORAGE_KEY, private=True) + self._init_lock = asyncio.Lock() @property def input_schema(self) -> vol.Schema: @@ -76,12 +78,16 @@ class TotpAuthModule(MultiFactorAuthModule): async def _async_load(self) -> None: """Load stored data.""" - data = await self._user_store.async_load() + async with self._init_lock: + if self._users is not None: + return - if data is None: - data = {STORAGE_USERS: {}} + data = await self._user_store.async_load() - self._users = data.get(STORAGE_USERS, {}) + if data is None: + data = {STORAGE_USERS: {}} + + self._users = data.get(STORAGE_USERS, {}) async def _async_save(self) -> None: """Save data.""" diff --git a/homeassistant/auth/providers/homeassistant.py b/homeassistant/auth/providers/homeassistant.py index b22f93f11f1..2187d272800 100644 --- a/homeassistant/auth/providers/homeassistant.py +++ b/homeassistant/auth/providers/homeassistant.py @@ -1,4 +1,5 @@ """Home Assistant auth provider.""" +import asyncio import base64 from collections import OrderedDict import logging @@ -204,15 +205,21 @@ class HassAuthProvider(AuthProvider): DEFAULT_TITLE = 'Home Assistant Local' - data = None + def __init__(self, *args: Any, **kwargs: Any) -> None: + """Initialize an Home Assistant auth provider.""" + super().__init__(*args, **kwargs) + self.data = None # type: Optional[Data] + self._init_lock = asyncio.Lock() async def async_initialize(self) -> None: """Initialize the auth provider.""" - if self.data is not None: - return + async with self._init_lock: + if self.data is not None: + return - self.data = Data(self.hass) - await self.data.async_load() + data = Data(self.hass) + await data.async_load() + self.data = data async def async_login_flow( self, context: Optional[Dict]) -> LoginFlow: diff --git a/tests/auth/mfa_modules/test_notify.py b/tests/auth/mfa_modules/test_notify.py index 748b5507824..c0680024dae 100644 --- a/tests/auth/mfa_modules/test_notify.py +++ b/tests/auth/mfa_modules/test_notify.py @@ -1,4 +1,5 @@ """Test the HMAC-based One Time Password (MFA) auth module.""" +import asyncio from unittest.mock import patch from homeassistant import data_entry_flow @@ -395,3 +396,26 @@ async def test_not_raise_exception_when_service_not_exist(hass): # wait service call finished await hass.async_block_till_done() + + +async def test_race_condition_in_data_loading(hass): + """Test race condition in the data loading.""" + counter = 0 + + async def mock_load(_): + """Mock homeassistant.helpers.storage.Store.async_load.""" + nonlocal counter + counter += 1 + await asyncio.sleep(0) + + notify_auth_module = await auth_mfa_module_from_config(hass, { + 'type': 'notify' + }) + with patch('homeassistant.helpers.storage.Store.async_load', + new=mock_load): + task1 = notify_auth_module.async_validate('user', {'code': 'value'}) + task2 = notify_auth_module.async_validate('user', {'code': 'value'}) + results = await asyncio.gather(task1, task2, return_exceptions=True) + assert counter == 1 + assert results[0] is False + assert results[1] is False diff --git a/tests/auth/mfa_modules/test_totp.py b/tests/auth/mfa_modules/test_totp.py index d400fe80672..35ab21ae6de 100644 --- a/tests/auth/mfa_modules/test_totp.py +++ b/tests/auth/mfa_modules/test_totp.py @@ -1,4 +1,5 @@ """Test the Time-based One Time Password (MFA) auth module.""" +import asyncio from unittest.mock import patch from homeassistant import data_entry_flow @@ -128,3 +129,26 @@ async def test_login_flow_validates_mfa(hass): result['flow_id'], {'code': MOCK_CODE}) assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY assert result['data'].id == 'mock-user' + + +async def test_race_condition_in_data_loading(hass): + """Test race condition in the data loading.""" + counter = 0 + + async def mock_load(_): + """Mock of homeassistant.helpers.storage.Store.async_load.""" + nonlocal counter + counter += 1 + await asyncio.sleep(0) + + totp_auth_module = await auth_mfa_module_from_config(hass, { + 'type': 'totp' + }) + with patch('homeassistant.helpers.storage.Store.async_load', + new=mock_load): + task1 = totp_auth_module.async_validate('user', {'code': 'value'}) + task2 = totp_auth_module.async_validate('user', {'code': 'value'}) + results = await asyncio.gather(task1, task2, return_exceptions=True) + assert counter == 1 + assert results[0] is False + assert results[1] is False diff --git a/tests/auth/providers/test_homeassistant.py b/tests/auth/providers/test_homeassistant.py index ffc4d67f21d..c466a1fa42b 100644 --- a/tests/auth/providers/test_homeassistant.py +++ b/tests/auth/providers/test_homeassistant.py @@ -1,4 +1,5 @@ """Test the Home Assistant local auth provider.""" +import asyncio from unittest.mock import Mock, patch import pytest @@ -288,3 +289,29 @@ async def test_legacy_get_or_create_credentials(hass, legacy_data): 'username': 'hello ' }) assert credentials1 is not credentials3 + + +async def test_race_condition_in_data_loading(hass): + """Test race condition in the hass_auth.Data loading. + + Ref issue: https://github.com/home-assistant/home-assistant/issues/21569 + """ + counter = 0 + + async def mock_load(_): + """Mock of homeassistant.helpers.storage.Store.async_load.""" + nonlocal counter + counter += 1 + await asyncio.sleep(0) + + provider = hass_auth.HassAuthProvider(hass, auth_store.AuthStore(hass), + {'type': 'homeassistant'}) + with patch('homeassistant.helpers.storage.Store.async_load', + new=mock_load): + task1 = provider.async_validate_login('user', 'pass') + task2 = provider.async_validate_login('user', 'pass') + results = await asyncio.gather(task1, task2, return_exceptions=True) + assert counter == 1 + assert isinstance(results[0], hass_auth.InvalidAuth) + # results[1] will be a TypeError if race condition occurred + assert isinstance(results[1], hass_auth.InvalidAuth) From e10e27d809c90658cab5f019c981929ea6553958 Mon Sep 17 00:00:00 2001 From: roblandry Date: Mon, 4 Mar 2019 18:56:05 -0500 Subject: [PATCH 089/291] Add SmartThingsAccelCluster to ZHA binary_sensor (#21609) * Add SmartThingsAccelCluster to binary_sensor * Make corrections per discussion with @dmulcahey * Add missing const to gateway.py * Remove Acceleration from no sensor --- homeassistant/components/zha/binary_sensor.py | 3 ++- homeassistant/components/zha/core/const.py | 1 + homeassistant/components/zha/core/gateway.py | 7 ++++--- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index a46ffdd305d..30f730f3de4 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -12,7 +12,7 @@ from .core.const import ( DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, ON_OFF_CHANNEL, LEVEL_CHANNEL, ZONE_CHANNEL, SIGNAL_ATTR_UPDATED, SIGNAL_MOVE_LEVEL, SIGNAL_SET_LEVEL, ATTRIBUTE_CHANNEL, UNKNOWN, OPENING, ZONE, OCCUPANCY, - ATTR_LEVEL, SENSOR_TYPE) + ATTR_LEVEL, SENSOR_TYPE, ACCELERATION) from .entity import ZhaEntity _LOGGER = logging.getLogger(__name__) @@ -41,6 +41,7 @@ DEVICE_CLASS_REGISTRY = { OPENING: OPENING, ZONE: get_ias_device_class, OCCUPANCY: OCCUPANCY, + ACCELERATION: 'moving', } diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index ecaa1c9bd20..33376b056c6 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -68,6 +68,7 @@ UNKNOWN = 'unknown' OPENING = 'opening' ZONE = 'zone' OCCUPANCY = 'occupancy' +ACCELERATION = 'acceleration' ATTR_LEVEL = 'level' diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index dcaf0d4a3ba..42548d6bd1b 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -23,7 +23,7 @@ from .const import ( REPORT_CONFIG_ASAP, REPORT_CONFIG_DEFAULT, REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, REPORT_CONFIG_OP, SIGNAL_REMOVE, NO_SENSOR_CLUSTERS, POWER_CONFIGURATION_CHANNEL, BINDABLE_CLUSTERS, - DATA_ZHA_GATEWAY) + DATA_ZHA_GATEWAY, ACCELERATION) from .device import ZHADevice, DeviceStatus from ..device_entity import ZhaDeviceEntity from .channels import ( @@ -452,7 +452,6 @@ def establish_device_mappings(): NO_SENSOR_CLUSTERS.append( zcl.clusters.general.PowerConfiguration.cluster_id) NO_SENSOR_CLUSTERS.append(zcl.clusters.lightlink.LightLink.cluster_id) - NO_SENSOR_CLUSTERS.append(SMARTTHINGS_ACCELERATION_CLUSTER) BINDABLE_CLUSTERS.append(zcl.clusters.general.LevelControl.cluster_id) BINDABLE_CLUSTERS.append(zcl.clusters.general.OnOff.cluster_id) @@ -501,6 +500,7 @@ def establish_device_mappings(): zcl.clusters.security.IasZone: 'binary_sensor', zcl.clusters.measurement.OccupancySensing: 'binary_sensor', zcl.clusters.hvac.Fan: 'fan', + SMARTTHINGS_ACCELERATION_CLUSTER: 'binary_sensor', }) SINGLE_OUTPUT_CLUSTER_DEVICE_CLASS.update({ @@ -523,7 +523,8 @@ def establish_device_mappings(): BINARY_SENSOR_TYPES.update({ zcl.clusters.measurement.OccupancySensing.cluster_id: OCCUPANCY, zcl.clusters.security.IasZone.cluster_id: ZONE, - zcl.clusters.general.OnOff.cluster_id: OPENING + zcl.clusters.general.OnOff.cluster_id: OPENING, + SMARTTHINGS_ACCELERATION_CLUSTER: ACCELERATION, }) CLUSTER_REPORT_CONFIGS.update({ From df25128923fd88b551814e420c434246f0f29513 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 4 Mar 2019 16:01:31 -0800 Subject: [PATCH 090/291] Avoid recorder thread crashing (#21668) --- homeassistant/components/recorder/__init__.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/recorder/__init__.py b/homeassistant/components/recorder/__init__.py index e0af36ea409..6c338457b34 100644 --- a/homeassistant/components/recorder/__init__.py +++ b/homeassistant/components/recorder/__init__.py @@ -318,6 +318,10 @@ class Recorder(threading.Thread): CONNECT_RETRY_WAIT) tries += 1 + except exc.SQLAlchemyError: + updated = True + _LOGGER.exception("Error saving event: %s", event) + if not updated: _LOGGER.error("Error in database update. Could not save " "after %d tries. Giving up", tries) From efa5d5dfe3d3002ad07d0dd283e051345c852b82 Mon Sep 17 00:00:00 2001 From: ktnrg45 <38207570+ktnrg45@users.noreply.github.com> Date: Mon, 4 Mar 2019 17:48:25 -0700 Subject: [PATCH 091/291] Add support for multiple devices for PS4 component (#21302) * Support multiple devices. * Revert "Support multiple devices." This reverts commit 3f5d4462a98da13ebb1ab1c07d341dbd7020e6cc. * Support multiple devices * Bump to 0.3.3 * bump 0.3.4 * Add tests for multiple devices. * Update Requirements * Update config_flow.py * Update config_flow.py * fixed typo * Reordered functions * Added multiple flow implementation test. * fix * typo * fix tests * bump 0.4.0 * Bump 0.4.0 * 0.4.0 * bump version * bump version * bump version * Add keep alive feature with multiple devices * bump version * bump version * bump version * bump 0.4.7 * bump 0.4.7 * bump 0.4.7 * Edited tests. * bump/pylint * pylint * bump/pylint * bump/pylint * Change to add additional entry * Changed to multiple entries * pylint * Corrections to manage multiple devices. * lint * comments * Removed redundant for loop * Shorthand correction * Remove reference to private object * Test fix * Revert changes. Test failure. * Test fix * test fix * unindent assertions * pylint --- homeassistant/components/ps4/__init__.py | 2 +- homeassistant/components/ps4/config_flow.py | 19 +- homeassistant/components/ps4/media_player.py | 26 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/ps4/test_config_flow.py | 197 ++++++++++++++++--- 6 files changed, 214 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/ps4/__init__.py b/homeassistant/components/ps4/__init__.py index 51260f5d86e..087d3f89f80 100644 --- a/homeassistant/components/ps4/__init__.py +++ b/homeassistant/components/ps4/__init__.py @@ -11,7 +11,7 @@ from homeassistant.components.ps4.const import DOMAIN # noqa: pylint: disable=u _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pyps4-homeassistant==0.3.0'] +REQUIREMENTS = ['pyps4-homeassistant==0.4.8'] async def async_setup(hass, config): diff --git a/homeassistant/components/ps4/config_flow.py b/homeassistant/components/ps4/config_flow.py index 3557c3fd930..d000ed1f7e7 100644 --- a/homeassistant/components/ps4/config_flow.py +++ b/homeassistant/components/ps4/config_flow.py @@ -37,10 +37,6 @@ class PlayStation4FlowHandler(config_entries.ConfigFlow): async def async_step_user(self, user_input=None): """Handle a user config flow.""" - # Abort if device is configured. - if self.hass.config_entries.async_entries(DOMAIN): - return self.async_abort(reason='devices_configured') - # Check if able to bind to ports: UDP 987, TCP 997. ports = PORT_MSG.keys() failed = await self.hass.async_add_executor_job( @@ -48,6 +44,9 @@ class PlayStation4FlowHandler(config_entries.ConfigFlow): if failed in ports: reason = PORT_MSG[failed] return self.async_abort(reason=reason) + # Skip Creds Step if a device is configured. + if self.hass.config_entries.async_entries(DOMAIN): + return await self.async_step_link() return await self.async_step_creds() async def async_step_creds(self, user_input=None): @@ -78,6 +77,18 @@ class PlayStation4FlowHandler(config_entries.ConfigFlow): device_list = [ device['host-ip'] for device in devices] + # If entry exists check that devices found aren't configured. + if self.hass.config_entries.async_entries(DOMAIN): + for entry in self.hass.config_entries.async_entries(DOMAIN): + conf_devices = entry.data['devices'] + for c_device in conf_devices: + if c_device['host'] in device_list: + # Remove configured device from search list. + device_list.remove(c_device['host']) + # If list is empty then all devices are configured. + if not device_list: + return self.async_abort(reason='devices_configured') + # Login to PS4 with user data. if user_input is not None: self.region = user_input[CONF_REGION] diff --git a/homeassistant/components/ps4/media_player.py b/homeassistant/components/ps4/media_player.py index bf7be1bbf91..74dce515d9d 100644 --- a/homeassistant/components/ps4/media_player.py +++ b/homeassistant/components/ps4/media_player.py @@ -133,6 +133,7 @@ class PS4Device(MediaPlayerDevice): self._retry = 0 self._info = None self._unique_id = None + self._power_on = False async def async_added_to_hass(self): """Subscribe PS4 events.""" @@ -144,6 +145,7 @@ class PS4Device(MediaPlayerDevice): try: status = self._ps4.get_status() if self._info is None: + # Add entity to registry self.get_device_info(status) self._games = self.load_games() if self._games is not None: @@ -153,6 +155,17 @@ class PS4Device(MediaPlayerDevice): if status is not None: self._retry = 0 if status.get('status') == 'Ok': + # Check if only 1 device in Hass. + if len(self.hass.data[PS4_DATA].devices) == 1: + # Enable keep alive feature for PS4 Connection. + # Only 1 device is supported, Since have to use port 997. + self._ps4.keep_alive = True + else: + self._ps4.keep_alive = False + if self._power_on: + # Auto Login after Turn On. + self._ps4.open() + self._power_on = False title_id = status.get('running-app-titleid') name = status.get('running-app-name') if title_id and name is not None: @@ -268,6 +281,10 @@ class PS4Device(MediaPlayerDevice): } self._unique_id = status['host-id'] + async def async_will_remove_from_hass(self): + """Remove Entity from Hass.""" + self.hass.data[PS4_DATA].devices.remove(self) + @property def device_info(self): """Return information about the device.""" @@ -346,15 +363,16 @@ class PS4Device(MediaPlayerDevice): def turn_on(self): """Turn on the media player.""" + self._power_on = True self._ps4.wakeup() def media_pause(self): """Send keypress ps to return to menu.""" - self._ps4.remote_control('ps') + self.send_remote_control('ps') def media_stop(self): """Send keypress ps to return to menu.""" - self._ps4.remote_control('ps') + self.send_remote_control('ps') def select_source(self, source): """Select input source.""" @@ -369,4 +387,8 @@ class PS4Device(MediaPlayerDevice): def send_command(self, command): """Send Button Command.""" + self.send_remote_control(command) + + def send_remote_control(self, command): + """Send RC command.""" self._ps4.remote_control(command) diff --git a/requirements_all.txt b/requirements_all.txt index 8166a1e9699..ad42b78eb78 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1214,7 +1214,7 @@ pypoint==1.1.1 pypollencom==2.2.3 # homeassistant.components.ps4 -pyps4-homeassistant==0.3.0 +pyps4-homeassistant==0.4.8 # homeassistant.components.qwikswitch pyqwikswitch==0.8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 30a303ac871..bb36f50444e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -218,7 +218,7 @@ pyopenuv==1.0.9 pyotp==2.2.6 # homeassistant.components.ps4 -pyps4-homeassistant==0.3.0 +pyps4-homeassistant==0.4.8 # homeassistant.components.qwikswitch pyqwikswitch==0.8 diff --git a/tests/components/ps4/test_config_flow.py b/tests/components/ps4/test_config_flow.py index b0170beeb48..271db46d856 100644 --- a/tests/components/ps4/test_config_flow.py +++ b/tests/components/ps4/test_config_flow.py @@ -14,20 +14,32 @@ MOCK_TITLE = 'PlayStation 4' MOCK_CODE = '12345678' MOCK_CREDS = '000aa000' MOCK_HOST = '192.0.0.0' +MOCK_HOST_ADDITIONAL = '192.0.0.1' MOCK_DEVICE = { CONF_HOST: MOCK_HOST, CONF_NAME: DEFAULT_NAME, CONF_REGION: DEFAULT_REGION } +MOCK_DEVICE_ADDITIONAL = { + CONF_HOST: MOCK_HOST_ADDITIONAL, + CONF_NAME: DEFAULT_NAME, + CONF_REGION: DEFAULT_REGION +} MOCK_CONFIG = { CONF_IP_ADDRESS: MOCK_HOST, CONF_NAME: DEFAULT_NAME, CONF_REGION: DEFAULT_REGION, CONF_CODE: MOCK_CODE } +MOCK_CONFIG_ADDITIONAL = { + CONF_IP_ADDRESS: MOCK_HOST_ADDITIONAL, + CONF_NAME: DEFAULT_NAME, + CONF_REGION: DEFAULT_REGION, + CONF_CODE: MOCK_CODE +} MOCK_DATA = { CONF_TOKEN: MOCK_CREDS, - 'devices': MOCK_DEVICE + 'devices': [MOCK_DEVICE] } MOCK_UDP_PORT = int(987) MOCK_TCP_PORT = int(997) @@ -37,13 +49,14 @@ async def test_full_flow_implementation(hass): """Test registering an implementation and flow works.""" flow = ps4.PlayStation4FlowHandler() flow.hass = hass + manager = hass.config_entries # User Step Started, results in Step Creds with patch('pyps4_homeassistant.Helper.port_bind', return_value=None): result = await flow.async_step_user() - assert result['type'] == data_entry_flow.RESULT_TYPE_FORM - assert result['step_id'] == 'creds' + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'creds' # Step Creds results with form in Step Link. with patch('pyps4_homeassistant.Helper.get_creds', @@ -51,8 +64,8 @@ async def test_full_flow_implementation(hass): patch('pyps4_homeassistant.Helper.has_devices', return_value=[{'host-ip': MOCK_HOST}]): result = await flow.async_step_creds({}) - assert result['type'] == data_entry_flow.RESULT_TYPE_FORM - assert result['step_id'] == 'link' + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'link' # User Input results in created entry. with patch('pyps4_homeassistant.Helper.link', @@ -60,10 +73,110 @@ async def test_full_flow_implementation(hass): patch('pyps4_homeassistant.Helper.has_devices', return_value=[{'host-ip': MOCK_HOST}]): result = await flow.async_step_link(MOCK_CONFIG) - assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY - assert result['data'][CONF_TOKEN] == MOCK_CREDS - assert result['data']['devices'] == [MOCK_DEVICE] - assert result['title'] == MOCK_TITLE + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['data'][CONF_TOKEN] == MOCK_CREDS + assert result['data']['devices'] == [MOCK_DEVICE] + assert result['title'] == MOCK_TITLE + + # Add entry using result data. + mock_data = { + CONF_TOKEN: result['data'][CONF_TOKEN], + 'devices': result['data']['devices']} + entry = MockConfigEntry(domain=ps4.DOMAIN, data=mock_data) + entry.add_to_manager(manager) + + # Check if entry exists. + assert len(manager.async_entries()) == 1 + # Check if there is a device config in entry. + assert len(entry.data['devices']) == 1 + + +async def test_multiple_flow_implementation(hass): + """Test multiple device flows.""" + flow = ps4.PlayStation4FlowHandler() + flow.hass = hass + manager = hass.config_entries + + # User Step Started, results in Step Creds + with patch('pyps4_homeassistant.Helper.port_bind', + return_value=None): + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'creds' + + # Step Creds results with form in Step Link. + with patch('pyps4_homeassistant.Helper.get_creds', + return_value=MOCK_CREDS), \ + patch('pyps4_homeassistant.Helper.has_devices', + return_value=[{'host-ip': MOCK_HOST}, + {'host-ip': MOCK_HOST_ADDITIONAL}]): + result = await flow.async_step_creds({}) + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'link' + + # User Input results in created entry. + with patch('pyps4_homeassistant.Helper.link', + return_value=(True, True)), \ + patch('pyps4_homeassistant.Helper.has_devices', + return_value=[{'host-ip': MOCK_HOST}, + {'host-ip': MOCK_HOST_ADDITIONAL}]): + result = await flow.async_step_link(MOCK_CONFIG) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['data'][CONF_TOKEN] == MOCK_CREDS + assert result['data']['devices'] == [MOCK_DEVICE] + assert result['title'] == MOCK_TITLE + + await hass.async_block_till_done() + + # Add entry using result data. + mock_data = { + CONF_TOKEN: result['data'][CONF_TOKEN], + 'devices': result['data']['devices']} + entry = MockConfigEntry(domain=ps4.DOMAIN, data=mock_data) + entry.add_to_manager(manager) + + # Check if entry exists. + assert len(manager.async_entries()) == 1 + # Check if there is a device config in entry. + assert len(entry.data['devices']) == 1 + + # Test additional flow. + + # User Step Started, results in Step Link: + with patch('pyps4_homeassistant.Helper.port_bind', + return_value=None), \ + patch('pyps4_homeassistant.Helper.has_devices', + return_value=[{'host-ip': MOCK_HOST}, + {'host-ip': MOCK_HOST_ADDITIONAL}]): + result = await flow.async_step_user() + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'link' + + # Step Link + with patch('pyps4_homeassistant.Helper.has_devices', + return_value=[{'host-ip': MOCK_HOST}, + {'host-ip': MOCK_HOST_ADDITIONAL}]), \ + patch('pyps4_homeassistant.Helper.link', + return_value=(True, True)): + result = await flow.async_step_link(user_input=MOCK_CONFIG_ADDITIONAL) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['data'][CONF_TOKEN] == MOCK_CREDS + assert len(result['data']['devices']) == 1 + assert result['title'] == MOCK_TITLE + + mock_data = { + CONF_TOKEN: result['data'][CONF_TOKEN], + 'devices': result['data']['devices']} + + # Update config entries with result data + entry = MockConfigEntry(domain=ps4.DOMAIN, data=mock_data) + entry.add_to_manager(manager) + manager.async_update_entry(entry) + + # Check if there are 2 entries. + assert len(manager.async_entries()) == 2 + # Check if there is device config in entry. + assert len(entry.data['devices']) == 1 async def test_port_bind_abort(hass): @@ -75,28 +188,62 @@ async def test_port_bind_abort(hass): return_value=MOCK_UDP_PORT): reason = 'port_987_bind_error' result = await flow.async_step_user(user_input=None) - assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT - assert result['reason'] == reason + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == reason with patch('pyps4_homeassistant.Helper.port_bind', return_value=MOCK_TCP_PORT): reason = 'port_997_bind_error' result = await flow.async_step_user(user_input=None) - assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT - assert result['reason'] == reason + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == reason async def test_duplicate_abort(hass): - """Test that Flow aborts when already configured.""" + """Test that Flow aborts when found devices already configured.""" MockConfigEntry(domain=ps4.DOMAIN, data=MOCK_DATA).add_to_hass(hass) flow = ps4.PlayStation4FlowHandler() flow.hass = hass - result = await flow.async_step_user(user_input=None) + with patch('pyps4_homeassistant.Helper.has_devices', + return_value=[{'host-ip': MOCK_HOST}]): + result = await flow.async_step_link(user_input=None) assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT assert result['reason'] == 'devices_configured' +async def test_additional_device(hass): + """Test that Flow can configure another device.""" + flow = ps4.PlayStation4FlowHandler() + flow.hass = hass + flow.creds = MOCK_CREDS + manager = hass.config_entries + + # Mock existing entry. + entry = MockConfigEntry(domain=ps4.DOMAIN, data=MOCK_DATA) + entry.add_to_manager(manager) + # Check that only 1 entry exists + assert len(manager.async_entries()) == 1 + + with patch('pyps4_homeassistant.Helper.has_devices', + return_value=[{'host-ip': MOCK_HOST}, + {'host-ip': MOCK_HOST_ADDITIONAL}]), \ + patch('pyps4_homeassistant.Helper.link', + return_value=(True, True)): + result = await flow.async_step_link(user_input=MOCK_CONFIG_ADDITIONAL) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['data'][CONF_TOKEN] == MOCK_CREDS + assert len(result['data']['devices']) == 1 + assert result['title'] == MOCK_TITLE + + # Add New Entry + entry = MockConfigEntry(domain=ps4.DOMAIN, data=MOCK_DATA) + entry.add_to_manager(manager) + + # Check that there are 2 entries + assert len(manager.async_entries()) == 2 + + async def test_no_devices_found_abort(hass): """Test that failure to find devices aborts flow.""" flow = ps4.PlayStation4FlowHandler() @@ -104,8 +251,8 @@ async def test_no_devices_found_abort(hass): with patch('pyps4_homeassistant.Helper.has_devices', return_value=None): result = await flow.async_step_link(MOCK_CONFIG) - assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT - assert result['reason'] == 'no_devices_found' + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'no_devices_found' async def test_credential_abort(hass): @@ -115,8 +262,8 @@ async def test_credential_abort(hass): with patch('pyps4_homeassistant.Helper.get_creds', return_value=None): result = await flow.async_step_creds({}) - assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT - assert result['reason'] == 'credential_error' + assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT + assert result['reason'] == 'credential_error' async def test_invalid_pin_error(hass): @@ -129,9 +276,9 @@ async def test_invalid_pin_error(hass): patch('pyps4_homeassistant.Helper.has_devices', return_value=[{'host-ip': MOCK_HOST}]): result = await flow.async_step_link(MOCK_CONFIG) - assert result['type'] == data_entry_flow.RESULT_TYPE_FORM - assert result['step_id'] == 'link' - assert result['errors'] == {'base': 'login_failed'} + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'link' + assert result['errors'] == {'base': 'login_failed'} async def test_device_connection_error(hass): @@ -144,6 +291,6 @@ async def test_device_connection_error(hass): patch('pyps4_homeassistant.Helper.has_devices', return_value=[{'host-ip': MOCK_HOST}]): result = await flow.async_step_link(MOCK_CONFIG) - assert result['type'] == data_entry_flow.RESULT_TYPE_FORM - assert result['step_id'] == 'link' - assert result['errors'] == {'base': 'not_ready'} + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'link' + assert result['errors'] == {'base': 'not_ready'} From efe4ce9a05ed5fd130249a56afbc7b51f4b9587d Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 5 Mar 2019 05:18:25 +0000 Subject: [PATCH 092/291] check we have a tb (#21670) --- homeassistant/components/system_log/__init__.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index 16786bdeba4..d6877c32f0d 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -91,15 +91,15 @@ class LogEntry: self.first_occured = self.timestamp = record.created self.level = record.levelname self.message = record.getMessage() + self.exception = '' + self.root_cause = None if record.exc_info: self.exception = ''.join( traceback.format_exception(*record.exc_info)) _, _, tb = record.exc_info # pylint: disable=invalid-name # Last line of traceback contains the root cause of the exception - self.root_cause = str(traceback.extract_tb(tb)[-1]) - else: - self.exception = '' - self.root_cause = None + if traceback.extract_tb(tb): + self.root_cause = str(traceback.extract_tb(tb)[-1]) self.source = source self.count = 1 From 0e780541953199ff2acf8af0b32d2e2a8cd22c4d Mon Sep 17 00:00:00 2001 From: Colby Rome Date: Tue, 5 Mar 2019 01:57:45 -0500 Subject: [PATCH 093/291] Xfinity Gateway device_tracker platform (#21026) * initial commit * updated .coveragerc, CODEOWNERS, generated requirements_all.txt * fixed lines exceeding 79 characters * pylint fixes * shorten docstring and simplify get_scanner * extract initialization into get_scanner * bump pypi version * name change --- .coveragerc | 1 + CODEOWNERS | 3 +- .../components/device_tracker/xfinity.py | 58 +++++++++++++++++++ requirements_all.txt | 3 + 4 files changed, 64 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/device_tracker/xfinity.py diff --git a/.coveragerc b/.coveragerc index ef64886f776..8ccf59dddc9 100644 --- a/.coveragerc +++ b/.coveragerc @@ -138,6 +138,7 @@ omit = homeassistant/components/device_tracker/trackr.py homeassistant/components/device_tracker/ubee.py homeassistant/components/device_tracker/ubus.py + homeassistant/components/device_tracker/xfinity.py homeassistant/components/digital_ocean/* homeassistant/components/dominos/* homeassistant/components/doorbird/* diff --git a/CODEOWNERS b/CODEOWNERS index d15fd85cb52..ed55211a9cf 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -63,12 +63,13 @@ homeassistant/components/cover/group.py @cdce8p homeassistant/components/cover/template.py @PhracturedBlue homeassistant/components/device_tracker/asuswrt.py @kennedyshead homeassistant/components/device_tracker/automatic.py @armills +homeassistant/components/device_tracker/bt_smarthub.py @jxwolstenholme homeassistant/components/device_tracker/huawei_router.py @abmantis homeassistant/components/device_tracker/quantum_gateway.py @cisasteelersfan homeassistant/components/device_tracker/tile.py @bachya homeassistant/components/device_tracker/traccar.py @ludeeus -homeassistant/components/device_tracker/bt_smarthub.py @jxwolstenholme homeassistant/components/device_tracker/synology_srm.py @aerialls +homeassistant/components/device_tracker/xfinity.py @cisasteelersfan homeassistant/components/light/lifx_legacy.py @amelchio homeassistant/components/light/yeelight.py @rytilahti homeassistant/components/light/yeelightsunflower.py @lindsaymarkward diff --git a/homeassistant/components/device_tracker/xfinity.py b/homeassistant/components/device_tracker/xfinity.py new file mode 100644 index 00000000000..04702355de7 --- /dev/null +++ b/homeassistant/components/device_tracker/xfinity.py @@ -0,0 +1,58 @@ +"""Support for device tracking via Xfinity Gateways.""" +import logging + +from requests.exceptions import RequestException +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 + +REQUIREMENTS = ['xfinity-gateway==0.0.4'] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_HOST = '10.0.0.1' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string +}) + + +def get_scanner(hass, config): + """Validate the configuration and return an Xfinity Gateway scanner.""" + from xfinity_gateway import XfinityGateway + + gateway = XfinityGateway(config[DOMAIN][CONF_HOST]) + scanner = None + try: + gateway.scan_devices() + scanner = XfinityDeviceScanner(gateway) + except (RequestException, ValueError): + _LOGGER.error("Error communicating with Xfinity Gateway. " + "Check host: %s", gateway.host) + + return scanner + + +class XfinityDeviceScanner(DeviceScanner): + """This class queries an Xfinity Gateway.""" + + def __init__(self, gateway): + """Initialize the scanner.""" + self.gateway = gateway + + def scan_devices(self): + """Scan for new devices and return a list of found MACs.""" + connected_devices = [] + try: + connected_devices = self.gateway.scan_devices() + except (RequestException, ValueError): + _LOGGER.error("Unable to scan devices. " + "Check connection to gateway") + return connected_devices + + def get_device_name(self, device): + """Return the name of the given device or None if we don't know.""" + return self.gateway.get_device_name(device) diff --git a/requirements_all.txt b/requirements_all.txt index ad42b78eb78..6ff44fe48b5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1778,6 +1778,9 @@ xbee-helper==0.0.7 # homeassistant.components.sensor.xbox_live xboxapi==0.1.1 +# homeassistant.components.device_tracker.xfinity +xfinity-gateway==0.0.4 + # homeassistant.components.knx xknx==0.9.4 From 17c3c1483386a3ee480d5160f778a4f66c4de2d0 Mon Sep 17 00:00:00 2001 From: carstenschroeder Date: Tue, 5 Mar 2019 11:07:40 +0100 Subject: [PATCH 094/291] Fix ADS race condition (#21677) --- homeassistant/components/ads/__init__.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py index 060e9b2b987..1b90e645af4 100644 --- a/homeassistant/components/ads/__init__.py +++ b/homeassistant/components/ads/__init__.py @@ -171,13 +171,12 @@ class AdsHub: hnotify, huser = self._client.add_device_notification( name, attr, self._device_notification_callback) hnotify = int(hnotify) + self._notification_items[hnotify] = NotificationItem( + hnotify, huser, name, plc_datatype, callback) _LOGGER.debug( "Added device notification %d for variable %s", hnotify, name) - self._notification_items[hnotify] = NotificationItem( - hnotify, huser, name, plc_datatype, callback) - def _device_notification_callback(self, notification, name): """Handle device notifications.""" contents = notification.contents @@ -187,9 +186,10 @@ class AdsHub: data = contents.data try: - notification_item = self._notification_items[hnotify] + with self._lock: + notification_item = self._notification_items[hnotify] except KeyError: - _LOGGER.debug("Unknown device notification handle: %d", hnotify) + _LOGGER.error("Unknown device notification handle: %d", hnotify) return # Parse data to desired datatype From 3ffff887d8c5dbc2abae00fcdfcff608f43a1838 Mon Sep 17 00:00:00 2001 From: Steven Looman Date: Tue, 5 Mar 2019 16:48:44 +0100 Subject: [PATCH 095/291] Adds option in UPnP component to override callback url (#21583) * Add option to override callback url * Upgrade to async_upnp_client==0.14.5 * Fix requirements_all.txt --- .../components/media_player/dlna_dmr.py | 30 ++++++++++++++----- homeassistant/components/upnp/__init__.py | 2 +- requirements_all.txt | 2 +- 3 files changed, 25 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/media_player/dlna_dmr.py b/homeassistant/components/media_player/dlna_dmr.py index 03015cd5c01..fbb0ee58c5a 100644 --- a/homeassistant/components/media_player/dlna_dmr.py +++ b/homeassistant/components/media_player/dlna_dmr.py @@ -9,6 +9,7 @@ from datetime import datetime from datetime import timedelta import functools import logging +from typing import Optional import aiohttp import voluptuous as vol @@ -22,13 +23,13 @@ from homeassistant.components.media_player.const import ( from homeassistant.const import ( CONF_NAME, CONF_URL, EVENT_HOMEASSISTANT_STOP, STATE_IDLE, STATE_OFF, STATE_ON, STATE_PAUSED, STATE_PLAYING) -from homeassistant.core import HomeAssistant from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.typing import HomeAssistantType import homeassistant.helpers.config_validation as cv from homeassistant.util import get_local_ip -REQUIREMENTS = ['async-upnp-client==0.14.4'] +REQUIREMENTS = ['async-upnp-client==0.14.5'] _LOGGER = logging.getLogger(__name__) @@ -39,12 +40,14 @@ DEFAULT_LISTEN_PORT = 8301 CONF_LISTEN_IP = 'listen_ip' CONF_LISTEN_PORT = 'listen_port' +CONF_CALLBACK_URL_OVERRIDE = 'callback_url_override' 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, + vol.Optional(CONF_CALLBACK_URL_OVERRIDE): cv.url, }) HOME_ASSISTANT_UPNP_CLASS_MAPPING = { @@ -82,7 +85,12 @@ def catch_request_errors(): return call_wrapper -async def async_start_event_handler(hass, server_host, server_port, requester): +async def async_start_event_handler( + hass: HomeAssistantType, + server_host: str, + server_port: int, + requester, + callback_url_override: Optional[str] = None): """Register notify view.""" hass_data = hass.data[DLNA_DMR_DATA] if 'event_handler' in hass_data: @@ -91,10 +99,14 @@ async def async_start_event_handler(hass, server_host, server_port, requester): # start event handler from async_upnp_client.aiohttp import AiohttpNotifyServer server = AiohttpNotifyServer( - requester, server_port, server_host, hass.loop) + requester, + listen_port=server_port, + listen_host=server_host, + loop=hass.loop, + callback_url=callback_url_override) await server.start_server() _LOGGER.info( - 'UPNP/DLNA event handler listening on: %s', server.callback_url) + 'UPNP/DLNA event handler listening, url: %s', server.callback_url) hass_data['notify_server'] = server hass_data['event_handler'] = server.event_handler @@ -109,7 +121,10 @@ async def async_start_event_handler(hass, server_host, server_port, requester): async def async_setup_platform( - hass: HomeAssistant, config, async_add_entities, discovery_info=None): + hass: HomeAssistantType, + config, + async_add_entities, + discovery_info=None): """Set up DLNA DMR platform.""" if config.get(CONF_URL) is not None: url = config[CONF_URL] @@ -135,8 +150,9 @@ async def async_setup_platform( if server_host is None: server_host = get_local_ip() server_port = config.get(CONF_LISTEN_PORT, DEFAULT_LISTEN_PORT) + callback_url_override = config.get(CONF_CALLBACK_URL_OVERRIDE) event_handler = await async_start_event_handler( - hass, server_host, server_port, requester) + hass, server_host, server_port, requester, callback_url_override) # create upnp device from async_upnp_client import UpnpFactory diff --git a/homeassistant/components/upnp/__init__.py b/homeassistant/components/upnp/__init__.py index efa3ee73af8..a9fb84f733e 100644 --- a/homeassistant/components/upnp/__init__.py +++ b/homeassistant/components/upnp/__init__.py @@ -23,7 +23,7 @@ from .const import DOMAIN from .const import LOGGER as _LOGGER from .device import Device -REQUIREMENTS = ['async-upnp-client==0.14.4'] +REQUIREMENTS = ['async-upnp-client==0.14.5'] NOTIFICATION_ID = 'upnp_notification' NOTIFICATION_TITLE = 'UPnP/IGD Setup' diff --git a/requirements_all.txt b/requirements_all.txt index 6ff44fe48b5..bf6834c18f4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -174,7 +174,7 @@ asterisk_mbox==0.5.0 # homeassistant.components.upnp # homeassistant.components.media_player.dlna_dmr -async-upnp-client==0.14.4 +async-upnp-client==0.14.5 # homeassistant.components.light.avion # avion==0.10 From 16d79b52c35a20f2dc97a387ab6282b5dcaf7d5a Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Tue, 5 Mar 2019 10:03:19 -0600 Subject: [PATCH 096/291] Serialize amcrest snapshot commands and bump PyPI package to 1.2.4 (#21664) * Serialize snapshot commands and bump amcrest package to 1.2.4 Attempting to send a snapshot command when a previous one hasn't finished will result in warnings and/or errors. This can happen when the camera picture is clicked on in the frontend, resulting in the thread that updates the thumbnail in the background every 10 seconds to sometimes collide with the thread that updates the large picture in the foreground quickly. An automation that calls the camera.snapshot service in yet another thread can make the situation worse. Fix by adding a thread lock to serialize snapshot commands. Also bump the amcrest package to 1.2.4 which fixes error handling in the command method and improves performance by reusing requests sessions. * Update amcrest package to 1.2.4 --- homeassistant/components/amcrest/__init__.py | 2 +- homeassistant/components/amcrest/camera.py | 16 +++++++++++++--- requirements_all.txt | 2 +- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index 49f11570b21..bcb18402900 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -13,7 +13,7 @@ from homeassistant.const import ( from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['amcrest==1.2.3'] +REQUIREMENTS = ['amcrest==1.2.4'] DEPENDENCIES = ['ffmpeg'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 7c943b89734..6acaa5fc86e 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -1,5 +1,9 @@ """Support for Amcrest IP cameras.""" import logging +import threading + +from requests import RequestException +from urllib3.exceptions import ReadTimeoutError from homeassistant.components.amcrest import ( DATA_AMCREST, STREAM_SOURCE_LIST, TIMEOUT) @@ -43,12 +47,18 @@ class AmcrestCam(Camera): self._stream_source = amcrest.stream_source self._resolution = amcrest.resolution self._token = self._auth = amcrest.authentication + self._snapshot_lock = threading.Lock() def camera_image(self): """Return a still image response from the camera.""" - # Send the request to snap a picture and return raw jpg data - response = self._camera.snapshot(channel=self._resolution) - return response.data + with self._snapshot_lock: + try: + # Send the request to snap a picture and return raw jpg data + return self._camera.snapshot(channel=self._resolution).data + except (RequestException, ReadTimeoutError, ValueError) as error: + _LOGGER.error( + 'Could not get camera image due to error %s', error) + return None async def handle_async_mjpeg_stream(self, request): """Return an MJPEG stream.""" diff --git a/requirements_all.txt b/requirements_all.txt index bf6834c18f4..dd476bc1fed 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -152,7 +152,7 @@ alarmdecoder==1.13.2 alpha_vantage==2.1.0 # homeassistant.components.amcrest -amcrest==1.2.3 +amcrest==1.2.4 # homeassistant.components.switch.anel_pwrctrl anel_pwrctrl-homeassistant==0.0.1.dev2 From 7d9c14541b44f2a0b7e7586cbd81dc13a78ed296 Mon Sep 17 00:00:00 2001 From: Nick Whyte Date: Wed, 6 Mar 2019 04:21:13 +1100 Subject: [PATCH 097/291] Bump nessclient version to 0.9.14 (#21679) --- homeassistant/components/ness_alarm/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ness_alarm/__init__.py b/homeassistant/components/ness_alarm/__init__.py index 7d8cd2c3c44..653ade806ec 100644 --- a/homeassistant/components/ness_alarm/__init__.py +++ b/homeassistant/components/ness_alarm/__init__.py @@ -13,7 +13,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send -REQUIREMENTS = ['nessclient==0.9.13'] +REQUIREMENTS = ['nessclient==0.9.14'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index dd476bc1fed..baa011c3174 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -723,7 +723,7 @@ nanoleaf==0.4.1 ndms2_client==0.0.6 # homeassistant.components.ness_alarm -nessclient==0.9.13 +nessclient==0.9.14 # homeassistant.components.sensor.netdata netdata==0.1.2 From 401720085d02541ecd0c49233bd6058162f8a7f0 Mon Sep 17 00:00:00 2001 From: Jonathan McDowell Date: Tue, 5 Mar 2019 17:22:21 +0000 Subject: [PATCH 098/291] Allow 202 status code as a successful REST notify response (#21678) The REST notification component only allows 200 + 201 as a successful response code to the submission. notify.me returns a 202 (Accepted) response, which works fine but gets logged as a warning in the log. Update the allowed statuses to treat the 202 as ok. --- homeassistant/components/notify/rest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/notify/rest.py b/homeassistant/components/notify/rest.py index dd35f986f78..710a1a597e9 100644 --- a/homeassistant/components/notify/rest.py +++ b/homeassistant/components/notify/rest.py @@ -112,7 +112,7 @@ class RestNotificationService(BaseNotificationService): response = requests.get(self._resource, headers=self._headers, params=data, timeout=10) - if response.status_code not in (200, 201): + if response.status_code not in (200, 201, 202): _LOGGER.exception( "Error sending message. Response %d: %s:", response.status_code, response.reason) From dbb92048aaa6d3e4d0ffe1cf4e3dfd55a44a8f58 Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Tue, 5 Mar 2019 09:23:00 -0800 Subject: [PATCH 099/291] Bump teslajsonpy to 0.0.24 (#21675) * Bump teslajsonpy to 0.0.24 * Update requirements_all.txt --- homeassistant/components/tesla/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tesla/__init__.py b/homeassistant/components/tesla/__init__.py index fc433ae18b1..8569bf5a75f 100644 --- a/homeassistant/components/tesla/__init__.py +++ b/homeassistant/components/tesla/__init__.py @@ -11,7 +11,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity from homeassistant.util import slugify -REQUIREMENTS = ['teslajsonpy==0.0.23'] +REQUIREMENTS = ['teslajsonpy==0.0.24'] DOMAIN = 'tesla' diff --git a/requirements_all.txt b/requirements_all.txt index baa011c3174..bb43238c751 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1674,7 +1674,7 @@ temescal==0.1 temperusb==1.5.3 # homeassistant.components.tesla -teslajsonpy==0.0.23 +teslajsonpy==0.0.24 # homeassistant.components.sensor.thermoworks_smoke thermoworks_smoke==0.1.8 From 467d8d616e4935850f96b32aaade30201bfc171e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 5 Mar 2019 11:45:31 -0800 Subject: [PATCH 100/291] Updated frontend to 20190305.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index fbbea13f026..d7c1aabdb49 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -21,7 +21,7 @@ from homeassistant.loader import bind_hass from .storage import async_setup_frontend_storage -REQUIREMENTS = ['home-assistant-frontend==20190303.0'] +REQUIREMENTS = ['home-assistant-frontend==20190305.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index bb43238c751..b7961435f66 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -539,7 +539,7 @@ hole==0.3.0 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190303.0 +home-assistant-frontend==20190305.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bb36f50444e..1dd3d9acfc8 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -120,7 +120,7 @@ hdate==0.8.7 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190303.0 +home-assistant-frontend==20190305.0 # homeassistant.components.homekit_controller homekit==0.12.2 From c9b173405bdef418daade1db04d756691b476267 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 5 Mar 2019 17:17:58 -0800 Subject: [PATCH 101/291] Fix Z-Wave relative imports (#21693) --- .../components/zwave/binary_sensor.py | 13 ++++---- homeassistant/components/zwave/climate.py | 2 +- homeassistant/components/zwave/cover.py | 3 +- homeassistant/components/zwave/fan.py | 6 ++-- homeassistant/components/zwave/light.py | 17 +++++++---- homeassistant/components/zwave/lock.py | 30 +++++++++---------- homeassistant/components/zwave/sensor.py | 20 +++++++------ homeassistant/components/zwave/switch.py | 13 ++++---- 8 files changed, 58 insertions(+), 46 deletions(-) diff --git a/homeassistant/components/zwave/binary_sensor.py b/homeassistant/components/zwave/binary_sensor.py index 478bfcbda7b..bc2b487171d 100644 --- a/homeassistant/components/zwave/binary_sensor.py +++ b/homeassistant/components/zwave/binary_sensor.py @@ -5,11 +5,14 @@ import homeassistant.util.dt as dt_util from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.event import track_point_in_time -from homeassistant.components import zwave -from homeassistant.components.zwave import workaround from homeassistant.components.binary_sensor import ( DOMAIN, BinarySensorDevice) +from . import ( + workaround, + ZWaveDeviceEntity +) +from .const import COMMAND_CLASS_SENSOR_BINARY _LOGGER = logging.getLogger(__name__) @@ -40,17 +43,17 @@ def get_device(values, **kwargs): if workaround.get_device_component_mapping(values.primary) == DOMAIN: return ZWaveBinarySensor(values, None) - if values.primary.command_class == zwave.const.COMMAND_CLASS_SENSOR_BINARY: + if values.primary.command_class == COMMAND_CLASS_SENSOR_BINARY: return ZWaveBinarySensor(values, None) return None -class ZWaveBinarySensor(BinarySensorDevice, zwave.ZWaveDeviceEntity): +class ZWaveBinarySensor(BinarySensorDevice, ZWaveDeviceEntity): """Representation of a binary sensor within Z-Wave.""" def __init__(self, values, device_class): """Initialize the sensor.""" - zwave.ZWaveDeviceEntity.__init__(self, values, DOMAIN) + ZWaveDeviceEntity.__init__(self, values, DOMAIN) self._sensor_type = device_class self._state = self.values.primary.data diff --git a/homeassistant/components/zwave/climate.py b/homeassistant/components/zwave/climate.py index b0ab273e86a..0c57b94739a 100644 --- a/homeassistant/components/zwave/climate.py +++ b/homeassistant/components/zwave/climate.py @@ -7,10 +7,10 @@ from homeassistant.components.climate.const import ( DOMAIN, 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.const import ( STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_TEMPERATURE) from homeassistant.helpers.dispatcher import async_dispatcher_connect +from . import ZWaveDeviceEntity _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zwave/cover.py b/homeassistant/components/zwave/cover.py index e40a885ede1..dc5d38944c8 100644 --- a/homeassistant/components/zwave/cover.py +++ b/homeassistant/components/zwave/cover.py @@ -4,10 +4,9 @@ from homeassistant.core import callback from homeassistant.components.cover import ( DOMAIN, SUPPORT_OPEN, SUPPORT_CLOSE, ATTR_POSITION) from homeassistant.components import zwave -from homeassistant.components.zwave import ( - ZWaveDeviceEntity, workaround) from homeassistant.components.cover import CoverDevice from homeassistant.helpers.dispatcher import async_dispatcher_connect +from . import ZWaveDeviceEntity, workaround _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zwave/fan.py b/homeassistant/components/zwave/fan.py index b2731f7d9a7..193f4fa59b7 100644 --- a/homeassistant/components/zwave/fan.py +++ b/homeassistant/components/zwave/fan.py @@ -6,8 +6,8 @@ from homeassistant.core import callback from homeassistant.components.fan import ( DOMAIN, FanEntity, SPEED_OFF, SPEED_LOW, SPEED_MEDIUM, SPEED_HIGH, SUPPORT_SET_SPEED) -from homeassistant.components import zwave from homeassistant.helpers.dispatcher import async_dispatcher_connect +from . import ZWaveDeviceEntity _LOGGER = logging.getLogger(__name__) @@ -52,12 +52,12 @@ def get_device(values, **kwargs): return ZwaveFan(values) -class ZwaveFan(zwave.ZWaveDeviceEntity, FanEntity): +class ZwaveFan(ZWaveDeviceEntity, FanEntity): """Representation of a Z-Wave fan.""" def __init__(self, values): """Initialize the Z-Wave fan device.""" - zwave.ZWaveDeviceEntity.__init__(self, values, DOMAIN) + ZWaveDeviceEntity.__init__(self, values, DOMAIN) self.update_properties() def update_properties(self): diff --git a/homeassistant/components/zwave/light.py b/homeassistant/components/zwave/light.py index 0af85b84177..15bd5968ad3 100644 --- a/homeassistant/components/zwave/light.py +++ b/homeassistant/components/zwave/light.py @@ -7,10 +7,15 @@ 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.const import STATE_OFF, STATE_ON from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.util.color as color_util +from . import ( + CONF_REFRESH_VALUE, + CONF_REFRESH_DELAY, + const, + ZWaveDeviceEntity, +) _LOGGER = logging.getLogger(__name__) @@ -68,13 +73,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): def get_device(node, values, node_config, **kwargs): """Create Z-Wave entity device.""" - refresh = node_config.get(zwave.CONF_REFRESH_VALUE) - delay = node_config.get(zwave.CONF_REFRESH_DELAY) + refresh = node_config.get(CONF_REFRESH_VALUE) + delay = node_config.get(CONF_REFRESH_DELAY) _LOGGER.debug("node=%d value=%d node_config=%s CONF_REFRESH_VALUE=%s" " CONF_REFRESH_DELAY=%s", node.node_id, values.primary.value_id, node_config, refresh, delay) - if node.has_command_class(zwave.const.COMMAND_CLASS_SWITCH_COLOR): + if node.has_command_class(const.COMMAND_CLASS_SWITCH_COLOR): return ZwaveColorLight(values, refresh, delay) return ZwaveDimmer(values, refresh, delay) @@ -104,12 +109,12 @@ def ct_to_hs(temp): return [int(val) for val in colorlist] -class ZwaveDimmer(zwave.ZWaveDeviceEntity, Light): +class ZwaveDimmer(ZWaveDeviceEntity, Light): """Representation of a Z-Wave dimmer.""" def __init__(self, values, refresh, delay): """Initialize the light.""" - zwave.ZWaveDeviceEntity.__init__(self, values, DOMAIN) + ZWaveDeviceEntity.__init__(self, values, DOMAIN) self._brightness = None self._state = None self._supported_features = None diff --git a/homeassistant/components/zwave/lock.py b/homeassistant/components/zwave/lock.py index ac7d7308333..34b5de18c8f 100755 --- a/homeassistant/components/zwave/lock.py +++ b/homeassistant/components/zwave/lock.py @@ -5,9 +5,9 @@ import voluptuous as vol from homeassistant.core import callback from homeassistant.components.lock import DOMAIN, LockDevice -from homeassistant.components import zwave from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.helpers.config_validation as cv +from . import ZWaveDeviceEntity, const _LOGGER = logging.getLogger(__name__) @@ -135,18 +135,18 @@ ALARM_TYPE_STD = [ ] SET_USERCODE_SCHEMA = vol.Schema({ - vol.Required(zwave.const.ATTR_NODE_ID): vol.Coerce(int), + vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), vol.Required(ATTR_CODE_SLOT): vol.Coerce(int), vol.Required(ATTR_USERCODE): cv.string, }) GET_USERCODE_SCHEMA = vol.Schema({ - vol.Required(zwave.const.ATTR_NODE_ID): vol.Coerce(int), + vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), vol.Required(ATTR_CODE_SLOT): vol.Coerce(int), }) CLEAR_USERCODE_SCHEMA = vol.Schema({ - vol.Required(zwave.const.ATTR_NODE_ID): vol.Coerce(int), + vol.Required(const.ATTR_NODE_ID): vol.Coerce(int), vol.Required(ATTR_CODE_SLOT): vol.Coerce(int), }) @@ -166,17 +166,17 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async_dispatcher_connect(hass, 'zwave_new_lock', async_add_lock) - network = hass.data[zwave.const.DATA_NETWORK] + network = hass.data[const.DATA_NETWORK] def set_usercode(service): """Set the usercode to index X on the lock.""" - node_id = service.data.get(zwave.const.ATTR_NODE_ID) + node_id = service.data.get(const.ATTR_NODE_ID) lock_node = network.nodes[node_id] code_slot = service.data.get(ATTR_CODE_SLOT) usercode = service.data.get(ATTR_USERCODE) for value in lock_node.get_values( - class_id=zwave.const.COMMAND_CLASS_USER_CODE).values(): + class_id=const.COMMAND_CLASS_USER_CODE).values(): if value.index != code_slot: continue if len(str(usercode)) < 4: @@ -190,12 +190,12 @@ async def async_setup_entry(hass, config_entry, async_add_entities): def get_usercode(service): """Get a usercode at index X on the lock.""" - node_id = service.data.get(zwave.const.ATTR_NODE_ID) + node_id = service.data.get(const.ATTR_NODE_ID) lock_node = network.nodes[node_id] code_slot = service.data.get(ATTR_CODE_SLOT) for value in lock_node.get_values( - class_id=zwave.const.COMMAND_CLASS_USER_CODE).values(): + class_id=const.COMMAND_CLASS_USER_CODE).values(): if value.index != code_slot: continue _LOGGER.info("Usercode at slot %s is: %s", value.index, value.data) @@ -203,13 +203,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): def clear_usercode(service): """Set usercode to slot X on the lock.""" - node_id = service.data.get(zwave.const.ATTR_NODE_ID) + node_id = service.data.get(const.ATTR_NODE_ID) lock_node = network.nodes[node_id] code_slot = service.data.get(ATTR_CODE_SLOT) data = '' for value in lock_node.get_values( - class_id=zwave.const.COMMAND_CLASS_USER_CODE).values(): + class_id=const.COMMAND_CLASS_USER_CODE).values(): if value.index != code_slot: continue for i in range(len(value.data)): @@ -236,12 +236,12 @@ def get_device(node, values, **kwargs): return ZwaveLock(values) -class ZwaveLock(zwave.ZWaveDeviceEntity, LockDevice): +class ZwaveLock(ZWaveDeviceEntity, LockDevice): """Representation of a Z-Wave Lock.""" def __init__(self, values): """Initialize the Z-Wave lock device.""" - zwave.ZWaveDeviceEntity.__init__(self, values, DOMAIN) + ZWaveDeviceEntity.__init__(self, values, DOMAIN) self._state = None self._notification = None self._lock_status = None @@ -297,12 +297,12 @@ class ZwaveLock(zwave.ZWaveDeviceEntity, LockDevice): if self._track_message_workaround: this_message = self.node.stats['lastReceivedMessage'][5] - if this_message == zwave.const.COMMAND_CLASS_DOOR_LOCK: + if this_message == const.COMMAND_CLASS_DOOR_LOCK: self._state = self.values.primary.data _LOGGER.debug("set state to %s based on message tracking", self._state) if self._previous_message == \ - zwave.const.COMMAND_CLASS_DOOR_LOCK: + const.COMMAND_CLASS_DOOR_LOCK: if self._state: self._notification = \ LOCK_NOTIFICATION[NOTIFICATION_RF_LOCK] diff --git a/homeassistant/components/zwave/sensor.py b/homeassistant/components/zwave/sensor.py index 44fc132cf77..e1c1914dccc 100644 --- a/homeassistant/components/zwave/sensor.py +++ b/homeassistant/components/zwave/sensor.py @@ -2,10 +2,12 @@ import logging from homeassistant.core import callback from homeassistant.components.sensor import DOMAIN -from homeassistant.components import zwave from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT from homeassistant.helpers.dispatcher import async_dispatcher_connect - +from . import ( + const, + ZWaveDeviceEntity, +) _LOGGER = logging.getLogger(__name__) @@ -28,23 +30,23 @@ async def async_setup_entry(hass, config_entry, async_add_entities): def get_device(node, values, **kwargs): """Create Z-Wave entity device.""" # Generic Device mappings - if node.has_command_class(zwave.const.COMMAND_CLASS_SENSOR_MULTILEVEL): + if node.has_command_class(const.COMMAND_CLASS_SENSOR_MULTILEVEL): return ZWaveMultilevelSensor(values) - if node.has_command_class(zwave.const.COMMAND_CLASS_METER) and \ - values.primary.type == zwave.const.TYPE_DECIMAL: + if node.has_command_class(const.COMMAND_CLASS_METER) and \ + values.primary.type == const.TYPE_DECIMAL: return ZWaveMultilevelSensor(values) - if node.has_command_class(zwave.const.COMMAND_CLASS_ALARM) or \ - node.has_command_class(zwave.const.COMMAND_CLASS_SENSOR_ALARM): + if node.has_command_class(const.COMMAND_CLASS_ALARM) or \ + node.has_command_class(const.COMMAND_CLASS_SENSOR_ALARM): return ZWaveAlarmSensor(values) return None -class ZWaveSensor(zwave.ZWaveDeviceEntity): +class ZWaveSensor(ZWaveDeviceEntity): """Representation of a Z-Wave sensor.""" def __init__(self, values): """Initialize the sensor.""" - zwave.ZWaveDeviceEntity.__init__(self, values, DOMAIN) + ZWaveDeviceEntity.__init__(self, values, DOMAIN) self.update_properties() def update_properties(self): diff --git a/homeassistant/components/zwave/switch.py b/homeassistant/components/zwave/switch.py index ef544222546..f9506aea798 100644 --- a/homeassistant/components/zwave/switch.py +++ b/homeassistant/components/zwave/switch.py @@ -3,8 +3,11 @@ import logging import time from homeassistant.core import callback from homeassistant.components.switch import DOMAIN, SwitchDevice -from homeassistant.components import zwave from homeassistant.helpers.dispatcher import async_dispatcher_connect +from . import ( + ZWaveDeviceEntity, + workaround, +) _LOGGER = logging.getLogger(__name__) @@ -30,15 +33,15 @@ def get_device(values, **kwargs): return ZwaveSwitch(values) -class ZwaveSwitch(zwave.ZWaveDeviceEntity, SwitchDevice): +class ZwaveSwitch(ZWaveDeviceEntity, SwitchDevice): """Representation of a Z-Wave switch.""" def __init__(self, values): """Initialize the Z-Wave switch device.""" - zwave.ZWaveDeviceEntity.__init__(self, values, DOMAIN) + ZWaveDeviceEntity.__init__(self, values, DOMAIN) self.refresh_on_update = ( - zwave.workaround.get_device_mapping(values.primary) == - zwave.workaround.WORKAROUND_REFRESH_NODE_ON_UPDATE) + workaround.get_device_mapping(values.primary) == + workaround.WORKAROUND_REFRESH_NODE_ON_UPDATE) self.last_update = time.perf_counter() self._state = self.values.primary.data From fc1ee9be43ca155f923ba190f62fe6f4bd61a55b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 5 Mar 2019 19:31:26 -0800 Subject: [PATCH 102/291] Use new style for built-in ws commmands (#21694) * Use new style for built-in ws commmands * Lint --- .../components/websocket_api/commands.py | 96 +++++++------------ tests/components/websocket_api/test_auth.py | 3 +- .../components/websocket_api/test_commands.py | 34 +++---- tests/components/websocket_api/test_init.py | 4 +- 4 files changed, 55 insertions(+), 82 deletions(-) diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index 3313971e79e..b367e3392ed 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -10,77 +10,25 @@ from homeassistant.helpers.service import async_get_all_descriptions from . import const, decorators, messages -TYPE_CALL_SERVICE = 'call_service' -TYPE_EVENT = 'event' -TYPE_GET_CONFIG = 'get_config' -TYPE_GET_SERVICES = 'get_services' -TYPE_GET_STATES = 'get_states' -TYPE_PING = 'ping' -TYPE_PONG = 'pong' -TYPE_SUBSCRIBE_EVENTS = 'subscribe_events' -TYPE_UNSUBSCRIBE_EVENTS = 'unsubscribe_events' - @callback def async_register_commands(hass): """Register commands.""" async_reg = hass.components.websocket_api.async_register_command - async_reg(TYPE_SUBSCRIBE_EVENTS, handle_subscribe_events, - SCHEMA_SUBSCRIBE_EVENTS) - async_reg(TYPE_UNSUBSCRIBE_EVENTS, handle_unsubscribe_events, - SCHEMA_UNSUBSCRIBE_EVENTS) - async_reg(TYPE_CALL_SERVICE, handle_call_service, SCHEMA_CALL_SERVICE) - async_reg(TYPE_GET_STATES, handle_get_states, SCHEMA_GET_STATES) - async_reg(TYPE_GET_SERVICES, handle_get_services, SCHEMA_GET_SERVICES) - async_reg(TYPE_GET_CONFIG, handle_get_config, SCHEMA_GET_CONFIG) - async_reg(TYPE_PING, handle_ping, SCHEMA_PING) - - -SCHEMA_SUBSCRIBE_EVENTS = messages.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): TYPE_SUBSCRIBE_EVENTS, - vol.Optional('event_type', default=MATCH_ALL): str, -}) - - -SCHEMA_UNSUBSCRIBE_EVENTS = messages.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): TYPE_UNSUBSCRIBE_EVENTS, - vol.Required('subscription'): cv.positive_int, -}) - - -SCHEMA_CALL_SERVICE = messages.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): TYPE_CALL_SERVICE, - vol.Required('domain'): str, - vol.Required('service'): str, - vol.Optional('service_data'): dict -}) - - -SCHEMA_GET_STATES = messages.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): TYPE_GET_STATES, -}) - - -SCHEMA_GET_SERVICES = messages.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): TYPE_GET_SERVICES, -}) - - -SCHEMA_GET_CONFIG = messages.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): TYPE_GET_CONFIG, -}) - - -SCHEMA_PING = messages.BASE_COMMAND_MESSAGE_SCHEMA.extend({ - vol.Required('type'): TYPE_PING, -}) + async_reg(handle_subscribe_events) + async_reg(handle_unsubscribe_events) + async_reg(handle_call_service) + async_reg(handle_get_states) + async_reg(handle_get_services) + async_reg(handle_get_config) + async_reg(handle_ping) def event_message(iden, event): """Return an event message.""" return { 'id': iden, - 'type': TYPE_EVENT, + 'type': 'event', 'event': event.as_dict(), } @@ -89,11 +37,15 @@ def pong_message(iden): """Return a pong message.""" return { 'id': iden, - 'type': TYPE_PONG, + 'type': 'pong', } @callback +@decorators.websocket_command({ + vol.Required('type'): 'subscribe_events', + vol.Optional('event_type', default=MATCH_ALL): str, +}) def handle_subscribe_events(hass, connection, msg): """Handle subscribe events command. @@ -116,6 +68,10 @@ def handle_subscribe_events(hass, connection, msg): @callback +@decorators.websocket_command({ + vol.Required('type'): 'unsubscribe_events', + vol.Required('subscription'): cv.positive_int, +}) def handle_unsubscribe_events(hass, connection, msg): """Handle unsubscribe events command. @@ -132,6 +88,12 @@ def handle_unsubscribe_events(hass, connection, msg): @decorators.async_response +@decorators.websocket_command({ + vol.Required('type'): 'call_service', + vol.Required('domain'): str, + vol.Required('service'): str, + vol.Optional('service_data'): dict +}) async def handle_call_service(hass, connection, msg): """Handle call service command. @@ -161,6 +123,9 @@ async def handle_call_service(hass, connection, msg): @callback +@decorators.websocket_command({ + vol.Required('type'): 'get_states', +}) def handle_get_states(hass, connection, msg): """Handle get states command. @@ -177,6 +142,9 @@ def handle_get_states(hass, connection, msg): @decorators.async_response +@decorators.websocket_command({ + vol.Required('type'): 'get_services', +}) async def handle_get_services(hass, connection, msg): """Handle get services command. @@ -188,6 +156,9 @@ async def handle_get_services(hass, connection, msg): @callback +@decorators.websocket_command({ + vol.Required('type'): 'get_config', +}) def handle_get_config(hass, connection, msg): """Handle get config command. @@ -198,6 +169,9 @@ def handle_get_config(hass, connection, msg): @callback +@decorators.websocket_command({ + vol.Required('type'): 'ping', +}) def handle_ping(hass, connection, msg): """Handle ping command. diff --git a/tests/components/websocket_api/test_auth.py b/tests/components/websocket_api/test_auth.py index 9d2d2ce251e..e2c6e303326 100644 --- a/tests/components/websocket_api/test_auth.py +++ b/tests/components/websocket_api/test_auth.py @@ -5,7 +5,6 @@ from homeassistant.components.websocket_api.const import URL from homeassistant.components.websocket_api.auth import ( TYPE_AUTH, TYPE_AUTH_INVALID, TYPE_AUTH_OK, TYPE_AUTH_REQUIRED) -from homeassistant.components.websocket_api import commands from homeassistant.setup import async_setup_component from tests.common import mock_coro @@ -45,7 +44,7 @@ async def test_auth_via_msg_incorrect_pass(no_auth_websocket_client): async def test_pre_auth_only_auth_allowed(no_auth_websocket_client): """Verify that before authentication, only auth messages are allowed.""" await no_auth_websocket_client.send_json({ - 'type': commands.TYPE_CALL_SERVICE, + 'type': 'call_service', 'domain': 'domain_test', 'service': 'test_service', 'service_data': { diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index c9ec04c5d7e..8e0f751abed 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -6,7 +6,7 @@ from homeassistant.components.websocket_api.const import URL from homeassistant.components.websocket_api.auth import ( TYPE_AUTH, TYPE_AUTH_OK, TYPE_AUTH_REQUIRED ) -from homeassistant.components.websocket_api import const, commands +from homeassistant.components.websocket_api import const from homeassistant.exceptions import HomeAssistantError from homeassistant.setup import async_setup_component @@ -27,7 +27,7 @@ async def test_call_service(hass, websocket_client): await websocket_client.send_json({ 'id': 5, - 'type': commands.TYPE_CALL_SERVICE, + 'type': 'call_service', 'domain': 'domain_test', 'service': 'test_service', 'service_data': { @@ -52,7 +52,7 @@ async def test_call_service_not_found(hass, websocket_client): """Test call service command.""" await websocket_client.send_json({ 'id': 5, - 'type': commands.TYPE_CALL_SERVICE, + 'type': 'call_service', 'domain': 'domain_test', 'service': 'test_service', 'service_data': { @@ -83,7 +83,7 @@ async def test_call_service_error(hass, websocket_client): await websocket_client.send_json({ 'id': 5, - 'type': commands.TYPE_CALL_SERVICE, + 'type': 'call_service', 'domain': 'domain_test', 'service': 'ha_error', }) @@ -98,7 +98,7 @@ async def test_call_service_error(hass, websocket_client): await websocket_client.send_json({ 'id': 6, - 'type': commands.TYPE_CALL_SERVICE, + 'type': 'call_service', 'domain': 'domain_test', 'service': 'unknown_error', }) @@ -118,7 +118,7 @@ async def test_subscribe_unsubscribe_events(hass, websocket_client): await websocket_client.send_json({ 'id': 5, - 'type': commands.TYPE_SUBSCRIBE_EVENTS, + 'type': 'subscribe_events', 'event_type': 'test_event' }) @@ -138,7 +138,7 @@ async def test_subscribe_unsubscribe_events(hass, websocket_client): msg = await websocket_client.receive_json() assert msg['id'] == 5 - assert msg['type'] == commands.TYPE_EVENT + assert msg['type'] == 'event' event = msg['event'] assert event['event_type'] == 'test_event' @@ -147,7 +147,7 @@ async def test_subscribe_unsubscribe_events(hass, websocket_client): await websocket_client.send_json({ 'id': 6, - 'type': commands.TYPE_UNSUBSCRIBE_EVENTS, + 'type': 'unsubscribe_events', 'subscription': 5 }) @@ -167,7 +167,7 @@ async def test_get_states(hass, websocket_client): await websocket_client.send_json({ 'id': 5, - 'type': commands.TYPE_GET_STATES, + 'type': 'get_states', }) msg = await websocket_client.receive_json() @@ -189,7 +189,7 @@ async def test_get_services(hass, websocket_client): """Test get_services command.""" await websocket_client.send_json({ 'id': 5, - 'type': commands.TYPE_GET_SERVICES, + 'type': 'get_services', }) msg = await websocket_client.receive_json() @@ -203,7 +203,7 @@ async def test_get_config(hass, websocket_client): """Test get_config command.""" await websocket_client.send_json({ 'id': 5, - 'type': commands.TYPE_GET_CONFIG, + 'type': 'get_config', }) msg = await websocket_client.receive_json() @@ -224,12 +224,12 @@ async def test_ping(websocket_client): """Test get_panels command.""" await websocket_client.send_json({ 'id': 5, - 'type': commands.TYPE_PING, + 'type': 'ping', }) msg = await websocket_client.receive_json() assert msg['id'] == 5 - assert msg['type'] == commands.TYPE_PONG + assert msg['type'] == 'pong' async def test_call_service_context_with_user(hass, aiohttp_client, @@ -258,7 +258,7 @@ async def test_call_service_context_with_user(hass, aiohttp_client, await ws.send_json({ 'id': 5, - 'type': commands.TYPE_CALL_SERVICE, + 'type': 'call_service', 'domain': 'domain_test', 'service': 'test_service', 'service_data': { @@ -285,7 +285,7 @@ async def test_subscribe_requires_admin(websocket_client, hass_admin_user): hass_admin_user.groups = [] await websocket_client.send_json({ 'id': 5, - 'type': commands.TYPE_SUBSCRIBE_EVENTS, + 'type': 'subscribe_events', 'event_type': 'test_event' }) @@ -307,7 +307,7 @@ async def test_states_filters_visible(hass, hass_admin_user, websocket_client): hass.states.async_set('test.not_visible_entity', 'invisible') await websocket_client.send_json({ 'id': 5, - 'type': commands.TYPE_GET_STATES, + 'type': 'get_states', }) msg = await websocket_client.receive_json() @@ -327,7 +327,7 @@ async def test_get_states_not_allows_nan(hass, websocket_client): await websocket_client.send_json({ 'id': 5, - 'type': commands.TYPE_GET_STATES, + 'type': 'get_states', }) msg = await websocket_client.receive_json() diff --git a/tests/components/websocket_api/test_init.py b/tests/components/websocket_api/test_init.py index a7e54e8146a..272ac3870ed 100644 --- a/tests/components/websocket_api/test_init.py +++ b/tests/components/websocket_api/test_init.py @@ -5,7 +5,7 @@ from unittest.mock import patch, Mock from aiohttp import WSMsgType import pytest -from homeassistant.components.websocket_api import const, commands, messages +from homeassistant.components.websocket_api import const, messages @pytest.fixture @@ -56,7 +56,7 @@ def test_pending_msg_overflow(hass, mock_low_queue, websocket_client): for idx in range(10): yield from websocket_client.send_json({ 'id': idx + 1, - 'type': commands.TYPE_PING, + 'type': 'ping', }) msg = yield from websocket_client.receive() assert msg.type == WSMsgType.close From d1038ea79fa4e6d6bdbb37b2ae7ba830ab5b84d6 Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Wed, 6 Mar 2019 12:00:53 +0800 Subject: [PATCH 103/291] Google Assistant: Create and pass context to service calls (#21551) * Google Assistant: Create and pass context to service calls * Refactor request data into separate object and pass to execute. --- homeassistant/components/cloud/__init__.py | 1 - homeassistant/components/cloud/iot.py | 4 +- .../components/google_assistant/helpers.py | 14 +- .../components/google_assistant/http.py | 15 +- .../components/google_assistant/smart_home.py | 84 ++++--- .../components/google_assistant/trait.py | 61 ++--- .../google_assistant/test_smart_home.py | 232 ++++++++++-------- .../components/google_assistant/test_trait.py | 199 ++++++++------- 8 files changed, 345 insertions(+), 265 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 4b1a60133db..85ed7391ec7 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -236,7 +236,6 @@ class Cloud: self._gactions_config = ga_h.Config( should_expose=should_expose, allow_unlock=self.prefs.google_allow_unlock, - agent_user_id=self.claims['cognito:username'], entity_config=conf.get(CONF_ENTITY_CONFIG), ) diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py index 4a7215305b2..76999e703fe 100644 --- a/homeassistant/components/cloud/iot.py +++ b/homeassistant/components/cloud/iot.py @@ -334,7 +334,9 @@ def async_handle_google_actions(hass, cloud, payload): return ga.turned_off_response(payload) result = yield from ga.async_handle_message( - hass, cloud.gactions_config, payload) + hass, cloud.gactions_config, + cloud.claims['cognito:username'], + payload) return result diff --git a/homeassistant/components/google_assistant/helpers.py b/homeassistant/components/google_assistant/helpers.py index f20a4106a16..8afa55acc5c 100644 --- a/homeassistant/components/google_assistant/helpers.py +++ b/homeassistant/components/google_assistant/helpers.py @@ -1,4 +1,5 @@ """Helper classes for Google Assistant integration.""" +from homeassistant.core import Context class SmartHomeError(Exception): @@ -16,10 +17,19 @@ class SmartHomeError(Exception): class Config: """Hold the configuration for Google Assistant.""" - def __init__(self, should_expose, allow_unlock, agent_user_id, + def __init__(self, should_expose, allow_unlock, entity_config=None): """Initialize the configuration.""" self.should_expose = should_expose - self.agent_user_id = agent_user_id self.entity_config = entity_config or {} self.allow_unlock = allow_unlock + + +class RequestData: + """Hold data associated with a particular request.""" + + def __init__(self, config, user_id, request_id): + """Initialize the request data.""" + self.config = config + self.request_id = request_id + self.context = Context(user_id=user_id) diff --git a/homeassistant/components/google_assistant/http.py b/homeassistant/components/google_assistant/http.py index f0294c3bcb2..cbe2015f4f9 100644 --- a/homeassistant/components/google_assistant/http.py +++ b/homeassistant/components/google_assistant/http.py @@ -71,17 +71,16 @@ class GoogleAssistantView(HomeAssistantView): def __init__(self, is_exposed, entity_config, allow_unlock): """Initialize the Google Assistant request handler.""" - self.is_exposed = is_exposed - self.entity_config = entity_config - self.allow_unlock = allow_unlock + self.config = Config(is_exposed, + allow_unlock, + entity_config) async def post(self, request: Request) -> Response: """Handle Google Assistant requests.""" message = await request.json() # type: dict - config = Config(self.is_exposed, - self.allow_unlock, - request['hass_user'].id, - self.entity_config) result = await async_handle_message( - request.app['hass'], config, message) + request.app['hass'], + self.config, + request['hass_user'].id, + message) return self.json(result) diff --git a/homeassistant/components/google_assistant/smart_home.py b/homeassistant/components/google_assistant/smart_home.py index 31323decd6c..fa272c25012 100644 --- a/homeassistant/components/google_assistant/smart_home.py +++ b/homeassistant/components/google_assistant/smart_home.py @@ -36,7 +36,7 @@ from .const import ( ERR_UNKNOWN_ERROR, EVENT_COMMAND_RECEIVED, EVENT_SYNC_RECEIVED, EVENT_QUERY_RECEIVED ) -from .helpers import SmartHomeError +from .helpers import SmartHomeError, RequestData HANDLERS = Registry() _LOGGER = logging.getLogger(__name__) @@ -87,7 +87,8 @@ class _GoogleEntity: domain = state.domain features = state.attributes.get(ATTR_SUPPORTED_FEATURES, 0) - return [Trait(self.hass, state, self.config) for Trait in trait.TRAITS + return [Trait(self.hass, state, self.config) + for Trait in trait.TRAITS if Trait.supported(domain, features)] async def sync_serialize(self): @@ -178,7 +179,7 @@ class _GoogleEntity: return attrs - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a command. https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute @@ -186,7 +187,7 @@ class _GoogleEntity: executed = False for trt in self.traits(): if trt.can_execute(command, params): - await trt.execute(command, params) + await trt.execute(command, data, params) executed = True break @@ -202,9 +203,13 @@ class _GoogleEntity: self.state = self.hass.states.get(self.entity_id) -async def async_handle_message(hass, config, message): +async def async_handle_message(hass, config, user_id, message): """Handle incoming API messages.""" - response = await _process(hass, config, message) + request_id = message.get('requestId') # type: str + + data = RequestData(config, user_id, request_id) + + response = await _process(hass, data, message) if response and 'errorCode' in response['payload']: _LOGGER.error('Error handling message %s: %s', @@ -213,14 +218,13 @@ async def async_handle_message(hass, config, message): return response -async def _process(hass, config, message): +async def _process(hass, data, message): """Process a message.""" - request_id = message.get('requestId') # type: str inputs = message.get('inputs') # type: list if len(inputs) != 1: return { - 'requestId': request_id, + 'requestId': data.request_id, 'payload': {'errorCode': ERR_PROTOCOL_ERROR} } @@ -228,49 +232,49 @@ async def _process(hass, config, message): if handler is None: return { - 'requestId': request_id, + 'requestId': data.request_id, 'payload': {'errorCode': ERR_PROTOCOL_ERROR} } try: - result = await handler(hass, config, request_id, - inputs[0].get('payload')) + result = await handler(hass, data, inputs[0].get('payload')) except SmartHomeError as err: return { - 'requestId': request_id, + 'requestId': data.request_id, 'payload': {'errorCode': err.code} } except Exception: # pylint: disable=broad-except _LOGGER.exception('Unexpected error') return { - 'requestId': request_id, + 'requestId': data.request_id, 'payload': {'errorCode': ERR_UNKNOWN_ERROR} } if result is None: return None - return {'requestId': request_id, 'payload': result} + return {'requestId': data.request_id, 'payload': result} @HANDLERS.register('action.devices.SYNC') -async def async_devices_sync(hass, config, request_id, payload): +async def async_devices_sync(hass, data, payload): """Handle action.devices.SYNC request. https://developers.google.com/actions/smarthome/create-app#actiondevicessync """ - hass.bus.async_fire(EVENT_SYNC_RECEIVED, { - 'request_id': request_id - }) + hass.bus.async_fire( + EVENT_SYNC_RECEIVED, + {'request_id': data.request_id}, + context=data.context) devices = [] for state in hass.states.async_all(): if state.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: continue - if not config.should_expose(state): + if not data.config.should_expose(state): continue - entity = _GoogleEntity(hass, config, state) + entity = _GoogleEntity(hass, data.config, state) serialized = await entity.sync_serialize() if serialized is None: @@ -280,7 +284,7 @@ async def async_devices_sync(hass, config, request_id, payload): devices.append(serialized) response = { - 'agentUserId': config.agent_user_id, + 'agentUserId': data.context.user_id, 'devices': devices, } @@ -288,7 +292,7 @@ async def async_devices_sync(hass, config, request_id, payload): @HANDLERS.register('action.devices.QUERY') -async def async_devices_query(hass, config, request_id, payload): +async def async_devices_query(hass, data, payload): """Handle action.devices.QUERY request. https://developers.google.com/actions/smarthome/create-app#actiondevicesquery @@ -298,23 +302,27 @@ async def async_devices_query(hass, config, request_id, payload): devid = device['id'] state = hass.states.get(devid) - hass.bus.async_fire(EVENT_QUERY_RECEIVED, { - 'request_id': request_id, - ATTR_ENTITY_ID: devid, - }) + hass.bus.async_fire( + EVENT_QUERY_RECEIVED, + { + 'request_id': data.request_id, + ATTR_ENTITY_ID: devid, + }, + context=data.context) if not state: # If we can't find a state, the device is offline devices[devid] = {'online': False} continue - devices[devid] = _GoogleEntity(hass, config, state).query_serialize() + entity = _GoogleEntity(hass, data.config, state) + devices[devid] = entity.query_serialize() return {'devices': devices} @HANDLERS.register('action.devices.EXECUTE') -async def handle_devices_execute(hass, config, request_id, payload): +async def handle_devices_execute(hass, data, payload): """Handle action.devices.EXECUTE request. https://developers.google.com/actions/smarthome/create-app#actiondevicesexecute @@ -327,11 +335,14 @@ async def handle_devices_execute(hass, config, request_id, payload): command['execution']): entity_id = device['id'] - hass.bus.async_fire(EVENT_COMMAND_RECEIVED, { - 'request_id': request_id, - ATTR_ENTITY_ID: entity_id, - 'execution': execution - }) + hass.bus.async_fire( + EVENT_COMMAND_RECEIVED, + { + 'request_id': data.request_id, + ATTR_ENTITY_ID: entity_id, + 'execution': execution + }, + context=data.context) # Happens if error occurred. Skip entity for further processing if entity_id in results: @@ -348,10 +359,11 @@ async def handle_devices_execute(hass, config, request_id, payload): } continue - entities[entity_id] = _GoogleEntity(hass, config, state) + entities[entity_id] = _GoogleEntity(hass, data.config, state) try: await entities[entity_id].execute(execution['command'], + data, execution.get('params', {})) except SmartHomeError as err: results[entity_id] = { @@ -378,7 +390,7 @@ async def handle_devices_execute(hass, config, request_id, payload): @HANDLERS.register('action.devices.DISCONNECT') -async def async_devices_disconnect(hass, config, request_id, payload): +async def async_devices_disconnect(hass, data, payload): """Handle action.devices.DISCONNECT request. https://developers.google.com/actions/smarthome/create#actiondevicesdisconnect diff --git a/homeassistant/components/google_assistant/trait.py b/homeassistant/components/google_assistant/trait.py index d0368ee0775..aff24f30512 100644 --- a/homeassistant/components/google_assistant/trait.py +++ b/homeassistant/components/google_assistant/trait.py @@ -102,7 +102,7 @@ class _Trait: """Test if command can be executed.""" return command in self.commands - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a trait command.""" raise NotImplementedError @@ -159,7 +159,7 @@ class BrightnessTrait(_Trait): return response - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a brightness command.""" domain = self.state.domain @@ -168,20 +168,20 @@ class BrightnessTrait(_Trait): light.DOMAIN, light.SERVICE_TURN_ON, { ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_BRIGHTNESS_PCT: params['brightness'] - }, blocking=True) + }, blocking=True, context=data.context) elif domain == cover.DOMAIN: await self.hass.services.async_call( cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION, { ATTR_ENTITY_ID: self.state.entity_id, cover.ATTR_POSITION: params['brightness'] - }, blocking=True) + }, blocking=True, context=data.context) elif domain == media_player.DOMAIN: await self.hass.services.async_call( media_player.DOMAIN, media_player.SERVICE_VOLUME_SET, { ATTR_ENTITY_ID: self.state.entity_id, media_player.ATTR_MEDIA_VOLUME_LEVEL: params['brightness'] / 100 - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -221,7 +221,7 @@ class OnOffTrait(_Trait): return {'on': self.state.state != cover.STATE_CLOSED} return {'on': self.state.state != STATE_OFF} - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute an OnOff command.""" domain = self.state.domain @@ -242,7 +242,7 @@ class OnOffTrait(_Trait): await self.hass.services.async_call(service_domain, service, { ATTR_ENTITY_ID: self.state.entity_id - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -288,7 +288,7 @@ class ColorSpectrumTrait(_Trait): return (command in self.commands and 'spectrumRGB' in params.get('color', {})) - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a color spectrum command.""" # Convert integer to hex format and left pad with 0's till length 6 hex_value = "{0:06x}".format(params['color']['spectrumRGB']) @@ -298,7 +298,7 @@ class ColorSpectrumTrait(_Trait): await self.hass.services.async_call(light.DOMAIN, SERVICE_TURN_ON, { ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_HS_COLOR: color - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -355,7 +355,7 @@ class ColorTemperatureTrait(_Trait): return (command in self.commands and 'temperature' in params.get('color', {})) - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a color temperature command.""" temp = color_util.color_temperature_kelvin_to_mired( params['color']['temperature']) @@ -371,7 +371,7 @@ class ColorTemperatureTrait(_Trait): await self.hass.services.async_call(light.DOMAIN, SERVICE_TURN_ON, { ATTR_ENTITY_ID: self.state.entity_id, light.ATTR_COLOR_TEMP: temp, - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -400,13 +400,14 @@ class SceneTrait(_Trait): """Return scene query attributes.""" return {} - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a scene command.""" # Don't block for scripts as they can be slow. await self.hass.services.async_call( self.state.domain, SERVICE_TURN_ON, { ATTR_ENTITY_ID: self.state.entity_id - }, blocking=self.state.domain != script.DOMAIN) + }, blocking=self.state.domain != script.DOMAIN, + context=data.context) @register_trait @@ -434,12 +435,12 @@ class DockTrait(_Trait): """Return dock query attributes.""" return {'isDocked': self.state.state == vacuum.STATE_DOCKED} - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a dock command.""" await self.hass.services.async_call( self.state.domain, vacuum.SERVICE_RETURN_TO_BASE, { ATTR_ENTITY_ID: self.state.entity_id - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -473,30 +474,30 @@ class StartStopTrait(_Trait): 'isPaused': self.state.state == vacuum.STATE_PAUSED, } - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a StartStop command.""" if command == COMMAND_STARTSTOP: if params['start']: await self.hass.services.async_call( self.state.domain, vacuum.SERVICE_START, { ATTR_ENTITY_ID: self.state.entity_id - }, blocking=True) + }, blocking=True, context=data.context) else: await self.hass.services.async_call( self.state.domain, vacuum.SERVICE_STOP, { ATTR_ENTITY_ID: self.state.entity_id - }, blocking=True) + }, blocking=True, context=data.context) elif command == COMMAND_PAUSEUNPAUSE: if params['pause']: await self.hass.services.async_call( self.state.domain, vacuum.SERVICE_PAUSE, { ATTR_ENTITY_ID: self.state.entity_id - }, blocking=True) + }, blocking=True, context=data.context) else: await self.hass.services.async_call( self.state.domain, vacuum.SERVICE_START, { ATTR_ENTITY_ID: self.state.entity_id - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -584,7 +585,7 @@ class TemperatureSettingTrait(_Trait): return response - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute a temperature point or mode command.""" # All sent in temperatures are always in Celsius unit = self.hass.config.units.temperature_unit @@ -608,7 +609,7 @@ class TemperatureSettingTrait(_Trait): climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE, { ATTR_ENTITY_ID: self.state.entity_id, ATTR_TEMPERATURE: temp - }, blocking=True) + }, blocking=True, context=data.context) elif command == COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE: temp_high = temp_util.convert( @@ -640,7 +641,7 @@ class TemperatureSettingTrait(_Trait): ATTR_ENTITY_ID: self.state.entity_id, climate.ATTR_TARGET_TEMP_HIGH: temp_high, climate.ATTR_TARGET_TEMP_LOW: temp_low, - }, blocking=True) + }, blocking=True, context=data.context) elif command == COMMAND_THERMOSTAT_SET_MODE: await self.hass.services.async_call( @@ -648,7 +649,7 @@ class TemperatureSettingTrait(_Trait): ATTR_ENTITY_ID: self.state.entity_id, climate.ATTR_OPERATION_MODE: self.google_to_hass[params['thermostatMode']], - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -681,7 +682,7 @@ class LockUnlockTrait(_Trait): allowed_unlock = not params['lock'] and self.config.allow_unlock return params['lock'] or allowed_unlock - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute an LockUnlock command.""" if params['lock']: service = lock.SERVICE_LOCK @@ -690,7 +691,7 @@ class LockUnlockTrait(_Trait): await self.hass.services.async_call(lock.DOMAIN, service, { ATTR_ENTITY_ID: self.state.entity_id - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -760,13 +761,13 @@ class FanSpeedTrait(_Trait): return response - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute an SetFanSpeed command.""" await self.hass.services.async_call( fan.DOMAIN, fan.SERVICE_SET_SPEED, { ATTR_ENTITY_ID: self.state.entity_id, fan.ATTR_SPEED: params['fanSpeed'] - }, blocking=True) + }, blocking=True, context=data.context) @register_trait @@ -934,7 +935,7 @@ class ModesTrait(_Trait): return response - async def execute(self, command, params): + async def execute(self, command, data, params): """Execute an SetModes command.""" settings = params.get('updateModeSettings') requested_source = settings.get( @@ -951,4 +952,4 @@ class ModesTrait(_Trait): media_player.SERVICE_SELECT_SOURCE, { ATTR_ENTITY_ID: self.state.entity_id, media_player.ATTR_INPUT_SOURCE: source - }, blocking=True) + }, blocking=True, context=data.context) diff --git a/tests/components/google_assistant/test_smart_home.py b/tests/components/google_assistant/test_smart_home.py index 76fb7b5ddde..302e8d8674f 100644 --- a/tests/components/google_assistant/test_smart_home.py +++ b/tests/components/google_assistant/test_smart_home.py @@ -1,7 +1,7 @@ """Test Google Smart Home.""" import pytest -from homeassistant.core import State +from homeassistant.core import State, EVENT_CALL_SERVICE from homeassistant.const import ( ATTR_SUPPORTED_FEATURES, ATTR_UNIT_OF_MEASUREMENT, TEMP_CELSIUS) from homeassistant.setup import async_setup_component @@ -19,8 +19,7 @@ from tests.common import (mock_device_registry, mock_registry, BASIC_CONFIG = helpers.Config( should_expose=lambda state: True, - allow_unlock=False, - agent_user_id='test-agent', + allow_unlock=False ) REQ_ID = 'ff36a3cc-ec34-11e6-b1a0-64510650abcf' @@ -56,7 +55,6 @@ async def test_sync_message(hass): config = helpers.Config( should_expose=lambda state: state.entity_id != 'light.not_expose', allow_unlock=False, - agent_user_id='test-agent', entity_config={ 'light.demo_light': { const.CONF_ROOM_HINT: 'Living Room', @@ -68,12 +66,14 @@ async def test_sync_message(hass): events = [] hass.bus.async_listen(EVENT_SYNC_RECEIVED, events.append) - result = await sh.async_handle_message(hass, config, { - "requestId": REQ_ID, - "inputs": [{ - "intent": "action.devices.SYNC" - }] - }) + result = await sh.async_handle_message( + hass, config, 'test-agent', + { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.SYNC" + }] + }) assert result == { 'requestId': REQ_ID, @@ -114,6 +114,7 @@ async def test_sync_message(hass): } +# pylint: disable=redefined-outer-name async def test_sync_in_area(hass, registries): """Test a sync message where room hint comes from area.""" area = registries.area.async_create("Living Room") @@ -142,19 +143,20 @@ async def test_sync_in_area(hass, registries): config = helpers.Config( should_expose=lambda _: True, allow_unlock=False, - agent_user_id='test-agent', entity_config={} ) events = [] hass.bus.async_listen(EVENT_SYNC_RECEIVED, events.append) - result = await sh.async_handle_message(hass, config, { - "requestId": REQ_ID, - "inputs": [{ - "intent": "action.devices.SYNC" - }] - }) + result = await sh.async_handle_message( + hass, config, 'test-agent', + { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.SYNC" + }] + }) assert result == { 'requestId': REQ_ID, @@ -216,21 +218,23 @@ async def test_query_message(hass): events = [] hass.bus.async_listen(EVENT_QUERY_RECEIVED, events.append) - result = await sh.async_handle_message(hass, BASIC_CONFIG, { - "requestId": REQ_ID, - "inputs": [{ - "intent": "action.devices.QUERY", - "payload": { - "devices": [{ - "id": "light.demo_light", - }, { - "id": "light.another_light", - }, { - "id": "light.non_existing", - }] - } - }] - }) + result = await sh.async_handle_message( + hass, BASIC_CONFIG, 'test-agent', + { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.QUERY", + "payload": { + "devices": [{ + "id": "light.demo_light", + }, { + "id": "light.another_light", + }, { + "id": "light.non_existing", + }] + } + }] + }) assert result == { 'requestId': REQ_ID, @@ -280,39 +284,44 @@ async def test_execute(hass): 'light': {'platform': 'demo'} }) - events = [] - hass.bus.async_listen(EVENT_COMMAND_RECEIVED, events.append) - await hass.services.async_call( 'light', 'turn_off', {'entity_id': 'light.ceiling_lights'}, blocking=True) - result = await sh.async_handle_message(hass, BASIC_CONFIG, { - "requestId": REQ_ID, - "inputs": [{ - "intent": "action.devices.EXECUTE", - "payload": { - "commands": [{ - "devices": [ - {"id": "light.non_existing"}, - {"id": "light.ceiling_lights"}, - ], - "execution": [{ - "command": "action.devices.commands.OnOff", - "params": { - "on": True - } - }, { - "command": - "action.devices.commands.BrightnessAbsolute", - "params": { - "brightness": 20 - } + events = [] + hass.bus.async_listen(EVENT_COMMAND_RECEIVED, events.append) + + service_events = [] + hass.bus.async_listen(EVENT_CALL_SERVICE, service_events.append) + + result = await sh.async_handle_message( + hass, BASIC_CONFIG, None, + { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.EXECUTE", + "payload": { + "commands": [{ + "devices": [ + {"id": "light.non_existing"}, + {"id": "light.ceiling_lights"}, + ], + "execution": [{ + "command": "action.devices.commands.OnOff", + "params": { + "on": True + } + }, { + "command": + "action.devices.commands.BrightnessAbsolute", + "params": { + "brightness": 20 + } + }] }] - }] - } - }] - }) + } + }] + }) assert result == { "requestId": REQ_ID, @@ -383,6 +392,24 @@ async def test_execute(hass): } } + assert len(service_events) == 2 + assert service_events[0].data == { + 'domain': 'light', + 'service': 'turn_on', + 'service_data': {'entity_id': 'light.ceiling_lights'} + } + assert service_events[0].context == events[2].context + assert service_events[1].data == { + 'domain': 'light', + 'service': 'turn_on', + 'service_data': { + 'brightness_pct': 20, + 'entity_id': 'light.ceiling_lights' + } + } + assert service_events[1].context == events[2].context + assert service_events[1].context == events[3].context + async def test_raising_error_trait(hass): """Test raising an error while executing a trait command.""" @@ -397,26 +424,28 @@ async def test_raising_error_trait(hass): hass.bus.async_listen(EVENT_COMMAND_RECEIVED, events.append) await hass.async_block_till_done() - result = await sh.async_handle_message(hass, BASIC_CONFIG, { - "requestId": REQ_ID, - "inputs": [{ - "intent": "action.devices.EXECUTE", - "payload": { - "commands": [{ - "devices": [ - {"id": "climate.bla"}, - ], - "execution": [{ - "command": "action.devices.commands." - "ThermostatTemperatureSetpoint", - "params": { - "thermostatTemperatureSetpoint": 10 - } + result = await sh.async_handle_message( + hass, BASIC_CONFIG, 'test-agent', + { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.EXECUTE", + "payload": { + "commands": [{ + "devices": [ + {"id": "climate.bla"}, + ], + "execution": [{ + "command": "action.devices.commands." + "ThermostatTemperatureSetpoint", + "params": { + "thermostatTemperatureSetpoint": 10 + } + }] }] - }] - } - }] - }) + } + }] + }) assert result == { "requestId": REQ_ID, @@ -446,6 +475,7 @@ async def test_raising_error_trait(hass): async def test_serialize_input_boolean(hass): """Test serializing an input boolean entity.""" state = State('input_boolean.bla', 'on') + # pylint: disable=protected-access entity = sh._GoogleEntity(hass, BASIC_CONFIG, state) result = await entity.sync_serialize() assert result == { @@ -466,15 +496,17 @@ async def test_unavailable_state_doesnt_sync(hass): ) light.hass = hass light.entity_id = 'light.demo_light' - light._available = False + light._available = False # pylint: disable=protected-access await light.async_update_ha_state() - result = await sh.async_handle_message(hass, BASIC_CONFIG, { - "requestId": REQ_ID, - "inputs": [{ - "intent": "action.devices.SYNC" - }] - }) + result = await sh.async_handle_message( + hass, BASIC_CONFIG, 'test-agent', + { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.SYNC" + }] + }) assert result == { 'requestId': REQ_ID, @@ -495,12 +527,14 @@ async def test_empty_name_doesnt_sync(hass): light.entity_id = 'light.demo_light' await light.async_update_ha_state() - result = await sh.async_handle_message(hass, BASIC_CONFIG, { - "requestId": REQ_ID, - "inputs": [{ - "intent": "action.devices.SYNC" - }] - }) + result = await sh.async_handle_message( + hass, BASIC_CONFIG, 'test-agent', + { + "requestId": REQ_ID, + "inputs": [{ + "intent": "action.devices.SYNC" + }] + }) assert result == { 'requestId': REQ_ID, @@ -513,11 +547,13 @@ async def test_empty_name_doesnt_sync(hass): async def test_query_disconnect(hass): """Test a disconnect message.""" - result = await sh.async_handle_message(hass, BASIC_CONFIG, { - 'inputs': [ - {'intent': 'action.devices.DISCONNECT'} - ], - 'requestId': REQ_ID - }) + result = await sh.async_handle_message( + hass, BASIC_CONFIG, 'test-agent', + { + 'inputs': [ + {'intent': 'action.devices.DISCONNECT'} + ], + 'requestId': REQ_ID + }) assert result is None diff --git a/tests/components/google_assistant/test_trait.py b/tests/components/google_assistant/test_trait.py index e051a5de4da..301de9c8c25 100644 --- a/tests/components/google_assistant/test_trait.py +++ b/tests/components/google_assistant/test_trait.py @@ -19,19 +19,25 @@ from homeassistant.components.google_assistant import trait, helpers, const from homeassistant.const import ( STATE_ON, STATE_OFF, ATTR_ENTITY_ID, SERVICE_TURN_ON, SERVICE_TURN_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE) -from homeassistant.core import State, DOMAIN as HA_DOMAIN +from homeassistant.core import State, DOMAIN as HA_DOMAIN, EVENT_CALL_SERVICE from homeassistant.util import color from tests.common import async_mock_service BASIC_CONFIG = helpers.Config( should_expose=lambda state: True, - allow_unlock=False, - agent_user_id='test-agent', + allow_unlock=False +) + +REQ_ID = 'ff36a3cc-ec34-11e6-b1a0-64510650abcf' + +BASIC_DATA = helpers.RequestData( + BASIC_CONFIG, + 'test-agent', + REQ_ID, ) UNSAFE_CONFIG = helpers.Config( should_expose=lambda state: True, - agent_user_id='test-agent', allow_unlock=True, ) @@ -51,16 +57,28 @@ async def test_brightness_light(hass): 'brightness': 95 } + events = [] + hass.bus.async_listen(EVENT_CALL_SERVICE, events.append) + calls = async_mock_service(hass, light.DOMAIN, light.SERVICE_TURN_ON) - await trt.execute(trait.COMMAND_BRIGHTNESS_ABSOLUTE, { - 'brightness': 50 - }) + await trt.execute( + trait.COMMAND_BRIGHTNESS_ABSOLUTE, BASIC_DATA, + {'brightness': 50}) + await hass.async_block_till_done() + assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'light.bla', light.ATTR_BRIGHTNESS_PCT: 50 } + assert len(events) == 1 + assert events[0].data == { + 'domain': 'light', + 'service': 'turn_on', + 'service_data': {'brightness_pct': 50, 'entity_id': 'light.bla'} + } + async def test_brightness_cover(hass): """Test brightness trait support for cover domain.""" @@ -79,9 +97,9 @@ async def test_brightness_cover(hass): calls = async_mock_service( hass, cover.DOMAIN, cover.SERVICE_SET_COVER_POSITION) - await trt.execute(trait.COMMAND_BRIGHTNESS_ABSOLUTE, { - 'brightness': 50 - }) + await trt.execute( + trait.COMMAND_BRIGHTNESS_ABSOLUTE, BASIC_DATA, + {'brightness': 50}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'cover.bla', @@ -107,9 +125,9 @@ async def test_brightness_media_player(hass): calls = async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_VOLUME_SET) - await trt.execute(trait.COMMAND_BRIGHTNESS_ABSOLUTE, { - 'brightness': 60 - }) + await trt.execute( + trait.COMMAND_BRIGHTNESS_ABSOLUTE, BASIC_DATA, + {'brightness': 60}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'media_player.bla', @@ -137,18 +155,18 @@ async def test_onoff_group(hass): } on_calls = async_mock_service(hass, HA_DOMAIN, SERVICE_TURN_ON) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': True - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': True}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'group.bla', } off_calls = async_mock_service(hass, HA_DOMAIN, SERVICE_TURN_OFF) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': False - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': False}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'group.bla', @@ -176,9 +194,9 @@ async def test_onoff_input_boolean(hass): } on_calls = async_mock_service(hass, input_boolean.DOMAIN, SERVICE_TURN_ON) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': True - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': True}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'input_boolean.bla', @@ -186,9 +204,9 @@ async def test_onoff_input_boolean(hass): off_calls = async_mock_service(hass, input_boolean.DOMAIN, SERVICE_TURN_OFF) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': False - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': False}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'input_boolean.bla', @@ -216,18 +234,18 @@ async def test_onoff_switch(hass): } on_calls = async_mock_service(hass, switch.DOMAIN, SERVICE_TURN_ON) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': True - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': True}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'switch.bla', } off_calls = async_mock_service(hass, switch.DOMAIN, SERVICE_TURN_OFF) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': False - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': False}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'switch.bla', @@ -252,18 +270,18 @@ async def test_onoff_fan(hass): } on_calls = async_mock_service(hass, fan.DOMAIN, SERVICE_TURN_ON) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': True - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': True}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'fan.bla', } off_calls = async_mock_service(hass, fan.DOMAIN, SERVICE_TURN_OFF) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': False - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': False}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'fan.bla', @@ -290,18 +308,18 @@ async def test_onoff_light(hass): } on_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': True - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': True}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'light.bla', } off_calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_OFF) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': False - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': False}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'light.bla', @@ -329,9 +347,9 @@ async def test_onoff_cover(hass): } on_calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_OPEN_COVER) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': True - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': True}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'cover.bla', @@ -339,9 +357,9 @@ async def test_onoff_cover(hass): off_calls = async_mock_service(hass, cover.DOMAIN, cover.SERVICE_CLOSE_COVER) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': False - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': False}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'cover.bla', @@ -369,9 +387,9 @@ async def test_onoff_media_player(hass): } on_calls = async_mock_service(hass, media_player.DOMAIN, SERVICE_TURN_ON) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': True - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': True}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'media_player.bla', @@ -380,9 +398,9 @@ async def test_onoff_media_player(hass): off_calls = async_mock_service(hass, media_player.DOMAIN, SERVICE_TURN_OFF) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': False - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': False}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'media_player.bla', @@ -410,9 +428,9 @@ async def test_onoff_climate(hass): } on_calls = async_mock_service(hass, climate.DOMAIN, SERVICE_TURN_ON) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': True - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': True}) assert len(on_calls) == 1 assert on_calls[0].data == { ATTR_ENTITY_ID: 'climate.bla', @@ -421,9 +439,9 @@ async def test_onoff_climate(hass): off_calls = async_mock_service(hass, climate.DOMAIN, SERVICE_TURN_OFF) - await trt_on.execute(trait.COMMAND_ONOFF, { - 'on': False - }) + await trt_on.execute( + trait.COMMAND_ONOFF, BASIC_DATA, + {'on': False}) assert len(off_calls) == 1 assert off_calls[0].data == { ATTR_ENTITY_ID: 'climate.bla', @@ -445,7 +463,8 @@ async def test_dock_vacuum(hass): calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_RETURN_TO_BASE) - await trt.execute(trait.COMMAND_DOCK, {}) + await trt.execute( + trait.COMMAND_DOCK, BASIC_DATA, {}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'vacuum.bla', @@ -469,7 +488,7 @@ async def test_startstop_vacuum(hass): start_calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_START) - await trt.execute(trait.COMMAND_STARTSTOP, {'start': True}) + await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {'start': True}) assert len(start_calls) == 1 assert start_calls[0].data == { ATTR_ENTITY_ID: 'vacuum.bla', @@ -477,7 +496,7 @@ async def test_startstop_vacuum(hass): stop_calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_STOP) - await trt.execute(trait.COMMAND_STARTSTOP, {'start': False}) + await trt.execute(trait.COMMAND_STARTSTOP, BASIC_DATA, {'start': False}) assert len(stop_calls) == 1 assert stop_calls[0].data == { ATTR_ENTITY_ID: 'vacuum.bla', @@ -485,7 +504,7 @@ async def test_startstop_vacuum(hass): pause_calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_PAUSE) - await trt.execute(trait.COMMAND_PAUSEUNPAUSE, {'pause': True}) + await trt.execute(trait.COMMAND_PAUSEUNPAUSE, BASIC_DATA, {'pause': True}) assert len(pause_calls) == 1 assert pause_calls[0].data == { ATTR_ENTITY_ID: 'vacuum.bla', @@ -493,7 +512,7 @@ async def test_startstop_vacuum(hass): unpause_calls = async_mock_service(hass, vacuum.DOMAIN, vacuum.SERVICE_START) - await trt.execute(trait.COMMAND_PAUSEUNPAUSE, {'pause': False}) + await trt.execute(trait.COMMAND_PAUSEUNPAUSE, BASIC_DATA, {'pause': False}) assert len(unpause_calls) == 1 assert unpause_calls[0].data == { ATTR_ENTITY_ID: 'vacuum.bla', @@ -532,7 +551,7 @@ async def test_color_spectrum_light(hass): }) calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) - await trt.execute(trait.COMMAND_COLOR_ABSOLUTE, { + await trt.execute(trait.COMMAND_COLOR_ABSOLUTE, BASIC_DATA, { 'color': { 'spectrumRGB': 1052927 } @@ -581,14 +600,14 @@ async def test_color_temperature_light(hass): calls = async_mock_service(hass, light.DOMAIN, SERVICE_TURN_ON) with pytest.raises(helpers.SmartHomeError) as err: - await trt.execute(trait.COMMAND_COLOR_ABSOLUTE, { + await trt.execute(trait.COMMAND_COLOR_ABSOLUTE, BASIC_DATA, { 'color': { 'temperature': 5555 } }) assert err.value.code == const.ERR_VALUE_OUT_OF_RANGE - await trt.execute(trait.COMMAND_COLOR_ABSOLUTE, { + await trt.execute(trait.COMMAND_COLOR_ABSOLUTE, BASIC_DATA, { 'color': { 'temperature': 2857 } @@ -626,7 +645,7 @@ async def test_scene_scene(hass): assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {}) calls = async_mock_service(hass, scene.DOMAIN, SERVICE_TURN_ON) - await trt.execute(trait.COMMAND_ACTIVATE_SCENE, {}) + await trt.execute(trait.COMMAND_ACTIVATE_SCENE, BASIC_DATA, {}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'scene.bla', @@ -643,7 +662,7 @@ async def test_scene_script(hass): assert trt.can_execute(trait.COMMAND_ACTIVATE_SCENE, {}) calls = async_mock_service(hass, script.DOMAIN, SERVICE_TURN_ON) - await trt.execute(trait.COMMAND_ACTIVATE_SCENE, {}) + await trt.execute(trait.COMMAND_ACTIVATE_SCENE, BASIC_DATA, {}) # We don't wait till script execution is done. await hass.async_block_till_done() @@ -695,10 +714,11 @@ async def test_temperature_setting_climate_range(hass): calls = async_mock_service( hass, climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE) - await trt.execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, { - 'thermostatTemperatureSetpointHigh': 25, - 'thermostatTemperatureSetpointLow': 20, - }) + await trt.execute( + trait.COMMAND_THERMOSTAT_TEMPERATURE_SET_RANGE, BASIC_DATA, { + 'thermostatTemperatureSetpointHigh': 25, + 'thermostatTemperatureSetpointLow': 20, + }) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'climate.bla', @@ -708,7 +728,7 @@ async def test_temperature_setting_climate_range(hass): calls = async_mock_service( hass, climate.DOMAIN, climate.SERVICE_SET_OPERATION_MODE) - await trt.execute(trait.COMMAND_THERMOSTAT_SET_MODE, { + await trt.execute(trait.COMMAND_THERMOSTAT_SET_MODE, BASIC_DATA, { 'thermostatMode': 'heatcool', }) assert len(calls) == 1 @@ -718,9 +738,9 @@ async def test_temperature_setting_climate_range(hass): } with pytest.raises(helpers.SmartHomeError) as err: - await trt.execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, { - 'thermostatTemperatureSetpoint': -100, - }) + await trt.execute( + trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, BASIC_DATA, + {'thermostatTemperatureSetpoint': -100}) assert err.value.code == const.ERR_VALUE_OUT_OF_RANGE hass.config.units.temperature_unit = TEMP_CELSIUS @@ -762,13 +782,13 @@ async def test_temperature_setting_climate_setpoint(hass): hass, climate.DOMAIN, climate.SERVICE_SET_TEMPERATURE) with pytest.raises(helpers.SmartHomeError): - await trt.execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, { - 'thermostatTemperatureSetpoint': -100, - }) + await trt.execute( + trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, BASIC_DATA, + {'thermostatTemperatureSetpoint': -100}) - await trt.execute(trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, { - 'thermostatTemperatureSetpoint': 19, - }) + await trt.execute( + trait.COMMAND_THERMOSTAT_TEMPERATURE_SETPOINT, BASIC_DATA, + {'thermostatTemperatureSetpoint': 19}) assert len(calls) == 1 assert calls[0].data == { ATTR_ENTITY_ID: 'climate.bla', @@ -793,7 +813,7 @@ async def test_lock_unlock_lock(hass): assert trt.can_execute(trait.COMMAND_LOCKUNLOCK, {'lock': True}) calls = async_mock_service(hass, lock.DOMAIN, lock.SERVICE_LOCK) - await trt.execute(trait.COMMAND_LOCKUNLOCK, {'lock': True}) + await trt.execute(trait.COMMAND_LOCKUNLOCK, BASIC_DATA, {'lock': True}) assert len(calls) == 1 assert calls[0].data == { @@ -830,7 +850,7 @@ async def test_lock_unlock_unlock(hass): assert trt.can_execute(trait.COMMAND_LOCKUNLOCK, {'lock': False}) calls = async_mock_service(hass, lock.DOMAIN, lock.SERVICE_UNLOCK) - await trt.execute(trait.COMMAND_LOCKUNLOCK, {'lock': False}) + await trt.execute(trait.COMMAND_LOCKUNLOCK, BASIC_DATA, {'lock': False}) assert len(calls) == 1 assert calls[0].data == { @@ -910,7 +930,8 @@ async def test_fan_speed(hass): trait.COMMAND_FANSPEED, params={'fanSpeed': 'medium'}) calls = async_mock_service(hass, fan.DOMAIN, fan.SERVICE_SET_SPEED) - await trt.execute(trait.COMMAND_FANSPEED, params={'fanSpeed': 'medium'}) + await trt.execute( + trait.COMMAND_FANSPEED, BASIC_DATA, {'fanSpeed': 'medium'}) assert len(calls) == 1 assert calls[0].data == { @@ -995,7 +1016,7 @@ async def test_modes(hass): calls = async_mock_service( hass, media_player.DOMAIN, media_player.SERVICE_SELECT_SOURCE) await trt.execute( - trait.COMMAND_MODES, params={ + trait.COMMAND_MODES, BASIC_DATA, { 'updateModeSettings': { trt.HA_TO_GOOGLE.get(media_player.ATTR_INPUT_SOURCE): 'media' }}) From b7b034c53243de1df28ef70a4f70840412c5ad0e Mon Sep 17 00:00:00 2001 From: Alan Tse Date: Tue, 5 Mar 2019 21:44:37 -0800 Subject: [PATCH 104/291] Update to teslajsonpy v0.0.25 (#21702) --- homeassistant/components/tesla/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/tesla/__init__.py b/homeassistant/components/tesla/__init__.py index 8569bf5a75f..244538f5f46 100644 --- a/homeassistant/components/tesla/__init__.py +++ b/homeassistant/components/tesla/__init__.py @@ -11,7 +11,7 @@ from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity from homeassistant.util import slugify -REQUIREMENTS = ['teslajsonpy==0.0.24'] +REQUIREMENTS = ['teslajsonpy==0.0.25'] DOMAIN = 'tesla' diff --git a/requirements_all.txt b/requirements_all.txt index b7961435f66..09dc0352013 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1674,7 +1674,7 @@ temescal==0.1 temperusb==1.5.3 # homeassistant.components.tesla -teslajsonpy==0.0.24 +teslajsonpy==0.0.25 # homeassistant.components.sensor.thermoworks_smoke thermoworks_smoke==0.1.8 From 54895fcb1e6f45c5598ad6c300037e71772a1421 Mon Sep 17 00:00:00 2001 From: Fredrik Erlandsson Date: Wed, 6 Mar 2019 13:52:25 +0100 Subject: [PATCH 105/291] resync hass that changes have occured (#21705) --- homeassistant/components/tellduslive/cover.py | 3 +++ homeassistant/components/tellduslive/light.py | 1 + homeassistant/components/tellduslive/switch.py | 2 ++ 3 files changed, 6 insertions(+) diff --git a/homeassistant/components/tellduslive/cover.py b/homeassistant/components/tellduslive/cover.py index 5a22311d7f0..1bd3158d100 100644 --- a/homeassistant/components/tellduslive/cover.py +++ b/homeassistant/components/tellduslive/cover.py @@ -44,11 +44,14 @@ class TelldusLiveCover(TelldusLiveEntity, CoverDevice): def close_cover(self, **kwargs): """Close the cover.""" self.device.down() + self._update_callback() def open_cover(self, **kwargs): """Open the cover.""" self.device.up() + self._update_callback() def stop_cover(self, **kwargs): """Stop the cover.""" self.device.stop() + self._update_callback() diff --git a/homeassistant/components/tellduslive/light.py b/homeassistant/components/tellduslive/light.py index 10eaee1ad8b..12baf8384f6 100644 --- a/homeassistant/components/tellduslive/light.py +++ b/homeassistant/components/tellduslive/light.py @@ -45,6 +45,7 @@ class TelldusLiveLight(TelldusLiveEntity, Light): def changed(self): """Define a property of the device that might have changed.""" self._last_brightness = self.brightness + self._update_callback() @property def brightness(self): diff --git a/homeassistant/components/tellduslive/switch.py b/homeassistant/components/tellduslive/switch.py index 63d1512698c..bb0164b10bb 100644 --- a/homeassistant/components/tellduslive/switch.py +++ b/homeassistant/components/tellduslive/switch.py @@ -44,7 +44,9 @@ class TelldusLiveSwitch(TelldusLiveEntity, ToggleEntity): def turn_on(self, **kwargs): """Turn the switch on.""" self.device.turn_on() + self._update_callback() def turn_off(self, **kwargs): """Turn the switch off.""" self.device.turn_off() + self._update_callback() From 8e9a4960025fd1b422c5a6dbd1c7c28af86853b5 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Wed, 6 Mar 2019 12:55:24 +0000 Subject: [PATCH 106/291] Utility Meter offset defined by a time_period (#20926) * change offset from int to Time period dictionary * track according to offset * left overs... tks @fabaff * typo --- .../components/utility_meter/__init__.py | 6 ++- .../components/utility_meter/sensor.py | 33 +++++++------ tests/components/utility_meter/test_sensor.py | 46 +++++++++++++++---- 3 files changed, 61 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index 2f062851ee6..97321c456e5 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -1,5 +1,6 @@ """Support for tracking consumption over given periods of time.""" import logging +from datetime import timedelta import voluptuous as vol @@ -23,6 +24,8 @@ TARIFF_ICON = 'mdi:clock-outline' ATTR_TARIFFS = 'tariffs' +DEFAULT_OFFSET = timedelta(hours=0) + SERVICE_METER_SCHEMA = vol.Schema({ vol.Required(ATTR_ENTITY_ID): cv.entity_ids, }) @@ -35,7 +38,8 @@ METER_CONFIG_SCHEMA = vol.Schema({ vol.Required(CONF_SOURCE_SENSOR): cv.entity_id, vol.Optional(CONF_NAME): cv.string, vol.Optional(CONF_METER_TYPE): vol.In(METER_TYPES), - vol.Optional(CONF_METER_OFFSET, default=0): cv.positive_int, + vol.Optional(CONF_METER_OFFSET, default=DEFAULT_OFFSET): + vol.All(cv.time_period, cv.positive_timedelta), vol.Optional(CONF_METER_NET_CONSUMPTION, default=False): cv.boolean, vol.Optional(CONF_TARIFFS, default=[]): vol.All( cv.ensure_list, [cv.string]), diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index 21dc1099442..dd1514f5e43 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -1,6 +1,6 @@ """Utility meter from sensors providing raw data.""" import logging - +from datetime import date, timedelta from decimal import Decimal, DecimalException import homeassistant.util.dt as dt_util @@ -128,15 +128,17 @@ class UtilityMeterSensor(RestoreEntity): self.async_schedule_update_ha_state() async def _async_reset_meter(self, event): - """Determine cycle - Helper function for larger then daily cycles.""" - now = dt_util.now() - if self._period == WEEKLY and now.weekday() != self._period_offset: + """Determine cycle - Helper function for larger than daily cycles.""" + now = dt_util.now().date() + if self._period == WEEKLY and\ + now != now - timedelta(days=now.weekday())\ + + self._period_offset: return if self._period == MONTHLY and\ - now.day != (1 + self._period_offset): + now != date(now.year, now.month, 1) + self._period_offset: return if self._period == YEARLY and\ - (now.month != (1 + self._period_offset) or now.day != 1): + now != date(now.year, 1, 1) + self._period_offset: return await self.async_reset_meter(self._tariff_entity) @@ -155,15 +157,16 @@ class UtilityMeterSensor(RestoreEntity): await super().async_added_to_hass() if self._period == HOURLY: - async_track_time_change(self.hass, self._async_reset_meter, - minute=self._period_offset, second=0) - elif self._period == DAILY: - async_track_time_change(self.hass, self._async_reset_meter, - hour=self._period_offset, minute=0, - second=0) - elif self._period in [WEEKLY, MONTHLY, YEARLY]: - async_track_time_change(self.hass, self._async_reset_meter, - hour=0, minute=0, second=0) + async_track_time_change( + self.hass, self._async_reset_meter, + minute=self._period_offset.seconds // 60, + second=self._period_offset.seconds % 60) + elif self._period in [DAILY, WEEKLY, MONTHLY, YEARLY]: + async_track_time_change( + self.hass, self._async_reset_meter, + hour=self._period_offset.seconds // 3600, + minute=self._period_offset.seconds % 3600 // 60, + second=self._period_offset.seconds % 3600 % 60) async_dispatcher_connect( self.hass, SIGNAL_RESET_METER, self.async_reset_meter) diff --git a/tests/components/utility_meter/test_sensor.py b/tests/components/utility_meter/test_sensor.py index 03c95fdf897..ee291439a2c 100644 --- a/tests/components/utility_meter/test_sensor.py +++ b/tests/components/utility_meter/test_sensor.py @@ -123,8 +123,8 @@ async def test_non_net_consumption(hass): assert state.state == '0' -async def _test_self_reset(hass, cycle, start_time, expect_reset=True): - """Test energy sensor self reset.""" +def gen_config(cycle, offset=None): + """Generate configuration.""" config = { 'utility_meter': { 'energy_bill': { @@ -134,6 +134,16 @@ async def _test_self_reset(hass, cycle, start_time, expect_reset=True): } } + if offset: + config['utility_meter']['energy_bill']['offset'] = { + 'days': offset.days, + 'seconds': offset.seconds + } + return config + + +async def _test_self_reset(hass, config, start_time, expect_reset=True): + """Test energy sensor self reset.""" assert await async_setup_component(hass, DOMAIN, config) assert await async_setup_component(hass, SENSOR_DOMAIN, config) await hass.async_block_till_done() @@ -173,30 +183,50 @@ async def _test_self_reset(hass, cycle, start_time, expect_reset=True): async def test_self_reset_hourly(hass): """Test hourly reset of meter.""" - await _test_self_reset(hass, 'hourly', "2017-12-31T23:59:00.000000+00:00") + await _test_self_reset(hass, gen_config('hourly'), + "2017-12-31T23:59:00.000000+00:00") async def test_self_reset_daily(hass): """Test daily reset of meter.""" - await _test_self_reset(hass, 'daily', "2017-12-31T23:59:00.000000+00:00") + await _test_self_reset(hass, gen_config('daily'), + "2017-12-31T23:59:00.000000+00:00") async def test_self_reset_weekly(hass): """Test weekly reset of meter.""" - await _test_self_reset(hass, 'weekly', "2017-12-31T23:59:00.000000+00:00") + await _test_self_reset(hass, gen_config('weekly'), + "2017-12-31T23:59:00.000000+00:00") async def test_self_reset_monthly(hass): """Test monthly reset of meter.""" - await _test_self_reset(hass, 'monthly', "2017-12-31T23:59:00.000000+00:00") + await _test_self_reset(hass, gen_config('monthly'), + "2017-12-31T23:59:00.000000+00:00") async def test_self_reset_yearly(hass): """Test yearly reset of meter.""" - await _test_self_reset(hass, 'yearly', "2017-12-31T23:59:00.000000+00:00") + await _test_self_reset(hass, gen_config('yearly'), + "2017-12-31T23:59:00.000000+00:00") async def test_self_no_reset_yearly(hass): """Test yearly reset of meter does not occur after 1st January.""" - await _test_self_reset(hass, 'yearly', "2018-01-01T23:59:00.000000+00:00", + await _test_self_reset(hass, gen_config('yearly'), + "2018-01-01T23:59:00.000000+00:00", + expect_reset=False) + + +async def test_reset_yearly_offset(hass): + """Test yearly reset of meter.""" + await _test_self_reset(hass, + gen_config('yearly', timedelta(days=1, minutes=10)), + "2018-01-02T00:09:00.000000+00:00") + + +async def test_no_reset_yearly_offset(hass): + """Test yearly reset of meter.""" + await _test_self_reset(hass, gen_config('yearly', timedelta(31)), + "2018-01-30T23:59:00.000000+00:00", expect_reset=False) From 0e36b2677029c84763961bcf33bc0752b6743d0b Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Wed, 6 Mar 2019 16:40:29 +0100 Subject: [PATCH 107/291] Upgrade toonapilib to 3.2.1 (#21706) --- homeassistant/components/toon/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/toon/__init__.py b/homeassistant/components/toon/__init__.py index 12cae9ac801..0ca0a414fa5 100644 --- a/homeassistant/components/toon/__init__.py +++ b/homeassistant/components/toon/__init__.py @@ -16,7 +16,7 @@ from .const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DISPLAY, CONF_TENANT, DATA_TOON_CLIENT, DATA_TOON_CONFIG, DOMAIN) -REQUIREMENTS = ['toonapilib==3.1.0'] +REQUIREMENTS = ['toonapilib==3.2.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 09dc0352013..ec838ea6cc8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1689,7 +1689,7 @@ tikteck==0.4 todoist-python==7.0.17 # homeassistant.components.toon -toonapilib==3.1.0 +toonapilib==3.2.1 # homeassistant.components.alarm_control_panel.totalconnect total_connect_client==0.22 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1dd3d9acfc8..38059af2538 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -295,7 +295,7 @@ srpenergy==1.0.5 statsd==3.2.1 # homeassistant.components.toon -toonapilib==3.1.0 +toonapilib==3.2.1 # homeassistant.components.camera.uvc uvcclient==0.11.0 From 56165050323be5eb181fc6ad2fd3c047c04c5fe0 Mon Sep 17 00:00:00 2001 From: Phil Bruckner Date: Wed, 6 Mar 2019 21:42:59 -0600 Subject: [PATCH 108/291] Change amcrest camera_image to async (#21720) Change AmcrestCam method camera_image to async so asyncio lock can be used instead of a threading lock. Bump amcrest package to 1.2.5. --- homeassistant/components/amcrest/__init__.py | 2 +- homeassistant/components/amcrest/camera.py | 12 +++++++----- requirements_all.txt | 2 +- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/amcrest/__init__.py b/homeassistant/components/amcrest/__init__.py index bcb18402900..b976c1bd9d3 100644 --- a/homeassistant/components/amcrest/__init__.py +++ b/homeassistant/components/amcrest/__init__.py @@ -13,7 +13,7 @@ from homeassistant.const import ( from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['amcrest==1.2.4'] +REQUIREMENTS = ['amcrest==1.2.5'] DEPENDENCIES = ['ffmpeg'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 6acaa5fc86e..2793fbca958 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -1,6 +1,6 @@ """Support for Amcrest IP cameras.""" +import asyncio import logging -import threading from requests import RequestException from urllib3.exceptions import ReadTimeoutError @@ -47,14 +47,16 @@ class AmcrestCam(Camera): self._stream_source = amcrest.stream_source self._resolution = amcrest.resolution self._token = self._auth = amcrest.authentication - self._snapshot_lock = threading.Lock() + self._snapshot_lock = asyncio.Lock() - def camera_image(self): + async def async_camera_image(self): """Return a still image response from the camera.""" - with self._snapshot_lock: + async with self._snapshot_lock: try: # Send the request to snap a picture and return raw jpg data - return self._camera.snapshot(channel=self._resolution).data + response = await self.hass.async_add_executor_job( + self._camera.snapshot, self._resolution) + return response.data except (RequestException, ReadTimeoutError, ValueError) as error: _LOGGER.error( 'Could not get camera image due to error %s', error) diff --git a/requirements_all.txt b/requirements_all.txt index ec838ea6cc8..207097b2bdc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -152,7 +152,7 @@ alarmdecoder==1.13.2 alpha_vantage==2.1.0 # homeassistant.components.amcrest -amcrest==1.2.4 +amcrest==1.2.5 # homeassistant.components.switch.anel_pwrctrl anel_pwrctrl-homeassistant==0.0.1.dev2 From dbf129dfdda55918568fc01b75f594afc58c1285 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Thu, 7 Mar 2019 03:44:52 +0000 Subject: [PATCH 109/291] Start preparing for homekit_controller config entries (#21564) * Start preparing for homekit_controller config entries * Review feedback * Review feedback * Only use the vol.strip validator for pairing_code * CV not required now * Changes from review * Changes after review --- .../homekit_controller/.translations/en.json | 33 + .../components/homekit_controller/__init__.py | 28 +- .../homekit_controller/config_flow.py | 260 ++++++ .../homekit_controller/connection.py | 35 + .../components/homekit_controller/const.py | 23 + .../homekit_controller/strings.json | 33 + tests/components/homekit_controller/common.py | 76 +- .../homekit_controller/test_config_flow.py | 783 ++++++++++++++++++ 8 files changed, 1219 insertions(+), 52 deletions(-) create mode 100644 homeassistant/components/homekit_controller/.translations/en.json create mode 100644 homeassistant/components/homekit_controller/config_flow.py create mode 100644 homeassistant/components/homekit_controller/connection.py create mode 100644 homeassistant/components/homekit_controller/const.py create mode 100644 homeassistant/components/homekit_controller/strings.json create mode 100644 tests/components/homekit_controller/test_config_flow.py diff --git a/homeassistant/components/homekit_controller/.translations/en.json b/homeassistant/components/homekit_controller/.translations/en.json new file mode 100644 index 00000000000..6cbd172085e --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/en.json @@ -0,0 +1,33 @@ +{ + "config": { + "title": "HomeKit Accessory", + "step": { + "user": { + "title": "Pair with HomeKit Accessory", + "description": "Select the device you want to pair with", + "data": { + "device": "Device" + } + }, + "pair": { + "title": "Pair with {{ model }}", + "description": "Enter your HomeKit pairing code to use this accessory", + "data": { + "pairing_code": "Pairing Code" + } + } + }, + "error": { + "unable_to_pair": "Unable to pair, please try again.", + "unknown_error": "Device reported an unknown error. Pairing failed.", + "authentication_error": "Incorrect HomeKit code. Please check it and try again." + }, + "abort": { + "no_devices": "No unpaired devices could be found", + "already_paired": "This accessory is already paired to another device. Please reset the accessory and try again.", + "ignored_model": "HomeKit support for this model is blocked as a more feature complete native integration is available.", + "already_configured": "Accessory is already configured with this controller.", + "invalid_config_entry": "This device is showing as ready to pair but there is already a conflicting config entry for it in Home Assistant that must first be removed." + } + } +} diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index e4bfdc24ffb..3e481db96da 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -8,38 +8,22 @@ from homeassistant.helpers import discovery from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import call_later +from .const import ( + CONTROLLER, DOMAIN, HOMEKIT_ACCESSORY_DISPATCH, KNOWN_ACCESSORIES, + KNOWN_DEVICES +) + + REQUIREMENTS = ['homekit==0.12.2'] -DOMAIN = 'homekit_controller' HOMEKIT_DIR = '.homekit' -# Mapping from Homekit type to component. -HOMEKIT_ACCESSORY_DISPATCH = { - 'lightbulb': 'light', - 'outlet': 'switch', - 'switch': 'switch', - 'thermostat': 'climate', - 'security-system': 'alarm_control_panel', - 'garage-door-opener': 'cover', - 'window': 'cover', - 'window-covering': 'cover', - 'lock-mechanism': 'lock', - 'motion': 'binary_sensor', - 'humidity': 'sensor', - 'light': 'sensor', - 'temperature': 'sensor' -} - HOMEKIT_IGNORE = [ 'BSB002', 'Home Assistant Bridge', 'TRADFRI gateway', ] -KNOWN_ACCESSORIES = "{}-accessories".format(DOMAIN) -KNOWN_DEVICES = "{}-devices".format(DOMAIN) -CONTROLLER = "{}-controller".format(DOMAIN) - _LOGGER = logging.getLogger(__name__) REQUEST_TIMEOUT = 5 # seconds diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py new file mode 100644 index 00000000000..5b4d5e81e29 --- /dev/null +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -0,0 +1,260 @@ +"""Config flow to configure homekit_controller.""" +import os +import json +import logging + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.core import callback + +from .const import DOMAIN, KNOWN_DEVICES +from .connection import get_bridge_information, get_accessory_name + + +HOMEKIT_IGNORE = [ + 'BSB002', + 'Home Assistant Bridge', + 'TRADFRI gateway', +] +HOMEKIT_DIR = '.homekit' +PAIRING_FILE = 'pairing.json' + +_LOGGER = logging.getLogger(__name__) + + +def load_old_pairings(hass): + """Load any old pairings from on-disk json fragments.""" + old_pairings = {} + + data_dir = os.path.join(hass.config.path(), HOMEKIT_DIR) + pairing_file = os.path.join(data_dir, PAIRING_FILE) + + # Find any pairings created with in HA 0.85 / 0.86 + if os.path.exists(pairing_file): + with open(pairing_file) as pairing_file: + old_pairings.update(json.load(pairing_file)) + + # Find any pairings created in HA <= 0.84 + if os.path.exists(data_dir): + for device in os.listdir(data_dir): + if not device.startswith('hk-'): + continue + alias = device[3:] + if alias in old_pairings: + continue + with open(os.path.join(data_dir, device)) as pairing_data_fp: + old_pairings[alias] = json.load(pairing_data_fp) + + return old_pairings + + +@callback +def find_existing_host(hass, serial): + """Return a set of the configured hosts.""" + for entry in hass.config_entries.async_entries(DOMAIN): + if entry.data['AccessoryPairingID'] == serial: + return entry + + +@config_entries.HANDLERS.register(DOMAIN) +class HomekitControllerFlowHandler(config_entries.ConfigFlow): + """Handle a HomeKit config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_LOCAL_POLL + + def __init__(self): + """Initialize the homekit_controller flow.""" + self.model = None + self.hkid = None + self.devices = {} + + async def async_step_user(self, user_input=None): + """Handle a flow start.""" + import homekit + + errors = {} + + if user_input is not None: + key = user_input['device'] + props = self.devices[key]['properties'] + self.hkid = props['id'] + self.model = props['md'] + return await self.async_step_pair() + + controller = homekit.Controller() + all_hosts = await self.hass.async_add_executor_job( + controller.discover, 5 + ) + + self.devices = {} + for host in all_hosts: + status_flags = int(host['properties']['sf']) + paired = not status_flags & 0x01 + if paired: + continue + self.devices[host['properties']['id']] = host + + if not self.devices: + return self.async_abort( + reason='no_devices' + ) + + return self.async_show_form( + step_id='user', + errors=errors, + data_schema=vol.Schema({ + vol.Required('device'): vol.In(self.devices.keys()), + }) + ) + + async def async_step_discovery(self, discovery_info): + """Handle a discovered HomeKit accessory. + + This flow is triggered by the discovery component. + """ + # Normalize properties from discovery + # homekit_python has code to do this, but not in a form we can + # easily use, so do the bare minimum ourselves here instead. + properties = { + key.lower(): value + for (key, value) in discovery_info['properties'].items() + } + + # The hkid is a unique random number that looks like a pairing code. + # It changes if a device is factory reset. + hkid = properties['id'] + model = properties['md'] + + status_flags = int(properties['sf']) + paired = not status_flags & 0x01 + + # The configuration number increases every time the characteristic map + # needs updating. Some devices use a slightly off-spec name so handle + # both cases. + try: + config_num = int(properties['c#']) + except KeyError: + _LOGGER.warning( + "HomeKit device %s: c# not exposed, in violation of spec", + hkid) + config_num = None + + if paired: + if hkid in self.hass.data.get(KNOWN_DEVICES, {}): + # The device is already paired and known to us + # According to spec we should monitor c# (config_num) for + # changes. If it changes, we check for new entities + conn = self.hass.data[KNOWN_DEVICES][hkid] + if conn.config_num != config_num: + _LOGGER.debug( + "HomeKit info %s: c# incremented, refreshing entities", + hkid) + self.hass.async_create_task( + conn.async_config_num_changed(config_num)) + return self.async_abort(reason='already_configured') + + old_pairings = await self.hass.async_add_executor_job( + load_old_pairings, + self.hass + ) + + if hkid in old_pairings: + return await self.async_import_legacy_pairing( + properties, + old_pairings[hkid] + ) + + # Device is paired but not to us - ignore it + _LOGGER.debug("HomeKit device %s ignored as already paired", hkid) + return self.async_abort(reason='already_paired') + + # Devices in HOMEKIT_IGNORE have native local integrations - users + # should be encouraged to use native integration and not confused + # by alternative HK API. + if model in HOMEKIT_IGNORE: + return self.async_abort(reason='ignored_model') + + # Device isn't paired with us or anyone else. + # But we have a 'complete' config entry for it - that is probably + # invalid. Remove it automatically. + existing = find_existing_host(self.hass, hkid) + if existing: + await self.hass.config_entries.async_remove(existing.entry_id) + + self.model = model + self.hkid = hkid + return await self.async_step_pair() + + async def async_import_legacy_pairing(self, discovery_props, pairing_data): + """Migrate a legacy pairing to config entries.""" + from homekit.controller import Pairing + + hkid = discovery_props['id'] + + existing = find_existing_host(self.hass, hkid) + if existing: + _LOGGER.info( + ("Legacy configuration for homekit accessory %s" + "not loaded as already migrated"), hkid) + return self.async_abort(reason='already_configured') + + _LOGGER.info( + ("Legacy configuration %s for homekit" + "accessory migrated to config entries"), hkid) + + pairing = Pairing(pairing_data) + + return await self._entry_from_accessory(pairing) + + async def async_step_pair(self, pair_info=None): + """Pair with a new HomeKit accessory.""" + import homekit # pylint: disable=import-error + + errors = {} + + if pair_info: + code = pair_info['pairing_code'] + controller = homekit.Controller() + try: + await self.hass.async_add_executor_job( + controller.perform_pairing, self.hkid, self.hkid, code + ) + + pairing = controller.pairings.get(self.hkid) + if pairing: + return await self._entry_from_accessory( + pairing) + + errors['pairing_code'] = 'unable_to_pair' + except homekit.AuthenticationError: + errors['pairing_code'] = 'authentication_error' + except homekit.UnknownError: + errors['pairing_code'] = 'unknown_error' + except homekit.UnavailableError: + return self.async_abort(reason='already_paired') + + return self.async_show_form( + step_id='pair', + description_placeholders={ + 'model': self.model, + }, + errors=errors, + data_schema=vol.Schema({ + vol.Required('pairing_code'): vol.All(str, vol.Strip), + }) + ) + + async def _entry_from_accessory(self, pairing): + """Return a config entry from an initialized bridge.""" + accessories = await self.hass.async_add_executor_job( + pairing.list_accessories_and_characteristics + ) + bridge_info = get_bridge_information(accessories) + name = get_accessory_name(bridge_info) + + return self.async_create_entry( + title=name, + data=pairing.pairing_data, + ) diff --git a/homeassistant/components/homekit_controller/connection.py b/homeassistant/components/homekit_controller/connection.py new file mode 100644 index 00000000000..5550846120b --- /dev/null +++ b/homeassistant/components/homekit_controller/connection.py @@ -0,0 +1,35 @@ +"""Helpers for managing a pairing with a HomeKit accessory or bridge.""" + + +def get_accessory_information(accessory): + """Obtain the accessory information service of a HomeKit device.""" + # pylint: disable=import-error + from homekit.model.services import ServicesTypes + from homekit.model.characteristics import CharacteristicsTypes + + result = {} + for service in accessory['services']: + stype = service['type'].upper() + if ServicesTypes.get_short(stype) != 'accessory-information': + continue + for characteristic in service['characteristics']: + ctype = CharacteristicsTypes.get_short(characteristic['type']) + if 'value' in characteristic: + result[ctype] = characteristic['value'] + return result + + +def get_bridge_information(accessories): + """Return the accessory info for the bridge.""" + for accessory in accessories: + if accessory['aid'] == 1: + return get_accessory_information(accessory) + return get_accessory_information(accessories[0]) + + +def get_accessory_name(accessory_info): + """Return the name field of an accessory.""" + for field in ('name', 'model', 'manufacturer'): + if field in accessory_info: + return accessory_info[field] + return None diff --git a/homeassistant/components/homekit_controller/const.py b/homeassistant/components/homekit_controller/const.py new file mode 100644 index 00000000000..873f6b343d2 --- /dev/null +++ b/homeassistant/components/homekit_controller/const.py @@ -0,0 +1,23 @@ +"""Constants for the homekit_controller component.""" +DOMAIN = 'homekit_controller' + +KNOWN_ACCESSORIES = "{}-accessories".format(DOMAIN) +KNOWN_DEVICES = "{}-devices".format(DOMAIN) +CONTROLLER = "{}-controller".format(DOMAIN) + +# Mapping from Homekit type to component. +HOMEKIT_ACCESSORY_DISPATCH = { + 'lightbulb': 'light', + 'outlet': 'switch', + 'switch': 'switch', + 'thermostat': 'climate', + 'security-system': 'alarm_control_panel', + 'garage-door-opener': 'cover', + 'window': 'cover', + 'window-covering': 'cover', + 'lock-mechanism': 'lock', + 'motion': 'binary_sensor', + 'humidity': 'sensor', + 'light': 'sensor', + 'temperature': 'sensor' +} diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json new file mode 100644 index 00000000000..6cbd172085e --- /dev/null +++ b/homeassistant/components/homekit_controller/strings.json @@ -0,0 +1,33 @@ +{ + "config": { + "title": "HomeKit Accessory", + "step": { + "user": { + "title": "Pair with HomeKit Accessory", + "description": "Select the device you want to pair with", + "data": { + "device": "Device" + } + }, + "pair": { + "title": "Pair with {{ model }}", + "description": "Enter your HomeKit pairing code to use this accessory", + "data": { + "pairing_code": "Pairing Code" + } + } + }, + "error": { + "unable_to_pair": "Unable to pair, please try again.", + "unknown_error": "Device reported an unknown error. Pairing failed.", + "authentication_error": "Incorrect HomeKit code. Please check it and try again." + }, + "abort": { + "no_devices": "No unpaired devices could be found", + "already_paired": "This accessory is already paired to another device. Please reset the accessory and try again.", + "ignored_model": "HomeKit support for this model is blocked as a more feature complete native integration is available.", + "already_configured": "Accessory is already configured with this controller.", + "invalid_config_entry": "This device is showing as ready to pair but there is already a conflicting config entry for it in Home Assistant that must first be removed." + } + } +} diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 9409e3affad..29e7f4e986e 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -22,38 +22,47 @@ class FakePairing: class. """ - def __init__(self, accessory): + def __init__(self, accessories): """Create a fake pairing from an accessory model.""" - self.accessory = accessory - self.pairing_data = { - 'accessories': self.list_accessories_and_characteristics() - } + self.accessories = accessories + self.pairing_data = {} def list_accessories_and_characteristics(self): """Fake implementation of list_accessories_and_characteristics.""" - return [self.accessory.to_accessory_and_service_list()] + accessories = [ + a.to_accessory_and_service_list() for a in self.accessories + ] + # replicate what happens upstream right now + self.pairing_data['accessories'] = accessories + return accessories def get_characteristics(self, characteristics): """Fake implementation of get_characteristics.""" results = {} for aid, cid in characteristics: - for service in self.accessory.services: - for char in service.characteristics: - if char.iid != cid: - continue - results[(aid, cid)] = { - 'value': char.get_value() - } + for accessory in self.accessories: + if aid != accessory.aid: + continue + for service in accessory.services: + for char in service.characteristics: + if char.iid != cid: + continue + results[(aid, cid)] = { + 'value': char.get_value() + } return results def put_characteristics(self, characteristics): """Fake implementation of put_characteristics.""" - for _, cid, new_val in characteristics: - for service in self.accessory.services: - for char in service.characteristics: - if char.iid != cid: - continue - char.set_value(new_val) + for aid, cid, new_val in characteristics: + for accessory in self.accessories: + if aid != accessory.aid: + continue + for service in accessory.services: + for char in service.characteristics: + if char.iid != cid: + continue + char.set_value(new_val) class FakeController: @@ -68,9 +77,9 @@ class FakeController: """Create a Fake controller with no pairings.""" self.pairings = {} - def add(self, accessory): + def add(self, accessories): """Create and register a fake pairing for a simulated accessory.""" - pairing = FakePairing(accessory) + pairing = FakePairing(accessories) self.pairings['00:00:00:00:00:00'] = pairing return pairing @@ -134,6 +143,20 @@ class FakeService(AbstractService): return char +async def setup_platform(hass): + """Load the platform but with a fake Controller API.""" + config = { + 'discovery': { + } + } + + with mock.patch('homekit.Controller') as controller: + fake_controller = controller.return_value = FakeController() + await async_setup_component(hass, DOMAIN, config) + + return fake_controller + + async def setup_test_component(hass, services, capitalize=False, suffix=None): """Load a fake homekit accessory based on a homekit accessory model. @@ -150,18 +173,11 @@ async def setup_test_component(hass, services, capitalize=False, suffix=None): assert domain, 'Cannot map test homekit services to homeassistant domain' - config = { - 'discovery': { - } - } - - with mock.patch('homekit.Controller') as controller: - fake_controller = controller.return_value = FakeController() - await async_setup_component(hass, DOMAIN, config) + fake_controller = await setup_platform(hass) accessory = Accessory('TestDevice', 'example.com', 'Test', '0001', '0.1') accessory.services.extend(services) - pairing = fake_controller.add(accessory) + pairing = fake_controller.add([accessory]) discovery_info = { 'host': '127.0.0.1', diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py new file mode 100644 index 00000000000..cf4da597b12 --- /dev/null +++ b/tests/components/homekit_controller/test_config_flow.py @@ -0,0 +1,783 @@ +"""Tests for homekit_controller config flow.""" +import json +from unittest import mock + +import homekit + +from homeassistant.components.homekit_controller import config_flow +from homeassistant.components.homekit_controller.const import KNOWN_DEVICES +from tests.common import MockConfigEntry +from tests.components.homekit_controller.common import ( + Accessory, FakeService, setup_platform +) + + +async def test_discovery_works(hass): + """Test a device being discovered.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + pairing = mock.Mock(pairing_data={ + 'AccessoryPairingID': '00:00:00:00:00:00', + }) + + pairing.list_accessories_and_characteristics.return_value = [{ + "aid": 1, + "services": [{ + "characteristics": [{ + "type": "23", + "value": "Koogeek-LS1-20833F" + }], + "type": "3e", + }] + }] + + controller = mock.Mock() + controller.pairings = { + '00:00:00:00:00:00': pairing, + } + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) + + assert result['type'] == 'create_entry' + assert result['title'] == 'Koogeek-LS1-20833F' + assert result['data'] == pairing.pairing_data + + +async def test_discovery_works_upper_case(hass): + """Test a device being discovered.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'MD': 'TestDevice', + 'ID': '00:00:00:00:00:00', + 'C#': 1, + 'SF': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + pairing = mock.Mock(pairing_data={ + 'AccessoryPairingID': '00:00:00:00:00:00', + }) + + pairing.list_accessories_and_characteristics.return_value = [{ + "aid": 1, + "services": [{ + "characteristics": [{ + "type": "23", + "value": "Koogeek-LS1-20833F" + }], + "type": "3e", + }] + }] + + controller = mock.Mock() + controller.pairings = { + '00:00:00:00:00:00': pairing, + } + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) + + assert result['type'] == 'create_entry' + assert result['title'] == 'Koogeek-LS1-20833F' + assert result['data'] == pairing.pairing_data + + +async def test_discovery_works_missing_csharp(hass): + """Test a device being discovered that has missing mdns attrs.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'sf': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + pairing = mock.Mock(pairing_data={ + 'AccessoryPairingID': '00:00:00:00:00:00', + }) + + pairing.list_accessories_and_characteristics.return_value = [{ + "aid": 1, + "services": [{ + "characteristics": [{ + "type": "23", + "value": "Koogeek-LS1-20833F" + }], + "type": "3e", + }] + }] + + controller = mock.Mock() + controller.pairings = { + '00:00:00:00:00:00': pairing, + } + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) + + assert result['type'] == 'create_entry' + assert result['title'] == 'Koogeek-LS1-20833F' + assert result['data'] == pairing.pairing_data + + +async def test_pair_already_paired_1(hass): + """Already paired.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 0, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'abort' + assert result['reason'] == 'already_paired' + + +async def test_discovery_ignored_model(hass): + """Already paired.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'BSB002', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'abort' + assert result['reason'] == 'ignored_model' + + +async def test_discovery_invalid_config_entry(hass): + """There is already a config entry for the pairing id but its invalid.""" + MockConfigEntry(domain='homekit_controller', data={ + 'AccessoryPairingID': '00:00:00:00:00:00' + }).add_to_hass(hass) + + # We just added a mock config entry so it must be visible in hass + assert len(hass.config_entries.async_entries()) == 1 + + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + # Discovery of a HKID that is in a pairable state but for which there is + # already a config entry - in that case the stale config entry is + # automatically removed. + config_entry_count = len(hass.config_entries.async_entries()) + assert config_entry_count == 0 + + +async def test_discovery_already_configured(hass): + """Already configured.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 0, + } + } + + await setup_platform(hass) + + conn = mock.Mock() + conn.config_num = 1 + hass.data[KNOWN_DEVICES]['00:00:00:00:00:00'] = conn + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'abort' + assert result['reason'] == 'already_configured' + + assert conn.async_config_num_changed.call_count == 0 + + +async def test_discovery_already_configured_config_change(hass): + """Already configured.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 2, + 'sf': 0, + } + } + + await setup_platform(hass) + + conn = mock.Mock() + conn.config_num = 1 + hass.data[KNOWN_DEVICES]['00:00:00:00:00:00'] = conn + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'abort' + assert result['reason'] == 'already_configured' + + assert conn.async_config_num_changed.call_args == mock.call(2) + + +async def test_pair_unable_to_pair(hass): + """Pairing completed without exception, but didn't create a pairing.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + controller = mock.Mock() + controller.pairings = {} + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) + + assert result['type'] == 'form' + assert result['errors']['pairing_code'] == 'unable_to_pair' + + +async def test_pair_authentication_error(hass): + """Pairing code is incorrect.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + controller = mock.Mock() + controller.pairings = {} + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + exc = homekit.AuthenticationError('Invalid pairing code') + controller.perform_pairing.side_effect = exc + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) + + assert result['type'] == 'form' + assert result['errors']['pairing_code'] == 'authentication_error' + + +async def test_pair_unknown_error(hass): + """Pairing failed for an unknown rason.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + controller = mock.Mock() + controller.pairings = {} + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + exc = homekit.UnknownError('Unknown error') + controller.perform_pairing.side_effect = exc + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) + + assert result['type'] == 'form' + assert result['errors']['pairing_code'] == 'unknown_error' + + +async def test_pair_already_paired(hass): + """Device is already paired.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_step_discovery(discovery_info) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + controller = mock.Mock() + controller.pairings = {} + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + exc = homekit.UnavailableError('Unavailable error') + controller.perform_pairing.side_effect = exc + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) + + assert result['type'] == 'abort' + assert result['reason'] == 'already_paired' + + +async def test_import_works(hass): + """Test a device being discovered.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + import_info = { + 'AccessoryPairingID': '00:00:00:00:00:00', + } + + pairing = mock.Mock(pairing_data={ + 'AccessoryPairingID': '00:00:00:00:00:00', + }) + + pairing.list_accessories_and_characteristics.return_value = [{ + "aid": 1, + "services": [{ + "characteristics": [{ + "type": "23", + "value": "Koogeek-LS1-20833F" + }], + "type": "3e", + }] + }] + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + with mock.patch('homekit.controller.Pairing') as pairing_cls: + pairing_cls.return_value = pairing + result = await flow.async_import_legacy_pairing( + discovery_info['properties'], import_info) + + assert result['type'] == 'create_entry' + assert result['title'] == 'Koogeek-LS1-20833F' + assert result['data'] == pairing.pairing_data + + +async def test_import_already_configured(hass): + """Test importing a device from .homekit that is already a ConfigEntry.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + import_info = { + 'AccessoryPairingID': '00:00:00:00:00:00', + } + + config_entry = MockConfigEntry( + domain='homekit_controller', + data=import_info + ) + config_entry.add_to_hass(hass) + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + result = await flow.async_import_legacy_pairing( + discovery_info['properties'], import_info) + assert result['type'] == 'abort' + assert result['reason'] == 'already_configured' + + +async def test_user_works(hass): + """Test user initiated disovers devices.""" + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 1, + } + } + + pairing = mock.Mock(pairing_data={ + 'AccessoryPairingID': '00:00:00:00:00:00', + }) + pairing.list_accessories_and_characteristics.return_value = [{ + "aid": 1, + "services": [{ + "characteristics": [{ + "type": "23", + "value": "Koogeek-LS1-20833F" + }], + "type": "3e", + }] + }] + + controller = mock.Mock() + controller.pairings = { + '00:00:00:00:00:00': pairing, + } + controller.discover.return_value = [ + discovery_info, + ] + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + result = await flow.async_step_user() + assert result['type'] == 'form' + assert result['step_id'] == 'user' + + result = await flow.async_step_user({ + 'device': '00:00:00:00:00:00', + }) + assert result['type'] == 'form' + assert result['step_id'] == 'pair' + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value = controller + result = await flow.async_step_pair({ + 'pairing_code': '111-22-33', + }) + assert result['type'] == 'create_entry' + assert result['title'] == 'Koogeek-LS1-20833F' + assert result['data'] == pairing.pairing_data + + +async def test_user_no_devices(hass): + """Test user initiated pairing where no devices discovered.""" + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value.discover.return_value = [] + result = await flow.async_step_user() + + assert result['type'] == 'abort' + assert result['reason'] == 'no_devices' + + +async def test_user_no_unpaired_devices(hass): + """Test user initiated pairing where no unpaired devices discovered.""" + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 0, + } + } + + with mock.patch('homekit.Controller') as controller_cls: + controller_cls.return_value.discover.return_value = [ + discovery_info, + ] + result = await flow.async_step_user() + + assert result['type'] == 'abort' + assert result['reason'] == 'no_devices' + + +async def test_parse_new_homekit_json(hass): + """Test migrating recent .homekit/pairings.json files.""" + service = FakeService('public.hap.service.lightbulb') + on_char = service.add_characteristic('on') + on_char.value = 1 + + accessory = Accessory('TestDevice', 'example.com', 'Test', '0001', '0.1') + accessory.services.append(service) + + fake_controller = await setup_platform(hass) + pairing = fake_controller.add([accessory]) + pairing.pairing_data = { + 'AccessoryPairingID': '00:00:00:00:00:00', + } + + mock_path = mock.Mock() + mock_path.exists.side_effect = [True, False] + + read_data = { + '00:00:00:00:00:00': pairing.pairing_data, + } + mock_open = mock.mock_open(read_data=json.dumps(read_data)) + + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 0, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + with mock.patch('homekit.controller.Pairing') as pairing_cls: + pairing_cls.return_value = pairing + with mock.patch('builtins.open', mock_open): + with mock.patch('os.path', mock_path): + result = await flow.async_step_discovery(discovery_info) + + assert result['type'] == 'create_entry' + assert result['title'] == 'TestDevice' + assert result['data']['AccessoryPairingID'] == '00:00:00:00:00:00' + + +async def test_parse_old_homekit_json(hass): + """Test migrating original .homekit/hk-00:00:00:00:00:00 files.""" + service = FakeService('public.hap.service.lightbulb') + on_char = service.add_characteristic('on') + on_char.value = 1 + + accessory = Accessory('TestDevice', 'example.com', 'Test', '0001', '0.1') + accessory.services.append(service) + + fake_controller = await setup_platform(hass) + pairing = fake_controller.add([accessory]) + pairing.pairing_data = { + 'AccessoryPairingID': '00:00:00:00:00:00', + } + + mock_path = mock.Mock() + mock_path.exists.side_effect = [False, True] + + mock_listdir = mock.Mock() + mock_listdir.return_value = [ + 'hk-00:00:00:00:00:00', + 'pairings.json' + ] + + read_data = { + 'AccessoryPairingID': '00:00:00:00:00:00', + } + mock_open = mock.mock_open(read_data=json.dumps(read_data)) + + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 0, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + with mock.patch('homekit.controller.Pairing') as pairing_cls: + pairing_cls.return_value = pairing + with mock.patch('builtins.open', mock_open): + with mock.patch('os.path', mock_path): + with mock.patch('os.listdir', mock_listdir): + result = await flow.async_step_discovery(discovery_info) + + assert result['type'] == 'create_entry' + assert result['title'] == 'TestDevice' + assert result['data']['AccessoryPairingID'] == '00:00:00:00:00:00' + + +async def test_parse_overlapping_homekit_json(hass): + """Test migrating .homekit/pairings.json files when hk- exists too.""" + service = FakeService('public.hap.service.lightbulb') + on_char = service.add_characteristic('on') + on_char.value = 1 + + accessory = Accessory('TestDevice', 'example.com', 'Test', '0001', '0.1') + accessory.services.append(service) + + fake_controller = await setup_platform(hass) + pairing = fake_controller.add([accessory]) + pairing.pairing_data = { + 'AccessoryPairingID': '00:00:00:00:00:00', + } + + mock_listdir = mock.Mock() + mock_listdir.return_value = [ + 'hk-00:00:00:00:00:00', + 'pairings.json' + ] + + mock_path = mock.Mock() + mock_path.exists.side_effect = [True, True] + + # First file to get loaded is .homekit/pairing.json + read_data_1 = { + '00:00:00:00:00:00': { + 'AccessoryPairingID': '00:00:00:00:00:00', + } + } + mock_open_1 = mock.mock_open(read_data=json.dumps(read_data_1)) + + # Second file to get loaded is .homekit/hk-00:00:00:00:00:00 + read_data_2 = { + 'AccessoryPairingID': '00:00:00:00:00:00', + } + mock_open_2 = mock.mock_open(read_data=json.dumps(read_data_2)) + + side_effects = [mock_open_1.return_value, mock_open_2.return_value] + + discovery_info = { + 'host': '127.0.0.1', + 'port': 8080, + 'properties': { + 'md': 'TestDevice', + 'id': '00:00:00:00:00:00', + 'c#': 1, + 'sf': 0, + } + } + + flow = config_flow.HomekitControllerFlowHandler() + flow.hass = hass + + with mock.patch('homekit.controller.Pairing') as pairing_cls: + pairing_cls.return_value = pairing + with mock.patch('builtins.open', side_effect=side_effects): + with mock.patch('os.path', mock_path): + with mock.patch('os.listdir', mock_listdir): + result = await flow.async_step_discovery(discovery_info) + + await hass.async_block_till_done() + + assert result['type'] == 'create_entry' + assert result['title'] == 'TestDevice' + assert result['data']['AccessoryPairingID'] == '00:00:00:00:00:00' From 9f06be750f3d4217c30d302a9b3b4e42401da992 Mon Sep 17 00:00:00 2001 From: Sergio Oller Date: Thu, 7 Mar 2019 04:47:13 +0100 Subject: [PATCH 110/291] Support multiple keys in ifttt triggers (#21454) * Support multiple keys in ifttt triggers * Rename `to` to `target` in ifttt. Follow PR code review suggestions --- homeassistant/components/ifttt/__init__.py | 22 +++++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ifttt/__init__.py b/homeassistant/components/ifttt/__init__.py index 0a06947b00f..4ab361d41eb 100644 --- a/homeassistant/components/ifttt/__init__.py +++ b/homeassistant/components/ifttt/__init__.py @@ -17,6 +17,7 @@ _LOGGER = logging.getLogger(__name__) EVENT_RECEIVED = 'ifttt_webhook_received' ATTR_EVENT = 'event' +ATTR_TARGET = 'target' ATTR_VALUE1 = 'value1' ATTR_VALUE2 = 'value2' ATTR_VALUE3 = 'value3' @@ -29,6 +30,7 @@ SERVICE_TRIGGER = 'trigger' SERVICE_TRIGGER_SCHEMA = vol.Schema({ vol.Required(ATTR_EVENT): cv.string, + vol.Optional(ATTR_TARGET): vol.All(cv.ensure_list, [cv.string]), vol.Optional(ATTR_VALUE1): cv.string, vol.Optional(ATTR_VALUE2): cv.string, vol.Optional(ATTR_VALUE3): cv.string, @@ -36,7 +38,7 @@ SERVICE_TRIGGER_SCHEMA = vol.Schema({ CONFIG_SCHEMA = vol.Schema({ vol.Optional(DOMAIN): vol.Schema({ - vol.Required(CONF_KEY): cv.string, + vol.Required(CONF_KEY): vol.Any({cv.string: cv.string}, cv.string), }), }, extra=vol.ALLOW_EXTRA) @@ -46,18 +48,32 @@ async def async_setup(hass, config): if DOMAIN not in config: return True - key = config[DOMAIN][CONF_KEY] + api_keys = config[DOMAIN][CONF_KEY] + if isinstance(api_keys, str): + api_keys = {"default": api_keys} def trigger_service(call): """Handle IFTTT trigger service calls.""" event = call.data[ATTR_EVENT] + targets = call.data.get(ATTR_TARGET, list(api_keys)) value1 = call.data.get(ATTR_VALUE1) value2 = call.data.get(ATTR_VALUE2) value3 = call.data.get(ATTR_VALUE3) + target_keys = dict() + for target in targets: + if target not in api_keys: + _LOGGER.error("No IFTTT api key for %s", target) + continue + target_keys[target] = api_keys[target] + try: import pyfttt - pyfttt.send_event(key, event, value1, value2, value3) + for target, key in target_keys.items(): + res = pyfttt.send_event(key, event, value1, value2, value3) + if res.status_code != 200: + _LOGGER.error("IFTTT reported error sending event to %s.", + target) except requests.exceptions.RequestException: _LOGGER.exception("Error communicating with IFTTT") From f4a9ad0b2ebc3369da3ae6b5c62c1f9eae0bb193 Mon Sep 17 00:00:00 2001 From: c-soft Date: Thu, 7 Mar 2019 04:47:47 +0100 Subject: [PATCH 111/291] Fix initialization and add "pending" status of Satel integra (#21194) * Added updating alarm state after start of the HA. Still rough and dirty. * Fixed initialization of the panel and binary sensor. Before cleanup. * Added alarm clearing, linting fixes. * Removed dead code, added style changes. * Updated requirements * Fixed linting errors. * Fixed linting errors * Fixed linter errors. * Fixed hopefully last linter errors. * Fixes after code review, imports sorted. * Removed init debugging --- .../components/satel_integra/__init__.py | 42 ++------- .../satel_integra/alarm_control_panel.py | 90 ++++++++++++++++--- .../components/satel_integra/binary_sensor.py | 22 +++-- requirements_all.txt | 2 +- 4 files changed, 102 insertions(+), 54 deletions(-) diff --git a/homeassistant/components/satel_integra/__init__.py b/homeassistant/components/satel_integra/__init__.py index bff365a079f..93f157cd5ec 100644 --- a/homeassistant/components/satel_integra/__init__.py +++ b/homeassistant/components/satel_integra/__init__.py @@ -1,19 +1,15 @@ """Support for Satel Integra devices.""" -import asyncio import logging - import voluptuous as vol +from homeassistant.const import EVENT_HOMEASSISTANT_STOP from homeassistant.core import callback -from homeassistant.const import ( - STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, - STATE_ALARM_TRIGGERED, EVENT_HOMEASSISTANT_STOP) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import async_dispatcher_send -REQUIREMENTS = ['satel_integra==0.2.0'] +REQUIREMENTS = ['satel_integra==0.3.2'] DEFAULT_ALARM_NAME = 'satel_integra' DEFAULT_PORT = 7094 @@ -76,7 +72,7 @@ async def async_setup(hass, config): port = conf.get(CONF_DEVICE_PORT) partition = conf.get(CONF_DEVICE_PARTITION) - from satel_integra.satel_integra import AsyncSatel, AlarmState + from satel_integra.satel_integra import AsyncSatel controller = AsyncSatel(host, port, hass.loop, zones, outputs, partition) @@ -96,41 +92,19 @@ async def async_setup(hass, config): conf, conf.get(CONF_ARM_HOME_MODE)) - task_control_panel = hass.async_create_task( + hass.async_create_task( async_load_platform(hass, 'alarm_control_panel', DOMAIN, conf, config)) - task_zones = hass.async_create_task( + hass.async_create_task( async_load_platform(hass, 'binary_sensor', DOMAIN, {CONF_ZONES: zones, CONF_OUTPUTS: outputs}, config) ) - await asyncio.wait([task_control_panel, task_zones], loop=hass.loop) - @callback - def alarm_status_update_callback(status): + def alarm_status_update_callback(): """Send status update received from alarm to home assistant.""" - _LOGGER.debug("Alarm status callback, status: %s", status) - hass_alarm_status = STATE_ALARM_DISARMED - - if status == AlarmState.ARMED_MODE0: - hass_alarm_status = STATE_ALARM_ARMED_AWAY - - elif status in [ - AlarmState.ARMED_MODE0, - AlarmState.ARMED_MODE1, - AlarmState.ARMED_MODE2, - AlarmState.ARMED_MODE3 - ]: - hass_alarm_status = STATE_ALARM_ARMED_HOME - - elif status in [AlarmState.TRIGGERED, AlarmState.TRIGGERED_FIRE]: - hass_alarm_status = STATE_ALARM_TRIGGERED - - elif status == AlarmState.DISARMED: - hass_alarm_status = STATE_ALARM_DISARMED - - _LOGGER.debug("Sending hass_alarm_status: %s...", hass_alarm_status) - async_dispatcher_send(hass, SIGNAL_PANEL_MESSAGE, hass_alarm_status) + _LOGGER.debug("Sending request to update panel state") + async_dispatcher_send(hass, SIGNAL_PANEL_MESSAGE) @callback def zones_update_callback(status): diff --git a/homeassistant/components/satel_integra/alarm_control_panel.py b/homeassistant/components/satel_integra/alarm_control_panel.py index 360acdb2497..d2d9f473051 100644 --- a/homeassistant/components/satel_integra/alarm_control_panel.py +++ b/homeassistant/components/satel_integra/alarm_control_panel.py @@ -1,12 +1,19 @@ """Support for Satel Integra alarm, using ETHM module.""" +import asyncio import logging +from collections import OrderedDict import homeassistant.components.alarm_control_panel as alarm -from homeassistant.components.satel_integra import ( - CONF_ARM_HOME_MODE, DATA_SATEL, SIGNAL_PANEL_MESSAGE) +from homeassistant.const import ( + STATE_ALARM_ARMED_AWAY, STATE_ALARM_ARMED_HOME, STATE_ALARM_DISARMED, + STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from . import ( + CONF_ARM_HOME_MODE, CONF_DEVICE_PARTITION, DATA_SATEL, + SIGNAL_PANEL_MESSAGE) + _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['satel_integra'] @@ -19,32 +26,73 @@ async def async_setup_platform( return device = SatelIntegraAlarmPanel( - "Alarm Panel", discovery_info.get(CONF_ARM_HOME_MODE)) + "Alarm Panel", + discovery_info.get(CONF_ARM_HOME_MODE), + discovery_info.get(CONF_DEVICE_PARTITION)) + async_add_entities([device]) class SatelIntegraAlarmPanel(alarm.AlarmControlPanel): """Representation of an AlarmDecoder-based alarm panel.""" - def __init__(self, name, arm_home_mode): + def __init__(self, name, arm_home_mode, partition_id): """Initialize the alarm panel.""" self._name = name self._state = None self._arm_home_mode = arm_home_mode + self._partition_id = partition_id async def async_added_to_hass(self): - """Register callbacks.""" + """Update alarm status and register callbacks for future updates.""" + _LOGGER.debug("Starts listening for panel messages") + self._update_alarm_status() async_dispatcher_connect( - self.hass, SIGNAL_PANEL_MESSAGE, self._message_callback) + self.hass, SIGNAL_PANEL_MESSAGE, self._update_alarm_status) @callback - def _message_callback(self, message): - """Handle received messages.""" - if message != self._state: - self._state = message + def _update_alarm_status(self): + """Handle alarm status update.""" + state = self._read_alarm_state() + _LOGGER.debug("Got status update, current status: %s", state) + if state != self._state: + self._state = state self.async_schedule_update_ha_state() else: - _LOGGER.warning("Ignoring alarm status message, same state") + _LOGGER.debug("Ignoring alarm status message, same state") + + def _read_alarm_state(self): + """Read current status of the alarm and translate it into HA status.""" + from satel_integra.satel_integra import AlarmState + + # Default - disarmed: + hass_alarm_status = STATE_ALARM_DISARMED + + satel_controller = self.hass.data[DATA_SATEL] + if not satel_controller.connected: + return None + + state_map = OrderedDict([ + (AlarmState.TRIGGERED, STATE_ALARM_TRIGGERED), + (AlarmState.TRIGGERED_FIRE, STATE_ALARM_TRIGGERED), + (AlarmState.ARMED_MODE3, STATE_ALARM_ARMED_HOME), + (AlarmState.ARMED_MODE2, STATE_ALARM_ARMED_HOME), + (AlarmState.ARMED_MODE1, STATE_ALARM_ARMED_HOME), + (AlarmState.ARMED_MODE0, STATE_ALARM_ARMED_AWAY), + (AlarmState.EXIT_COUNTDOWN_OVER_10, STATE_ALARM_PENDING), + (AlarmState.EXIT_COUNTDOWN_UNDER_10, STATE_ALARM_PENDING) + ]) + _LOGGER.debug("State map of Satel: %s", + satel_controller.partition_states) + + for satel_state, ha_state in state_map.items(): + if satel_state in satel_controller.partition_states and\ + self._partition_id in\ + satel_controller.partition_states[satel_state]: + hass_alarm_status = ha_state + break + + return hass_alarm_status @property def name(self): @@ -68,16 +116,32 @@ class SatelIntegraAlarmPanel(alarm.AlarmControlPanel): async def async_alarm_disarm(self, code=None): """Send disarm command.""" - if code: - await self.hass.data[DATA_SATEL].disarm(code) + if not code: + _LOGGER.debug("Code was empty or None") + return + + clear_alarm_necessary = self._state == STATE_ALARM_TRIGGERED + + _LOGGER.debug("Disarming, self._state: %s", self._state) + + await self.hass.data[DATA_SATEL].disarm(code) + + if clear_alarm_necessary: + # Wait 1s before clearing the alarm + await asyncio.sleep(1) + await self.hass.data[DATA_SATEL].clear_alarm(code) async def async_alarm_arm_away(self, code=None): """Send arm away command.""" + _LOGGER.debug("Arming away") + if code: await self.hass.data[DATA_SATEL].arm(code) async def async_alarm_arm_home(self, code=None): """Send arm home command.""" + _LOGGER.debug("Arming home") + if code: await self.hass.data[DATA_SATEL].arm( code, self._arm_home_mode) diff --git a/homeassistant/components/satel_integra/binary_sensor.py b/homeassistant/components/satel_integra/binary_sensor.py index 34ced628712..0384ff37f14 100644 --- a/homeassistant/components/satel_integra/binary_sensor.py +++ b/homeassistant/components/satel_integra/binary_sensor.py @@ -2,15 +2,13 @@ import logging from homeassistant.components.binary_sensor import BinarySensorDevice -from homeassistant.components.satel_integra import (CONF_ZONES, - CONF_OUTPUTS, - CONF_ZONE_NAME, - CONF_ZONE_TYPE, - SIGNAL_ZONES_UPDATED, - SIGNAL_OUTPUTS_UPDATED) from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from . import ( + CONF_OUTPUTS, CONF_ZONE_NAME, CONF_ZONE_TYPE, CONF_ZONES, DATA_SATEL, + SIGNAL_OUTPUTS_UPDATED, SIGNAL_ZONES_UPDATED) + DEPENDENCIES = ['satel_integra'] _LOGGER = logging.getLogger(__name__) @@ -58,6 +56,18 @@ class SatelIntegraBinarySensor(BinarySensorDevice): async def async_added_to_hass(self): """Register callbacks.""" + if self._react_to_signal == SIGNAL_OUTPUTS_UPDATED: + if self._device_number in\ + self.hass.data[DATA_SATEL].violated_outputs: + self._state = 1 + else: + self._state = 0 + else: + if self._device_number in\ + self.hass.data[DATA_SATEL].violated_zones: + self._state = 1 + else: + self._state = 0 async_dispatcher_connect( self.hass, self._react_to_signal, self._devices_updated) diff --git a/requirements_all.txt b/requirements_all.txt index 207097b2bdc..c03af9f9cc1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1532,7 +1532,7 @@ rxv==0.6.0 samsungctl[websocket]==0.7.1 # homeassistant.components.satel_integra -satel_integra==0.2.0 +satel_integra==0.3.2 # homeassistant.components.sensor.deutsche_bahn schiene==0.22 From a85119ac0926a01cde55c8f44c98ff99a1a7bcff Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Wed, 6 Mar 2019 21:47:56 -0800 Subject: [PATCH 112/291] Fix pylint warning on python 3.7 (#21714) --- homeassistant/components/climate/eq3btsmart.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/climate/eq3btsmart.py b/homeassistant/components/climate/eq3btsmart.py index c7c5973fb86..43a26c27ce1 100644 --- a/homeassistant/components/climate/eq3btsmart.py +++ b/homeassistant/components/climate/eq3btsmart.py @@ -184,7 +184,8 @@ class EQ3BTSmartThermostat(ClimateDevice): def update(self): """Update the data from the thermostat.""" - from bluepy.btle import BTLEException # pylint: disable=import-error + # pylint: disable=import-error,no-name-in-module + from bluepy.btle import BTLEException try: self._thermostat.update() except BTLEException as ex: From 38a93afa66e88a4b86ff9c9b2ef8403a8f2a2093 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Wed, 6 Mar 2019 22:27:46 -0800 Subject: [PATCH 113/291] Make pytest in tox quite (#21727) --- tox.ini | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tox.ini b/tox.ini index 471deea0253..8423141df60 100644 --- a/tox.ini +++ b/tox.ini @@ -5,7 +5,7 @@ skip_missing_interpreters = True [testenv] basepython = {env:PYTHON3_PATH:python3} commands = - pytest --timeout=9 --duration=10 {posargs} + pytest --timeout=9 --duration=10 -qq -o console_output_style=count -p no:sugar {posargs} {toxinidir}/script/check_dirty deps = -r{toxinidir}/requirements_test_all.txt @@ -13,7 +13,7 @@ deps = [testenv:cov] commands = - pytest --timeout=9 --duration=10 --cov --cov-report= {posargs} + pytest --timeout=9 --duration=10 -qq -o console_output_style=count -p no:sugar --cov --cov-report= {posargs} {toxinidir}/script/check_dirty deps = -r{toxinidir}/requirements_test_all.txt From 9c70b00403b17a050975f93237042cbaa79e98c4 Mon Sep 17 00:00:00 2001 From: Leonardo Merza Date: Thu, 7 Mar 2019 04:54:09 -0500 Subject: [PATCH 114/291] tplink - catch SmartDeviceException on is_dimmable call (#21726) * automated commit 06/03/2019 20:49:50 * automated commit 06/03/2019 20:53:13 * automated commit 06/03/2019 20:53:48 --- homeassistant/components/tplink/__init__.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tplink/__init__.py b/homeassistant/components/tplink/__init__.py index bc285150890..9fc12db0d63 100644 --- a/homeassistant/components/tplink/__init__.py +++ b/homeassistant/components/tplink/__init__.py @@ -107,10 +107,15 @@ async def async_setup_entry(hass, config_entry): def _fill_device_lists(): for dev in devices.values(): if isinstance(dev, SmartPlug): - if dev.is_dimmable: # Dimmers act as lights - lights.append(dev) - else: - switches.append(dev) + try: + if dev.is_dimmable: # Dimmers act as lights + lights.append(dev) + else: + switches.append(dev) + except SmartDeviceException as ex: + _LOGGER.error("Unable to connect to device %s: %s", + dev.host, ex) + elif isinstance(dev, SmartBulb): lights.append(dev) else: From a46458d04f19ab1f23298b62bec989f5e59981da Mon Sep 17 00:00:00 2001 From: Markus Jankowski Date: Thu, 7 Mar 2019 11:07:32 +0100 Subject: [PATCH 115/291] Fix Name of Homematic IP accesspoint in devices, if name is configured (#21617) * Fix Name of Accesspoint if name is configured * fix lint * Simplyfied naming * applied suggestion Co-Authored-By: SukramJ * update comment --- homeassistant/components/homematicip_cloud/__init__.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homematicip_cloud/__init__.py b/homeassistant/components/homematicip_cloud/__init__.py index 7bc5f33d42f..ac93ef05b85 100644 --- a/homeassistant/components/homematicip_cloud/__init__.py +++ b/homeassistant/components/homematicip_cloud/__init__.py @@ -60,11 +60,14 @@ async def async_setup_entry(hass, entry): # Register hap as device in registry. device_registry = await dr.async_get_registry(hass) home = hap.home + # Add the HAP name from configuration if set. + hapname = home.label \ + if not home.name else "{} {}".format(home.label, home.name) device_registry.async_get_or_create( config_entry_id=home.id, identifiers={(DOMAIN, home.id)}, manufacturer='eQ-3', - name=home.label, + name=hapname, model=home.modelType, sw_version=home.currentAPVersion, ) From 1891d5bf226db58d86033220ec1937bd15f6c723 Mon Sep 17 00:00:00 2001 From: Markus Jankowski Date: Thu, 7 Mar 2019 11:12:03 +0100 Subject: [PATCH 116/291] Fix group-switch availability for Homematic IP (#21640) * Add available=True to groups * Added unreach to stateattributes * Fixed comments * added missing sabotage check * added missing lowBat check * fix typo * apply suggestion Co-Authored-By: SukramJ * apply suggestion Co-Authored-By: SukramJ * applied suggiestions * readded lost str() * fix comment --- .../homematicip_cloud/binary_sensor.py | 45 ++++++++++++------- .../components/homematicip_cloud/device.py | 1 + .../components/homematicip_cloud/switch.py | 23 +++++++++- 3 files changed, 51 insertions(+), 18 deletions(-) diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index 4b82a500bde..d6ce4152001 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -4,6 +4,8 @@ import logging from homeassistant.components.binary_sensor import BinarySensorDevice from homeassistant.components.homematicip_cloud import ( DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice) +from homeassistant.components.homematicip_cloud.device import ( + ATTR_GROUP_MEMBER_UNREACHABLE) DEPENDENCIES = ['homematicip_cloud'] @@ -31,8 +33,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): AsyncWaterSensor, AsyncRotaryHandleSensor, AsyncMotionDetectorPushButton) - from homematicip.group import ( - SecurityGroup, SecurityZoneGroup) + from homematicip.aio.group import ( + AsyncSecurityGroup, AsyncSecurityZoneGroup) home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [] @@ -48,9 +50,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): devices.append(HomematicipWaterDetector(home, device)) for group in home.groups: - if isinstance(group, SecurityGroup): + if isinstance(group, AsyncSecurityGroup): devices.append(HomematicipSecuritySensorGroup(home, group)) - elif isinstance(group, SecurityZoneGroup): + elif isinstance(group, AsyncSecurityZoneGroup): devices.append(HomematicipSecurityZoneSensorGroup(home, group)) if devices: @@ -137,27 +139,37 @@ class HomematicipSecurityZoneSensorGroup(HomematicipGenericDevice, """Return the class of this sensor.""" return 'safety' + @property + def available(self): + """Security-Group available.""" + # A security-group must be available, and should not be affected by + # the individual availability of group members. + return True + @property def device_state_attributes(self): """Return the state attributes of the security zone group.""" attr = super().device_state_attributes if self._device.motionDetected: - attr.update({ATTR_MOTIONDETECTED: True}) + attr[ATTR_MOTIONDETECTED] = True if self._device.presenceDetected: - attr.update({ATTR_PRESENCEDETECTED: True}) + attr[ATTR_PRESENCEDETECTED] = True from homematicip.base.enums import WindowState if self._device.windowState is not None and \ self._device.windowState != WindowState.CLOSED: - attr.update({ATTR_WINDOWSTATE: str(self._device.windowState)}) - + attr[ATTR_WINDOWSTATE] = str(self._device.windowState) + if self._device.unreach: + attr[ATTR_GROUP_MEMBER_UNREACHABLE] = True return attr @property def is_on(self): """Return true if security issue detected.""" if self._device.motionDetected or \ - self._device.presenceDetected: + self._device.presenceDetected or \ + self._device.unreach or \ + self._device.sabotage: return True from homematicip.base.enums import WindowState if self._device.windowState is not None and \ @@ -180,29 +192,30 @@ class HomematicipSecuritySensorGroup(HomematicipSecurityZoneSensorGroup, attr = super().device_state_attributes if self._device.powerMainsFailure: - attr.update({ATTR_POWERMAINSFAILURE: True}) + attr[ATTR_POWERMAINSFAILURE] = True if self._device.moistureDetected: - attr.update({ATTR_MOISTUREDETECTED: True}) + attr[ATTR_MOISTUREDETECTED] = True if self._device.waterlevelDetected: - attr.update({ATTR_WATERLEVELDETECTED: True}) + attr[ATTR_WATERLEVELDETECTED] = True from homematicip.base.enums import SmokeDetectorAlarmType if self._device.smokeDetectorAlarmType is not None and \ self._device.smokeDetectorAlarmType != \ SmokeDetectorAlarmType.IDLE_OFF: - attr.update({ATTR_SMOKEDETECTORALARM: str( - self._device.smokeDetectorAlarmType)}) + attr[ATTR_SMOKEDETECTORALARM] = \ + str(self._device.smokeDetectorAlarmType) return attr @property def is_on(self): - """Return true if security issue detected.""" + """Return true if safety issue detected.""" parent_is_on = super().is_on from homematicip.base.enums import SmokeDetectorAlarmType if parent_is_on or \ self._device.powerMainsFailure or \ self._device.moistureDetected or \ - self._device.waterlevelDetected: + self._device.waterlevelDetected or \ + self._device.lowBat: return True if self._device.smokeDetectorAlarmType is not None and \ self._device.smokeDetectorAlarmType != \ diff --git a/homeassistant/components/homematicip_cloud/device.py b/homeassistant/components/homematicip_cloud/device.py index 85cc3c0c77a..9940e6960db 100644 --- a/homeassistant/components/homematicip_cloud/device.py +++ b/homeassistant/components/homematicip_cloud/device.py @@ -21,6 +21,7 @@ ATTR_OPERATION_LOCK = 'operation_lock' ATTR_SABOTAGE = 'sabotage' ATTR_STATUS_UPDATE = 'status_update' ATTR_UNREACHABLE = 'unreachable' +ATTR_GROUP_MEMBER_UNREACHABLE = 'group_member_unreachable' class HomematicipGenericDevice(Entity): diff --git a/homeassistant/components/homematicip_cloud/switch.py b/homeassistant/components/homematicip_cloud/switch.py index f129febb5e7..74f50f87b25 100644 --- a/homeassistant/components/homematicip_cloud/switch.py +++ b/homeassistant/components/homematicip_cloud/switch.py @@ -3,6 +3,8 @@ import logging from homeassistant.components.homematicip_cloud import ( DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice) +from homeassistant.components.homematicip_cloud.device import ( + ATTR_GROUP_MEMBER_UNREACHABLE) from homeassistant.components.switch import SwitchDevice DEPENDENCIES = ['homematicip_cloud'] @@ -30,7 +32,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): AsyncOpenCollector8Module, ) - from homematicip.group import SwitchingGroup + from homematicip.aio.group import AsyncSwitchingGroup home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [] @@ -50,7 +52,7 @@ async def async_setup_entry(hass, config_entry, async_add_entities): devices.append(HomematicipMultiSwitch(home, device, channel)) for group in home.groups: - if isinstance(group, SwitchingGroup): + if isinstance(group, AsyncSwitchingGroup): devices.append( HomematicipGroupSwitch(home, group)) @@ -92,6 +94,23 @@ class HomematicipGroupSwitch(HomematicipGenericDevice, SwitchDevice): """Return true if group is on.""" return self._device.on + @property + def available(self): + """Switch-Group available.""" + # A switch-group must be available, and should not be affected by the + # individual availability of group members. + # This allows switching even when individual group members + # are not available. + return True + + @property + def device_state_attributes(self): + """Return the state attributes of the switch-group.""" + attr = {} + if self._device.unreach: + attr[ATTR_GROUP_MEMBER_UNREACHABLE] = True + return attr + async def async_turn_on(self, **kwargs): """Turn the group on.""" await self._device.turn_on() From ba70459e1e33acf42f19b47db68eda597f17e8f4 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Thu, 7 Mar 2019 03:29:15 -0800 Subject: [PATCH 117/291] Remove pytest warning message (#21713) --- tests/util/test_async.py | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/tests/util/test_async.py b/tests/util/test_async.py index 3e57ea20b5c..8baacec5dca 100644 --- a/tests/util/test_async.py +++ b/tests/util/test_async.py @@ -1,5 +1,6 @@ """Tests for async util methods from Python source.""" import asyncio +import sys from unittest.mock import MagicMock, patch from unittest import TestCase @@ -144,7 +145,11 @@ class RunThreadsafeTests(TestCase): """Wait 0.05 second and return a + b.""" yield from asyncio.sleep(0.05, loop=self.loop) if cancel: - asyncio.tasks.Task.current_task(self.loop).cancel() + if sys.version_info[:2] >= (3, 7): + current_task = asyncio.current_task + else: + current_task = asyncio.tasks.Task.current_task + current_task(self.loop).cancel() yield return self.add_callback(a, b, fail, invalid) @@ -205,7 +210,11 @@ class RunThreadsafeTests(TestCase): self.loop.run_until_complete(future) self.run_briefly(self.loop) # Check that there's no pending task (add has been cancelled) - for task in asyncio.Task.all_tasks(self.loop): + if sys.version_info[:2] >= (3, 7): + all_tasks = asyncio.all_tasks + else: + all_tasks = asyncio.Task.all_tasks + for task in all_tasks(self.loop): self.assertTrue(task.done()) def test_run_coroutine_threadsafe_task_cancelled(self): From 02bcf460530e24125c4cd569f4719dc8531ffbe9 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 7 Mar 2019 14:40:18 +0100 Subject: [PATCH 118/291] Update .travis.yml (#21736) * Update .travis.yml * Update tox.ini * Update main.workflow * Update tox.ini --- .github/main.workflow | 10 ++++------ .travis.yml | 9 --------- 2 files changed, 4 insertions(+), 15 deletions(-) diff --git a/.github/main.workflow b/.github/main.workflow index 1c8a94c89f8..02ba24aeded 100644 --- a/.github/main.workflow +++ b/.github/main.workflow @@ -20,9 +20,7 @@ action "Python 3.6 - tests" { workflow "Python 3.5 - tox" { on = "push" - resolves = [ - "Pyton 3.5 - typing,cov", - ] + resolves = ["Pyton 3.5 - typing"] } action "Python 3.5 - tests" { @@ -33,11 +31,11 @@ action "Python 3.5 - tests" { action "Python 3.5 - lints" { uses = "home-assistant/actions/py35-tox@master" needs = ["Python 3.5 - tests"] - args = "-e lint,pylint -p auto --parallel-live" + args = "-e lint" } -action "Pyton 3.5 - typing,cov" { +action "Pyton 3.5 - typing" { uses = "home-assistant/actions/py35-tox@master" - args = "-e typing,cov -p auto --parallel-live" + args = "-e typing" needs = ["Python 3.5 - lints"] } diff --git a/.travis.yml b/.travis.yml index be00f989290..f5d18fb78ef 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,20 +6,11 @@ addons: matrix: fast_finish: true include: - - python: "3.5.3" - env: TOXENV=lint - python: "3.5.3" env: TOXENV=pylint - - python: "3.5.3" - env: TOXENV=typing - python: "3.5.3" env: TOXENV=cov after_success: coveralls - - python: "3.6" - env: TOXENV=py36 - - python: "3.7" - env: TOXENV=py37 - dist: xenial - python: "3.8-dev" env: TOXENV=py38 dist: xenial From 5c2d174d5ffe3d99b7067bb8d119b9924025fbb2 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 7 Mar 2019 15:25:27 +0100 Subject: [PATCH 119/291] Change github trigger type --- .github/main.workflow | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/main.workflow b/.github/main.workflow index 02ba24aeded..05a73485d7b 100644 --- a/.github/main.workflow +++ b/.github/main.workflow @@ -1,6 +1,6 @@ workflow "Python 3.7 - tox" { - on = "push" resolves = ["Python 3.7 - tests"] + on = "pull_request" } action "Python 3.7 - tests" { @@ -9,8 +9,8 @@ action "Python 3.7 - tests" { } workflow "Python 3.6 - tox" { - on = "push" resolves = ["Python 3.6 - tests"] + on = "pull_request" } action "Python 3.6 - tests" { @@ -19,8 +19,8 @@ action "Python 3.6 - tests" { } workflow "Python 3.5 - tox" { - on = "push" resolves = ["Pyton 3.5 - typing"] + on = "pull_request" } action "Python 3.5 - tests" { From 720b0c533438cf3c6616659baf1a4923b07bb073 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 7 Mar 2019 15:30:48 +0100 Subject: [PATCH 120/291] Revert Travis until github actions work better for PR (#21746) --- .travis.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.travis.yml b/.travis.yml index f5d18fb78ef..be00f989290 100644 --- a/.travis.yml +++ b/.travis.yml @@ -6,11 +6,20 @@ addons: matrix: fast_finish: true include: + - python: "3.5.3" + env: TOXENV=lint - python: "3.5.3" env: TOXENV=pylint + - python: "3.5.3" + env: TOXENV=typing - python: "3.5.3" env: TOXENV=cov after_success: coveralls + - python: "3.6" + env: TOXENV=py36 + - python: "3.7" + env: TOXENV=py37 + dist: xenial - python: "3.8-dev" env: TOXENV=py38 dist: xenial From 61786b79f744a8004ecec0af65fb2c2c60861cf0 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Thu, 7 Mar 2019 15:33:13 +0100 Subject: [PATCH 121/291] Revert pull request to push --- .github/main.workflow | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/main.workflow b/.github/main.workflow index 05a73485d7b..54869682e1c 100644 --- a/.github/main.workflow +++ b/.github/main.workflow @@ -1,6 +1,6 @@ workflow "Python 3.7 - tox" { resolves = ["Python 3.7 - tests"] - on = "pull_request" + on = "push" } action "Python 3.7 - tests" { @@ -10,7 +10,7 @@ action "Python 3.7 - tests" { workflow "Python 3.6 - tox" { resolves = ["Python 3.6 - tests"] - on = "pull_request" + on = "push" } action "Python 3.6 - tests" { @@ -20,7 +20,7 @@ action "Python 3.6 - tests" { workflow "Python 3.5 - tox" { resolves = ["Pyton 3.5 - typing"] - on = "pull_request" + on = "push" } action "Python 3.5 - tests" { From f2abc91c1e3a5ef44475fe0c1eaf129fb40032d1 Mon Sep 17 00:00:00 2001 From: zewelor Date: Thu, 7 Mar 2019 18:03:30 +0100 Subject: [PATCH 122/291] Allow light toggle service to accept all turn on params (#20912) --- homeassistant/components/light/__init__.py | 5 +---- homeassistant/components/light/services.yaml | 8 ++------ 2 files changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/light/__init__.py b/homeassistant/components/light/__init__.py index ef82167b222..acf95a3c081 100644 --- a/homeassistant/components/light/__init__.py +++ b/homeassistant/components/light/__init__.py @@ -123,10 +123,7 @@ LIGHT_TURN_OFF_SCHEMA = vol.Schema({ ATTR_FLASH: vol.In([FLASH_SHORT, FLASH_LONG]), }) -LIGHT_TOGGLE_SCHEMA = vol.Schema({ - ATTR_ENTITY_ID: cv.comp_entity_ids, - ATTR_TRANSITION: VALID_TRANSITION, -}) +LIGHT_TOGGLE_SCHEMA = LIGHT_TURN_ON_SCHEMA PROFILE_SCHEMA = vol.Schema( vol.ExactSequence((str, cv.small_float, cv.small_float, cv.byte)) diff --git a/homeassistant/components/light/services.yaml b/homeassistant/components/light/services.yaml index 10cbeb42aa4..a2863482477 100644 --- a/homeassistant/components/light/services.yaml +++ b/homeassistant/components/light/services.yaml @@ -68,12 +68,8 @@ turn_off: toggle: description: Toggles a light. fields: - entity_id: - description: Name(s) of entities to toggle. - example: 'light.kitchen' - transition: - description: Duration in seconds it takes to get to next state. - example: 60 + '...': + description: All turn_on parameters can be used. hue_activate_scene: description: Activate a hue scene stored in the hue hub. From fc943dc4b6254bb8666816c78390dad5184bd50b Mon Sep 17 00:00:00 2001 From: Daniel Shokouhi Date: Thu, 7 Mar 2019 11:03:02 -0800 Subject: [PATCH 123/291] Fix botvac connected maps call as it is not a supported model (#21752) --- homeassistant/components/neato/vacuum.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py index ff78a087de8..990c79552b4 100644 --- a/homeassistant/components/neato/vacuum.py +++ b/homeassistant/components/neato/vacuum.py @@ -186,10 +186,11 @@ class NeatoConnectedVacuum(StateVacuumDevice): self._battery_level = self._state['details']['charge'] if self._robot_has_map: - robot_map_id = self._robot_maps[self._robot_serial][0]['id'] + if self._state['availableServices']['maps'] != "basic-1": + robot_map_id = self._robot_maps[self._robot_serial][0]['id'] - self._robot_boundaries = self.robot.get_map_boundaries( - robot_map_id).json() + self._robot_boundaries = self.robot.get_map_boundaries( + robot_map_id).json() @property def name(self): From e356b48fca9ee20133e0d95c4d720497ef39b700 Mon Sep 17 00:00:00 2001 From: Leonardo Merza Date: Thu, 7 Mar 2019 14:03:32 -0500 Subject: [PATCH 124/291] automated commit 07/03/2019 10:47:38 (#21749) --- homeassistant/components/sensor/google_travel_time.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/google_travel_time.py b/homeassistant/components/sensor/google_travel_time.py index 1f4d8425d6e..86b1a7aff44 100644 --- a/homeassistant/components/sensor/google_travel_time.py +++ b/homeassistant/components/sensor/google_travel_time.py @@ -67,7 +67,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ })) }) -TRACKABLE_DOMAINS = ['device_tracker', 'sensor', 'zone'] +TRACKABLE_DOMAINS = ['device_tracker', 'sensor', 'zone', 'person'] DATA_KEY = 'google_travel_time' From c91fb82807e814879487384795e750f8e3732955 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Thu, 7 Mar 2019 11:07:07 -0800 Subject: [PATCH 125/291] Fix colorlog import error (#21754) * Fix colorlog import error * Lint --- homeassistant/scripts/check_config.py | 21 +++++++++++++-------- 1 file changed, 13 insertions(+), 8 deletions(-) diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index 1b8c6719395..cae937102cc 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -41,15 +41,20 @@ ERROR_STR = 'General Errors' def color(the_color, *args, reset=None): """Color helper.""" - from colorlog.escape_codes import escape_codes, parse_colors try: - if not args: - assert reset is None, "You cannot reset if nothing being printed" - return parse_colors(the_color) - return parse_colors(the_color) + ' '.join(args) + \ - escape_codes[reset or 'reset'] - except KeyError as k: - raise ValueError("Invalid color {} in {}".format(str(k), the_color)) + from colorlog.escape_codes import escape_codes, parse_colors + try: + if not args: + assert reset is None, "Cannot reset if nothing being printed" + return parse_colors(the_color) + return parse_colors(the_color) + ' '.join(args) + \ + escape_codes[reset or 'reset'] + except KeyError as k: + raise ValueError( + "Invalid color {} in {}".format(str(k), the_color)) + except ImportError: + # We should fallback to black-and-white if colorlog is not installed + return ' '.join(args) def run(script_args: List) -> int: From 5112f8f6b5b02ec6799f9a27506ceac1b10a88b2 Mon Sep 17 00:00:00 2001 From: Marvin Wichmann Date: Thu, 7 Mar 2019 22:53:42 +0100 Subject: [PATCH 126/291] Introduce target_temperature_state_address for climate device (#21541) --- homeassistant/components/knx/__init__.py | 8 ++++++-- homeassistant/components/knx/climate.py | 8 ++++++-- requirements_all.txt | 2 +- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index fdaba5e5709..ea5b18b7ede 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -11,7 +11,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_state_change from homeassistant.helpers.script import Script -REQUIREMENTS = ['xknx==0.9.4'] +REQUIREMENTS = ['xknx==0.10.0'] _LOGGER = logging.getLogger(__name__) @@ -25,6 +25,7 @@ CONF_KNX_LOCAL_IP = "local_ip" CONF_KNX_FIRE_EVENT = "fire_event" CONF_KNX_FIRE_EVENT_FILTER = "fire_event_filter" CONF_KNX_STATE_UPDATER = "state_updater" +CONF_KNX_RATE_LIMIT = "rate_limit" CONF_KNX_EXPOSE = "expose" CONF_KNX_EXPOSE_TYPE = "type" CONF_KNX_EXPOSE_ADDRESS = "address" @@ -62,6 +63,8 @@ CONFIG_SCHEMA = vol.Schema({ vol.Inclusive(CONF_KNX_FIRE_EVENT_FILTER, 'fire_ev'): vol.All(cv.ensure_list, [cv.string]), vol.Optional(CONF_KNX_STATE_UPDATER, default=True): cv.boolean, + vol.Optional(CONF_KNX_RATE_LIMIT, default=20): + vol.All(vol.Coerce(int), vol.Range(min=1, max=100)), vol.Optional(CONF_KNX_EXPOSE): vol.All( cv.ensure_list, @@ -138,7 +141,8 @@ class KNXModule: def init_xknx(self): """Initialize of KNX object.""" from xknx import XKNX - self.xknx = XKNX(config=self.config_file(), loop=self.hass.loop) + self.xknx = XKNX(config=self.config_file(), loop=self.hass.loop, + rate_limit=self.config[DOMAIN][CONF_KNX_RATE_LIMIT]) async def start(self): """Start KNX object. Connect to tunneling or Routing device.""" diff --git a/homeassistant/components/knx/climate.py b/homeassistant/components/knx/climate.py index 96b9f2ea91f..921b2936d97 100644 --- a/homeassistant/components/knx/climate.py +++ b/homeassistant/components/knx/climate.py @@ -17,6 +17,7 @@ CONF_SETPOINT_SHIFT_MAX = 'setpoint_shift_max' CONF_SETPOINT_SHIFT_MIN = 'setpoint_shift_min' CONF_TEMPERATURE_ADDRESS = 'temperature_address' CONF_TARGET_TEMPERATURE_ADDRESS = 'target_temperature_address' +CONF_TARGET_TEMPERATURE_STATE_ADDRESS = 'target_temperature_state_address' CONF_OPERATION_MODE_ADDRESS = 'operation_mode_address' CONF_OPERATION_MODE_STATE_ADDRESS = 'operation_mode_state_address' CONF_CONTROLLER_STATUS_ADDRESS = 'controller_status_address' @@ -57,7 +58,8 @@ OPERATION_MODES_INV = dict(( PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Required(CONF_TEMPERATURE_ADDRESS): cv.string, - vol.Required(CONF_TARGET_TEMPERATURE_ADDRESS): cv.string, + vol.Required(CONF_TARGET_TEMPERATURE_STATE_ADDRESS): cv.string, + vol.Optional(CONF_TARGET_TEMPERATURE_ADDRESS): cv.string, vol.Optional(CONF_SETPOINT_SHIFT_ADDRESS): cv.string, vol.Optional(CONF_SETPOINT_SHIFT_STATE_ADDRESS): cv.string, vol.Optional(CONF_SETPOINT_SHIFT_STEP, @@ -136,9 +138,11 @@ def async_add_entities_config(hass, config, async_add_entities): climate = xknx.devices.Climate( hass.data[DATA_KNX].xknx, name=config.get(CONF_NAME), - group_address_temperature=config.get(CONF_TEMPERATURE_ADDRESS), + group_address_temperature=config[CONF_TEMPERATURE_ADDRESS], group_address_target_temperature=config.get( CONF_TARGET_TEMPERATURE_ADDRESS), + group_address_target_temperature_state=config[ + CONF_TARGET_TEMPERATURE_STATE_ADDRESS], group_address_setpoint_shift=config.get(CONF_SETPOINT_SHIFT_ADDRESS), group_address_setpoint_shift_state=config.get( CONF_SETPOINT_SHIFT_STATE_ADDRESS), diff --git a/requirements_all.txt b/requirements_all.txt index c03af9f9cc1..fd1d24a7200 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1782,7 +1782,7 @@ xboxapi==0.1.1 xfinity-gateway==0.0.4 # homeassistant.components.knx -xknx==0.9.4 +xknx==0.10.0 # homeassistant.components.media_player.bluesound # homeassistant.components.sensor.startca From cfd94ecbbc493be80d080f7fd17ab198a83f20a8 Mon Sep 17 00:00:00 2001 From: Sebastian Muszynski Date: Thu, 7 Mar 2019 22:55:11 +0100 Subject: [PATCH 127/291] Bump PyXiaomiGateway version to 0.12.2 (Closes: #21731) (#21764) --- homeassistant/components/xiaomi_aqara/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/xiaomi_aqara/__init__.py b/homeassistant/components/xiaomi_aqara/__init__.py index 19d7aaaa30d..66fc1fa13dd 100644 --- a/homeassistant/components/xiaomi_aqara/__init__.py +++ b/homeassistant/components/xiaomi_aqara/__init__.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity import Entity from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.util.dt import utcnow -REQUIREMENTS = ['PyXiaomiGateway==0.12.0'] +REQUIREMENTS = ['PyXiaomiGateway==0.12.2'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index fd1d24a7200..9b86dfa04d6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -67,7 +67,7 @@ PyRMVtransport==0.1.3 PyTransportNSW==0.1.1 # homeassistant.components.xiaomi_aqara -PyXiaomiGateway==0.12.0 +PyXiaomiGateway==0.12.2 # homeassistant.components.rpi_gpio # RPi.GPIO==0.6.5 From a843da1bfcedf8e39a825030dac242dcea65a0b8 Mon Sep 17 00:00:00 2001 From: William Scanlon Date: Thu, 7 Mar 2019 19:46:01 -0500 Subject: [PATCH 128/291] Updated to newest pyeconet (#21772) --- homeassistant/components/water_heater/econet.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/water_heater/econet.py b/homeassistant/components/water_heater/econet.py index 69fde44bdd2..efc21798859 100644 --- a/homeassistant/components/water_heater/econet.py +++ b/homeassistant/components/water_heater/econet.py @@ -13,7 +13,7 @@ from homeassistant.const import ( TEMP_FAHRENHEIT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyeconet==0.0.8'] +REQUIREMENTS = ['pyeconet==0.0.9'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 9b86dfa04d6..84083ba7e9a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1004,7 +1004,7 @@ pydukeenergy==0.0.6 pyebox==1.1.4 # homeassistant.components.water_heater.econet -pyeconet==0.0.8 +pyeconet==0.0.9 # homeassistant.components.switch.edimax pyedimax==0.1 From 2fb978393b0218da821afecada007676a9c0c5d0 Mon Sep 17 00:00:00 2001 From: David Thulke Date: Fri, 8 Mar 2019 01:46:50 +0100 Subject: [PATCH 129/291] adds missing SUPPORT_VOLUME_SET flag to webos media_player (#21766) --- homeassistant/components/webostv/media_player.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/webostv/media_player.py b/homeassistant/components/webostv/media_player.py index a6cbfbae99d..35c3c456680 100644 --- a/homeassistant/components/webostv/media_player.py +++ b/homeassistant/components/webostv/media_player.py @@ -14,7 +14,7 @@ from homeassistant.components.media_player.const import ( MEDIA_TYPE_CHANNEL, SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PLAY_MEDIA, SUPPORT_PREVIOUS_TRACK, SUPPORT_SELECT_SOURCE, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, - SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP) + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_VOLUME_STEP) from homeassistant.const import ( CONF_CUSTOMIZE, CONF_FILENAME, CONF_HOST, CONF_NAME, CONF_TIMEOUT, STATE_OFF, STATE_PAUSED, STATE_PLAYING) @@ -36,7 +36,7 @@ WEBOSTV_CONFIG_FILE = 'webostv.conf' SUPPORT_WEBOSTV = SUPPORT_TURN_OFF | \ SUPPORT_NEXT_TRACK | SUPPORT_PAUSE | SUPPORT_PREVIOUS_TRACK | \ - SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_STEP | \ + SUPPORT_VOLUME_MUTE | SUPPORT_VOLUME_SET | SUPPORT_VOLUME_STEP | \ SUPPORT_SELECT_SOURCE | SUPPORT_PLAY_MEDIA | SUPPORT_PLAY MIN_TIME_BETWEEN_SCANS = timedelta(seconds=10) From 78a806bc5cea473beaa85bb2ad69cb9ce36ac23f Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Thu, 7 Mar 2019 16:48:14 -0800 Subject: [PATCH 130/291] Fix script load module issue (#21763) * Fix script load depedency * Revert #21754 --- homeassistant/scripts/__init__.py | 9 ++------- homeassistant/scripts/check_config.py | 21 ++++++++------------- 2 files changed, 10 insertions(+), 20 deletions(-) diff --git a/homeassistant/scripts/__init__.py b/homeassistant/scripts/__init__.py index 3050379a496..070d907a7d9 100644 --- a/homeassistant/scripts/__init__.py +++ b/homeassistant/scripts/__init__.py @@ -52,15 +52,10 @@ def run(args: List) -> int: hass = HomeAssistant(loop) pkgload = PackageLoadable(hass) for req in getattr(script, 'REQUIREMENTS', []): - try: - loop.run_until_complete(pkgload.loadable(req)) + if loop.run_until_complete(pkgload.loadable(req)): continue - except ImportError: - pass - returncode = install_package(req, **_pip_kwargs) - - if not returncode: + if not install_package(req, **_pip_kwargs): print('Aborting script, could not install dependency', req) return 1 diff --git a/homeassistant/scripts/check_config.py b/homeassistant/scripts/check_config.py index cae937102cc..1b8c6719395 100644 --- a/homeassistant/scripts/check_config.py +++ b/homeassistant/scripts/check_config.py @@ -41,20 +41,15 @@ ERROR_STR = 'General Errors' def color(the_color, *args, reset=None): """Color helper.""" + from colorlog.escape_codes import escape_codes, parse_colors try: - from colorlog.escape_codes import escape_codes, parse_colors - try: - if not args: - assert reset is None, "Cannot reset if nothing being printed" - return parse_colors(the_color) - return parse_colors(the_color) + ' '.join(args) + \ - escape_codes[reset or 'reset'] - except KeyError as k: - raise ValueError( - "Invalid color {} in {}".format(str(k), the_color)) - except ImportError: - # We should fallback to black-and-white if colorlog is not installed - return ' '.join(args) + if not args: + assert reset is None, "You cannot reset if nothing being printed" + return parse_colors(the_color) + return parse_colors(the_color) + ' '.join(args) + \ + escape_codes[reset or 'reset'] + except KeyError as k: + raise ValueError("Invalid color {} in {}".format(str(k), the_color)) def run(script_args: List) -> int: From 2812483193e27ef6e35f270d56ff1020c073e04d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Fri, 8 Mar 2019 09:13:35 +0200 Subject: [PATCH 131/291] Upgrade pylint to 2.3.1 (#21789) --- requirements_test.txt | 2 +- requirements_test_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements_test.txt b/requirements_test.txt index 531fb0b78f6..9aa5d7d5c91 100644 --- a/requirements_test.txt +++ b/requirements_test.txt @@ -8,7 +8,7 @@ flake8==3.7.7 mock-open==1.3.1 mypy==0.670 pydocstyle==3.0.0 -pylint==2.3.0 +pylint==2.3.1 pytest-aiohttp==0.3.0 pytest-cov==2.6.1 pytest-sugar==0.9.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 38059af2538..dddef010a9a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -9,7 +9,7 @@ flake8==3.7.7 mock-open==1.3.1 mypy==0.670 pydocstyle==3.0.0 -pylint==2.3.0 +pylint==2.3.1 pytest-aiohttp==0.3.0 pytest-cov==2.6.1 pytest-sugar==0.9.2 From f705ac6b43af601af41c32fb11b6da0859ed70cb Mon Sep 17 00:00:00 2001 From: Jose Motta Lopes Date: Fri, 8 Mar 2019 04:21:22 -0300 Subject: [PATCH 132/291] Add Time of Flight Sensor using VL53L1X (#21230) * Add Time of Flight Sensor using VL53L1X * Fix issues found by bot * Fix issues from bot * Remove extra logs * Keep removing logs dependencies not used * Remove log from update * Add logger info to async_update * Fix over-indented line * Fix pylint error * Remove logger reporting successful operation * Update requirements * Update requirements_all.txt * Update requirements_test_all.txt * Used isort to keep imports and added STMicroelectronics to docstring * Replace time.sleep by asyncio.sleep * Add requirements to COMMENT_REQUIREMENTS and fix typo * Using async_add_executor_job to schedule the call in the pool * Fix typo * Optimize async_update * Updated requirements files * Group and schedule calls that should be run sequentially * Fix lint errors * Revision showing development history * Cleaning and typos * Cleaning and typos * Fix wrong-import-order * Fix gen_requirements_all * Schedule rpi_gpio I/O on the executor thread pool * Fix partial parameters * Fix bot error - add blank line * Fix lint error * Remove dependencies from requirements * Review initial commits * Move all device I/O to async_update * Update requirements_all.txt * Revised header with no url to the docs * Use async_added_to_hass to add and initialize the sensor * Add docstring to init() * Move sensor.open() to async_setup_platform * Remove logging and async * Fix typo * Move sensor.open to safe initialization * Fix typo * Fix typo * Add the new tof module to .coveragerc * Move the sensor platform under a tof package * Update .coveragerc and requirements_all for tof package --- .coveragerc | 1 + homeassistant/components/tof/__init__.py | 1 + homeassistant/components/tof/sensor.py | 125 +++++++++++++++++++++++ requirements_all.txt | 3 + script/gen_requirements_all.py | 1 + 5 files changed, 131 insertions(+) create mode 100644 homeassistant/components/tof/__init__.py create mode 100644 homeassistant/components/tof/sensor.py diff --git a/.coveragerc b/.coveragerc index 8ccf59dddc9..a4f040696fa 100644 --- a/.coveragerc +++ b/.coveragerc @@ -633,6 +633,7 @@ omit = homeassistant/components/thingspeak/* homeassistant/components/thinkingcleaner/* homeassistant/components/tibber/* + homeassistant/components/tof/sensor.py homeassistant/components/toon/* homeassistant/components/tplink_lte/* homeassistant/components/tradfri/* diff --git a/homeassistant/components/tof/__init__.py b/homeassistant/components/tof/__init__.py new file mode 100644 index 00000000000..0e72aca724b --- /dev/null +++ b/homeassistant/components/tof/__init__.py @@ -0,0 +1 @@ +"""Platform for Time of Flight sensor VL53L1X from STMicroelectronics.""" diff --git a/homeassistant/components/tof/sensor.py b/homeassistant/components/tof/sensor.py new file mode 100644 index 00000000000..a403db03682 --- /dev/null +++ b/homeassistant/components/tof/sensor.py @@ -0,0 +1,125 @@ +"""Platform for Time of Flight sensor VL53L1X from STMicroelectronics.""" + +import asyncio +import logging +from functools import partial + +import voluptuous as vol + +from homeassistant.components.sensor import PLATFORM_SCHEMA +import homeassistant.helpers.config_validation as cv +from homeassistant.components import rpi_gpio +from homeassistant.const import CONF_NAME +from homeassistant.helpers.entity import Entity + +REQUIREMENTS = ['VL53L1X2==0.1.5'] + +DEPENDENCIES = ['rpi_gpio'] + +_LOGGER = logging.getLogger(__name__) + +LENGTH_MILLIMETERS = 'mm' + +CONF_I2C_ADDRESS = 'i2c_address' +CONF_I2C_BUS = 'i2c_bus' +CONF_XSHUT = 'xshut' + +DEFAULT_NAME = 'VL53L1X' +DEFAULT_I2C_ADDRESS = 0x29 +DEFAULT_I2C_BUS = 1 +DEFAULT_XSHUT = 16 +DEFAULT_RANGE = 2 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, + default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_I2C_ADDRESS, + default=DEFAULT_I2C_ADDRESS): vol.Coerce(int), + vol.Optional(CONF_I2C_BUS, + default=DEFAULT_I2C_BUS): vol.Coerce(int), + vol.Optional(CONF_XSHUT, + default=DEFAULT_XSHUT): cv.positive_int, +}) + + +def init_tof_0(xshut, sensor): + """XSHUT port LOW resets the device.""" + sensor.open() + rpi_gpio.setup_output(xshut) + rpi_gpio.write_output(xshut, 0) + + +def init_tof_1(xshut): + """XSHUT port HIGH enables the device.""" + rpi_gpio.setup_output(xshut) + rpi_gpio.write_output(xshut, 1) + + +async def async_setup_platform(hass, + config, + async_add_entities, + discovery_info=None): + """Reset and initialize the VL53L1X ToF Sensor from STMicroelectronics.""" + from VL53L1X2 import VL53L1X # pylint: disable=import-error + + name = config.get(CONF_NAME) + bus_number = config.get(CONF_I2C_BUS) + i2c_address = config.get(CONF_I2C_ADDRESS) + unit = LENGTH_MILLIMETERS + xshut = config.get(CONF_XSHUT) + + sensor = await hass.async_add_executor_job( + partial(VL53L1X, bus_number) + ) + await hass.async_add_executor_job( + init_tof_0, xshut, sensor + ) + await asyncio.sleep(0.01) + await hass.async_add_executor_job( + init_tof_1, xshut + ) + await asyncio.sleep(0.01) + + dev = [VL53L1XSensor(sensor, name, unit, i2c_address)] + + async_add_entities(dev, True) + + +class VL53L1XSensor(Entity): + """Implementation of VL53L1X sensor.""" + + def __init__(self, vl53l1x_sensor, name, unit, i2c_address): + """Initialize the sensor.""" + self._name = name + self._unit_of_measurement = unit + self.vl53l1x_sensor = vl53l1x_sensor + self.i2c_address = i2c_address + self._state = None + self.init = True + + @property + def name(self) -> str: + """Return the name of the sensor.""" + return self._name + + @property + def state(self) -> int: + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self) -> str: + """Return the unit of measurement.""" + return self._unit_of_measurement + + def update(self): + """Get the latest measurement and update state.""" + if self.init: + self.vl53l1x_sensor.add_sensor( + self.i2c_address, self.i2c_address) + self.init = False + self.vl53l1x_sensor.start_ranging( + self.i2c_address, DEFAULT_RANGE) + self.vl53l1x_sensor.update(self.i2c_address) + self.vl53l1x_sensor.stop_ranging(self.i2c_address) + self._state = self.vl53l1x_sensor.distance diff --git a/requirements_all.txt b/requirements_all.txt index 84083ba7e9a..21fd7de5fb4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -81,6 +81,9 @@ TravisPy==0.3.5 # homeassistant.components.notify.twitter TwitterAPI==2.5.9 +# homeassistant.components.tof.sensor +# VL53L1X2==0.1.5 + # homeassistant.components.sensor.waze_travel_time WazeRouteCalculator==0.9 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 7db76b1361b..25f7fbfc419 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -24,6 +24,7 @@ COMMENT_REQUIREMENTS = ( 'i2csense', 'opencv-python', 'py_noaa', + 'VL53L1X2', 'pybluez', 'pycups', 'PySwitchbot', From dfd9f7ccf3ae3e25280529c500b43acfaad1f77b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Fri, 8 Mar 2019 10:28:53 +0200 Subject: [PATCH 133/291] Upgrade huawei-lte-api to 1.1.5 (#21791) --- homeassistant/components/huawei_lte/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 2ff21c4d5a7..a462b1b3072 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -19,7 +19,7 @@ _LOGGER = logging.getLogger(__name__) # https://github.com/quandyfactory/dicttoxml/issues/60 logging.getLogger('dicttoxml').setLevel(logging.WARNING) -REQUIREMENTS = ['huawei-lte-api==1.1.3'] +REQUIREMENTS = ['huawei-lte-api==1.1.5'] MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=10) diff --git a/requirements_all.txt b/requirements_all.txt index 21fd7de5fb4..b8aabafba19 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -558,7 +558,7 @@ homematicip==0.10.6 httplib2==0.10.3 # homeassistant.components.huawei_lte -huawei-lte-api==1.1.3 +huawei-lte-api==1.1.5 # homeassistant.components.hydrawise hydrawiser==0.1.1 From 4571f1bf0d4ac3f86ff2cc4075d85886f280b382 Mon Sep 17 00:00:00 2001 From: Finbarr Brady Date: Fri, 8 Mar 2019 13:56:10 +0000 Subject: [PATCH 134/291] Adding enigma2 media player (#21271) * Updated based on review comments * fix hound * Update homeassistant/components/media_player/enigma2.py * Update homeassistant/components/media_player/enigma2.py * Update homeassistant/components/media_player/enigma2.py * Update enigma2.py * Update enigma2.py * Move file and update docsstring * Fix path in coverage rc file * requirements * Update media_player.py * Setup discovery for e2 * Handle discovered devices * Add reqs * Update for auth for openwebif * Forget to set DEFAULT_PASSWORD * Add source selection * Fix get current source name * Update pip version * - adding some extra attributes - support better recording playback integration * bump pip version * Bump pip * Adding prefer_picon config option * Updates to move logic into pypi module * bump pip * bump pip * - remove http dependancy. - rename prefer_picon to use_channel_icon * Bump pypi to fix toggle bug. also fix travis also move setup out of init --- .coveragerc | 1 + .../components/discovery/__init__.py | 2 + homeassistant/components/enigma2/__init__.py | 18 ++ .../components/enigma2/media_player.py | 221 ++++++++++++++++++ requirements_all.txt | 3 + 5 files changed, 245 insertions(+) create mode 100644 homeassistant/components/enigma2/__init__.py create mode 100644 homeassistant/components/enigma2/media_player.py diff --git a/.coveragerc b/.coveragerc index a4f040696fa..17dc2328e8b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -155,6 +155,7 @@ omit = homeassistant/components/elkm1/* homeassistant/components/emoncms_history/* homeassistant/components/emulated_hue/upnp.py + homeassistant/components/enigma2/media_player.py homeassistant/components/enocean/* homeassistant/components/envisalink/* homeassistant/components/esphome/__init__.py diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 85e3164d08b..16d49d3d5bb 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -31,6 +31,7 @@ SERVICE_AXIS = 'axis' SERVICE_DAIKIN = 'daikin' SERVICE_DECONZ = 'deconz' SERVICE_DLNA_DMR = 'dlna_dmr' +SERVICE_ENIGMA2 = 'enigma2' SERVICE_FREEBOX = 'freebox' SERVICE_HASS_IOS_APP = 'hass_ios' SERVICE_HASSIO = 'hassio' @@ -68,6 +69,7 @@ SERVICE_HANDLERS = { SERVICE_HASSIO: ('hassio', None), SERVICE_AXIS: ('axis', None), SERVICE_APPLE_TV: ('apple_tv', None), + SERVICE_ENIGMA2: ('enigma2', None), SERVICE_ROKU: ('roku', None), SERVICE_WINK: ('wink', None), SERVICE_XIAOMI_GW: ('xiaomi_aqara', None), diff --git a/homeassistant/components/enigma2/__init__.py b/homeassistant/components/enigma2/__init__.py new file mode 100644 index 00000000000..3f7f3d2241c --- /dev/null +++ b/homeassistant/components/enigma2/__init__.py @@ -0,0 +1,18 @@ +"""Support for Enigma2 devices.""" +from homeassistant.components.discovery import SERVICE_ENIGMA2 +from homeassistant.helpers.discovery import load_platform +from homeassistant.helpers import discovery + +DOMAIN = 'enigma2' + + +def setup(hass, config): + """Set up the Enigma2 platform.""" + def device_discovered(service, info): + """Handle when an Enigma2 device has been discovered.""" + load_platform(hass, 'media_player', DOMAIN, info, config) + + discovery.listen( + hass, SERVICE_ENIGMA2, device_discovered) + + return True diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py new file mode 100644 index 00000000000..10b39b7c5a9 --- /dev/null +++ b/homeassistant/components/enigma2/media_player.py @@ -0,0 +1,221 @@ +"""Support for Enigma2 media players.""" +import logging +import asyncio + +import voluptuous as vol + +from homeassistant.components.media_player import MediaPlayerDevice +from homeassistant.helpers.config_validation import (PLATFORM_SCHEMA) +from homeassistant.components.media_player.const import ( + SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PREVIOUS_TRACK, SUPPORT_TURN_ON, + SUPPORT_TURN_OFF, SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_SET, SUPPORT_STOP, + SUPPORT_SELECT_SOURCE, SUPPORT_VOLUME_STEP, MEDIA_TYPE_TVSHOW) +from homeassistant.const import ( + CONF_HOST, CONF_NAME, CONF_USERNAME, CONF_PASSWORD, CONF_SSL, + STATE_OFF, STATE_ON, STATE_PLAYING, CONF_PORT) +import homeassistant.helpers.config_validation as cv + +REQUIREMENTS = ['openwebifpy==1.2.7'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_MEDIA_CURRENTLY_RECORDING = 'media_currently_recording' +ATTR_MEDIA_DESCRIPTION = 'media_description' +ATTR_MEDIA_END_TIME = 'media_end_time' +ATTR_MEDIA_START_TIME = 'media_start_time' + +CONF_USE_CHANNEL_ICON = "use_channel_icon" + +DEFAULT_NAME = 'Enigma2 Media Player' +DEFAULT_PORT = 80 +DEFAULT_SSL = False +DEFAULT_USE_CHANNEL_ICON = False +DEFAULT_USERNAME = 'root' +DEFAULT_PASSWORD = 'dreambox' + +SUPPORTED_ENIGMA2 = SUPPORT_VOLUME_SET | SUPPORT_VOLUME_MUTE | \ + SUPPORT_TURN_OFF | SUPPORT_NEXT_TRACK | SUPPORT_STOP | \ + SUPPORT_PREVIOUS_TRACK | SUPPORT_VOLUME_STEP | \ + SUPPORT_TURN_ON | SUPPORT_PAUSE | SUPPORT_SELECT_SOURCE + +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, + vol.Optional(CONF_USERNAME, default=DEFAULT_USERNAME): cv.string, + vol.Optional(CONF_PASSWORD, default=DEFAULT_PASSWORD): cv.string, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_USE_CHANNEL_ICON, + default=DEFAULT_USE_CHANNEL_ICON): cv.boolean, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up of an enigma2 media player.""" + if discovery_info: + # Discovery gives us the streaming service port (8001) + # which is not useful as OpenWebif never runs on that port. + # So use the default port instead. + config[CONF_PORT] = DEFAULT_PORT + config[CONF_NAME] = discovery_info['hostname'] + config[CONF_HOST] = discovery_info['host'] + config[CONF_USERNAME] = DEFAULT_USERNAME + config[CONF_PASSWORD] = DEFAULT_PASSWORD + config[CONF_SSL] = DEFAULT_SSL + config[CONF_USE_CHANNEL_ICON] = DEFAULT_USE_CHANNEL_ICON + + from openwebif.api import CreateDevice + device = \ + CreateDevice(host=config[CONF_HOST], + port=config.get(CONF_PORT), + username=config.get(CONF_USERNAME), + password=config.get(CONF_PASSWORD), + is_https=config.get(CONF_SSL), + prefer_picon=config.get(CONF_USE_CHANNEL_ICON)) + + add_devices([Enigma2Device(config[CONF_NAME], device)], True) + + +class Enigma2Device(MediaPlayerDevice): + """Representation of an Enigma2 box.""" + + def __init__(self, name, device): + """Initialize the Enigma2 device.""" + self._name = name + self.e2_box = device + + @property + def name(self): + """Return the name of the device.""" + return self._name + + @property + def state(self): + """Return the state of the device.""" + if self.e2_box.is_recording_playback: + return STATE_PLAYING + return STATE_OFF if self.e2_box.in_standby else STATE_ON + + @property + def supported_features(self): + """Flag of media commands that are supported.""" + return SUPPORTED_ENIGMA2 + + def turn_off(self): + """Turn off media player.""" + self.e2_box.turn_off() + + def turn_on(self): + """Turn the media player on.""" + self.e2_box.turn_on() + + @property + def media_title(self): + """Title of current playing media.""" + return self.e2_box.current_service_channel_name + + @property + def media_series_title(self): + """Return the title of current episode of TV show.""" + return self.e2_box.current_programme_name + + @property + def media_channel(self): + """Channel of current playing media.""" + return self.e2_box.current_service_channel_name + + @property + def media_content_id(self): + """Service Ref of current playing media.""" + return self.e2_box.current_service_ref + + @property + def media_content_type(self): + """Type of video currently playing.""" + return MEDIA_TYPE_TVSHOW + + @property + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self.e2_box.muted + + @property + def media_image_url(self): + """Picon url for the channel.""" + return self.e2_box.picon_url + + def set_volume_level(self, volume): + """Set volume level, range 0..1.""" + self.e2_box.set_volume(int(volume * 100)) + + def volume_up(self): + """Volume up the media player.""" + self.e2_box.set_volume(int(self.e2_box.volume * 100) + 5) + + def volume_down(self): + """Volume down media player.""" + self.e2_box.set_volume(int(self.e2_box.volume * 100) - 5) + + @property + def volume_level(self): + """Volume level of the media player (0..1).""" + return self.e2_box.volume + + def media_stop(self): + """Send stop command.""" + self.e2_box.set_stop() + + def media_play(self): + """Play media.""" + self.e2_box.toggle_play_pause() + + def media_pause(self): + """Pause the media player.""" + self.e2_box.toggle_play_pause() + + def media_next_track(self): + """Send next track command.""" + self.e2_box.set_channel_up() + + def media_previous_track(self): + """Send next track command.""" + self.e2_box.set_channel_down() + + def mute_volume(self, mute): + """Mute or unmute.""" + self.e2_box.mute_volume() + + @property + def source(self): + """Return the current input source.""" + return self.e2_box.current_service_channel_name + + @property + def source_list(self): + """List of available input sources.""" + return self.e2_box.source_list + + @asyncio.coroutine + def async_select_source(self, source): + """Select input source.""" + self.e2_box.select_source(self.e2_box.sources[source]) + + def update(self): + """Update state of the media_player.""" + self.e2_box.update() + + @property + def device_state_attributes(self): + """Return device specific state attributes.""" + attributes = {} + if not self.e2_box.in_standby: + attributes[ATTR_MEDIA_CURRENTLY_RECORDING] = \ + self.e2_box.status_info['isRecording'] + attributes[ATTR_MEDIA_DESCRIPTION] = \ + self.e2_box.status_info['currservice_fulldescription'] + attributes[ATTR_MEDIA_START_TIME] = \ + self.e2_box.status_info['currservice_begin'] + attributes[ATTR_MEDIA_END_TIME] = \ + self.e2_box.status_info['currservice_end'] + + return attributes diff --git a/requirements_all.txt b/requirements_all.txt index b8aabafba19..9d7220a1149 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -779,6 +779,9 @@ openhomedevice==0.4.2 # homeassistant.components.air_quality.opensensemap opensensemap-api==0.1.5 +# homeassistant.components.enigma2.media_player +openwebifpy==1.2.7 + # homeassistant.components.device_tracker.luci openwrt-luci-rpc==1.0.5 From 7226e917edb918868314ef98f09b597639f5059e Mon Sep 17 00:00:00 2001 From: Greg Dowling Date: Fri, 8 Mar 2019 20:43:59 +0000 Subject: [PATCH 135/291] Bump loopenergy to 0.1.0. Loop updated their socket.io server from 0.9 to 2.0 - which required a library update. (#21809) --- homeassistant/components/sensor/loopenergy.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/loopenergy.py b/homeassistant/components/sensor/loopenergy.py index 0f2362ca33c..2ee94249b4c 100644 --- a/homeassistant/components/sensor/loopenergy.py +++ b/homeassistant/components/sensor/loopenergy.py @@ -17,7 +17,7 @@ from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pyloopenergy==0.0.18'] +REQUIREMENTS = ['pyloopenergy==0.1.0'] CONF_ELEC = 'electricity' CONF_GAS = 'gas' diff --git a/requirements_all.txt b/requirements_all.txt index 9d7220a1149..35ec19f7c96 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1129,7 +1129,7 @@ pylinky==0.3.0 pylitejet==0.1 # homeassistant.components.sensor.loopenergy -pyloopenergy==0.0.18 +pyloopenergy==0.1.0 # homeassistant.components.lutron_caseta pylutron-caseta==0.5.0 From a0e8543aed52471e781431f643b36ce7eda26d8f Mon Sep 17 00:00:00 2001 From: Toon Willems Date: Fri, 8 Mar 2019 22:19:48 +0100 Subject: [PATCH 136/291] remove occupancy, as it is not available at this level in the iRail api (#21810) --- homeassistant/components/sensor/nmbs.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/sensor/nmbs.py b/homeassistant/components/sensor/nmbs.py index e677a072ef3..84e187fa5a4 100644 --- a/homeassistant/components/sensor/nmbs.py +++ b/homeassistant/components/sensor/nmbs.py @@ -194,7 +194,6 @@ class NMBSSensor(Entity): 'departure': "In {} minutes".format(departure), 'destination': self._station_to, 'direction': self._attrs['departure']['direction']['name'], - 'occupancy': self._attrs['departure']['occupancy']['name'], "platform_arriving": self._attrs['arrival']['platform'], "platform_departing": self._attrs['departure']['platform'], "vehicle_id": self._attrs['departure']['vehicle'], From 3da0ed9cc75596681e78dcb398e212a5befcd35e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 8 Mar 2019 13:51:42 -0800 Subject: [PATCH 137/291] Onboarding to generate auth code (#21777) --- homeassistant/components/auth/__init__.py | 9 +++++++ .../components/onboarding/__init__.py | 2 +- homeassistant/components/onboarding/views.py | 10 ++++++++ tests/components/onboarding/test_views.py | 25 ++++++++++++++++++- 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 2e74961d11b..8c0c17844f9 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -127,6 +127,7 @@ import voluptuous as vol from homeassistant.auth.models import User, Credentials, \ TOKEN_TYPE_LONG_LIVED_ACCESS_TOKEN +from homeassistant.loader import bind_hass from homeassistant.components import websocket_api from homeassistant.components.http import KEY_REAL_IP from homeassistant.components.http.auth import async_sign_path @@ -184,10 +185,18 @@ RESULT_TYPE_USER = 'user' _LOGGER = logging.getLogger(__name__) +@bind_hass +def create_auth_code(hass, client_id: str, user: User) -> str: + """Create an authorization code to fetch tokens.""" + return hass.data[DOMAIN](client_id, user) + + async def async_setup(hass, config): """Component to allow users to login.""" store_result, retrieve_result = _create_auth_code_store() + hass.data[DOMAIN] = store_result + hass.http.register_view(TokenView(retrieve_result)) hass.http.register_view(LinkUserView(retrieve_result)) diff --git a/homeassistant/components/onboarding/__init__.py b/homeassistant/components/onboarding/__init__.py index 6bbe546dcb1..f8885962ee7 100644 --- a/homeassistant/components/onboarding/__init__.py +++ b/homeassistant/components/onboarding/__init__.py @@ -4,7 +4,7 @@ from homeassistant.loader import bind_hass from .const import DOMAIN, STEP_USER, STEPS -DEPENDENCIES = ['http'] +DEPENDENCIES = ['auth', 'http'] STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 diff --git a/homeassistant/components/onboarding/views.py b/homeassistant/components/onboarding/views.py index 804589200fa..d9631b77a20 100644 --- a/homeassistant/components/onboarding/views.py +++ b/homeassistant/components/onboarding/views.py @@ -74,6 +74,7 @@ class UserOnboardingView(_BaseOnboardingView): vol.Required('name'): str, vol.Required('username'): str, vol.Required('password'): str, + vol.Required('client_id'): str, })) async def post(self, request, data): """Return the manifest.json.""" @@ -98,8 +99,17 @@ class UserOnboardingView(_BaseOnboardingView): await hass.components.person.async_create_person( data['name'], user_id=user.id ) + await self._async_mark_done(hass) + # Return an authorization code to allow fetching tokens. + auth_code = hass.components.auth.create_auth_code( + data['client_id'], user + ) + return self.json({ + 'auth_code': auth_code + }) + @callback def _async_get_hass_provider(hass): diff --git a/tests/components/onboarding/test_views.py b/tests/components/onboarding/test_views.py index 5b303943747..fdf472f3b13 100644 --- a/tests/components/onboarding/test_views.py +++ b/tests/components/onboarding/test_views.py @@ -8,7 +8,7 @@ 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 tests.common import CLIENT_ID, register_auth_provider from . import mock_storage @@ -59,6 +59,7 @@ async def test_onboarding_user_already_done(hass, hass_storage, client = await aiohttp_client(hass.http.app) resp = await client.post('/api/onboarding/users', json={ + 'client_id': CLIENT_ID, 'name': 'Test Name', 'username': 'test-user', 'password': 'test-pass', @@ -79,12 +80,16 @@ async def test_onboarding_user(hass, hass_storage, aiohttp_client): client = await aiohttp_client(hass.http.app) resp = await client.post('/api/onboarding/users', json={ + 'client_id': CLIENT_ID, 'name': 'Test Name', 'username': 'test-user', 'password': 'test-pass', }) assert resp.status == 200 + data = await resp.json() + assert 'auth_code' in data + users = await hass.auth.async_get_users() assert len(users) == 1 user = users[0] @@ -93,6 +98,21 @@ async def test_onboarding_user(hass, hass_storage, aiohttp_client): assert user.credentials[0].data['username'] == 'test-user' assert len(hass.data['person'].storage_data) == 1 + # Request refresh tokens + resp = await client.post('/auth/token', data={ + 'client_id': CLIENT_ID, + 'grant_type': 'authorization_code', + 'code': data['auth_code'] + }) + + 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_onboarding_user_invalid_name(hass, hass_storage, aiohttp_client): @@ -106,6 +126,7 @@ async def test_onboarding_user_invalid_name(hass, hass_storage, client = await aiohttp_client(hass.http.app) resp = await client.post('/api/onboarding/users', json={ + 'client_id': CLIENT_ID, 'username': 'test-user', 'password': 'test-pass', }) @@ -124,11 +145,13 @@ async def test_onboarding_user_race(hass, hass_storage, aiohttp_client): client = await aiohttp_client(hass.http.app) resp1 = client.post('/api/onboarding/users', json={ + 'client_id': CLIENT_ID, 'name': 'Test 1', 'username': '1-user', 'password': '1-pass', }) resp2 = client.post('/api/onboarding/users', json={ + 'client_id': CLIENT_ID, 'name': 'Test 2', 'username': '2-user', 'password': '2-pass', From 4c9e5eef9c4a408d59e5cbf4c54d8caa05cf213d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 8 Mar 2019 14:07:49 -0800 Subject: [PATCH 138/291] Remove stub from config component (#21822) --- homeassistant/components/config/__init__.py | 1 - homeassistant/components/config/hassbian.py | 86 --------------------- tests/components/config/test_hassbian.py | 68 ---------------- 3 files changed, 155 deletions(-) delete mode 100644 homeassistant/components/config/hassbian.py delete mode 100644 tests/components/config/test_hassbian.py diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index 65a4d50be84..d7a1e641c67 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -24,7 +24,6 @@ SECTIONS = ( 'device_registry', 'entity_registry', 'group', - 'hassbian', 'script', ) ON_DEMAND = ('zwave',) diff --git a/homeassistant/components/config/hassbian.py b/homeassistant/components/config/hassbian.py deleted file mode 100644 index c475dc317f7..00000000000 --- a/homeassistant/components/config/hassbian.py +++ /dev/null @@ -1,86 +0,0 @@ -"""Component to interact with Hassbian tools.""" -import json -import os - -from homeassistant.components.http import HomeAssistantView - - -_TEST_OUTPUT = """ -{ - "suites":{ - "libcec":{ - "state":"Uninstalled", - "description":"Installs the libcec package for controlling CEC devices from this Pi" - }, - "mosquitto":{ - "state":"failed", - "description":"Installs the Mosquitto package for setting up a local MQTT server" - }, - "openzwave":{ - "state":"Uninstalled", - "description":"Installs the Open Z-wave package for setting up your zwave network" - }, - "samba":{ - "state":"installing", - "description":"Installs the samba package for sharing the hassbian configuration files over the Pi's network." - } - } -} -""" # noqa - - -async def async_setup(hass): - """Set up the Hassbian config.""" - # Test if is Hassbian - test_mode = 'FORCE_HASSBIAN' in os.environ - is_hassbian = test_mode - - if not is_hassbian: - return False - - hass.http.register_view(HassbianSuitesView(test_mode)) - hass.http.register_view(HassbianSuiteInstallView(test_mode)) - - return True - - -async def hassbian_status(hass, test_mode=False): - """Query for the Hassbian status.""" - # Fetch real output when not in test mode - if test_mode: - return json.loads(_TEST_OUTPUT) - - raise Exception('Real mode not implemented yet.') - - -class HassbianSuitesView(HomeAssistantView): - """Hassbian packages endpoint.""" - - url = '/api/config/hassbian/suites' - name = 'api:config:hassbian:suites' - - def __init__(self, test_mode): - """Initialize suites view.""" - self._test_mode = test_mode - - async def get(self, request): - """Request suite status.""" - inp = await hassbian_status(request.app['hass'], self._test_mode) - - return self.json(inp['suites']) - - -class HassbianSuiteInstallView(HomeAssistantView): - """Hassbian packages endpoint.""" - - url = '/api/config/hassbian/suites/{suite}/install' - name = 'api:config:hassbian:suite' - - def __init__(self, test_mode): - """Initialize suite view.""" - self._test_mode = test_mode - - async def post(self, request, suite): - """Request suite status.""" - # do real install if not in test mode - return self.json({"status": "ok"}) diff --git a/tests/components/config/test_hassbian.py b/tests/components/config/test_hassbian.py deleted file mode 100644 index 547bb612ee4..00000000000 --- a/tests/components/config/test_hassbian.py +++ /dev/null @@ -1,68 +0,0 @@ -"""Test hassbian config.""" -import asyncio -import os -from unittest.mock import patch - -from homeassistant.bootstrap import async_setup_component -from homeassistant.components import config -from homeassistant.components.config.hassbian import ( - HassbianSuitesView, HassbianSuiteInstallView) - - -def test_setup_check_env_prevents_load(hass, loop): - """Test it does not set up hassbian if environment var not present.""" - with patch.dict(os.environ, clear=True), \ - patch.object(config, 'SECTIONS', ['hassbian']), \ - patch('homeassistant.components.http.' - 'HomeAssistantHTTP.register_view') as reg_view: - loop.run_until_complete(async_setup_component(hass, 'config', {})) - assert 'config' in hass.config.components - assert reg_view.called is False - - -def test_setup_check_env_works(hass, loop): - """Test it sets up hassbian if environment var present.""" - with patch.dict(os.environ, {'FORCE_HASSBIAN': '1'}), \ - patch.object(config, 'SECTIONS', ['hassbian']), \ - patch('homeassistant.components.http.' - 'HomeAssistantHTTP.register_view') as reg_view: - loop.run_until_complete(async_setup_component(hass, 'config', {})) - assert 'config' in hass.config.components - assert len(reg_view.mock_calls) == 2 - assert isinstance(reg_view.mock_calls[0][1][0], HassbianSuitesView) - assert isinstance(reg_view.mock_calls[1][1][0], HassbianSuiteInstallView) - - -@asyncio.coroutine -def test_get_suites(hass, hass_client): - """Test getting suites.""" - with patch.dict(os.environ, {'FORCE_HASSBIAN': '1'}), \ - patch.object(config, 'SECTIONS', ['hassbian']): - yield from async_setup_component(hass, 'config', {}) - - client = yield from hass_client() - resp = yield from client.get('/api/config/hassbian/suites') - assert resp.status == 200 - result = yield from resp.json() - - assert 'mosquitto' in result - info = result['mosquitto'] - assert info['state'] == 'failed' - assert info['description'] == \ - 'Installs the Mosquitto package for setting up a local MQTT server' - - -@asyncio.coroutine -def test_install_suite(hass, hass_client): - """Test getting suites.""" - with patch.dict(os.environ, {'FORCE_HASSBIAN': '1'}), \ - patch.object(config, 'SECTIONS', ['hassbian']): - yield from async_setup_component(hass, 'config', {}) - - client = yield from hass_client() - resp = yield from client.post( - '/api/config/hassbian/suites/openzwave/install') - assert resp.status == 200 - result = yield from resp.json() - - assert result == {"status": "ok"} From ed6082eb2b5f75615c10f517a1c053fb926539e6 Mon Sep 17 00:00:00 2001 From: uchagani Date: Fri, 8 Mar 2019 17:08:19 -0500 Subject: [PATCH 139/291] change paths to be relative (#21827) --- homeassistant/components/zwave/cover.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/zwave/cover.py b/homeassistant/components/zwave/cover.py index dc5d38944c8..a3cd7269b99 100644 --- a/homeassistant/components/zwave/cover.py +++ b/homeassistant/components/zwave/cover.py @@ -3,10 +3,13 @@ import logging from homeassistant.core import callback from homeassistant.components.cover import ( DOMAIN, SUPPORT_OPEN, SUPPORT_CLOSE, ATTR_POSITION) -from homeassistant.components import zwave from homeassistant.components.cover import CoverDevice from homeassistant.helpers.dispatcher import async_dispatcher_connect -from . import ZWaveDeviceEntity, workaround +from . import ( + ZWaveDeviceEntity, CONF_INVERT_OPENCLOSE_BUTTONS, workaround) +from .const import ( + COMMAND_CLASS_SWITCH_MULTILEVEL, COMMAND_CLASS_SWITCH_BINARY, + COMMAND_CLASS_BARRIER_OPERATOR, DATA_NETWORK) _LOGGER = logging.getLogger(__name__) @@ -31,26 +34,26 @@ async def async_setup_entry(hass, config_entry, async_add_entities): def get_device(hass, values, node_config, **kwargs): """Create Z-Wave entity device.""" - invert_buttons = node_config.get(zwave.CONF_INVERT_OPENCLOSE_BUTTONS) + invert_buttons = node_config.get(CONF_INVERT_OPENCLOSE_BUTTONS) if (values.primary.command_class == - zwave.const.COMMAND_CLASS_SWITCH_MULTILEVEL + COMMAND_CLASS_SWITCH_MULTILEVEL and values.primary.index == 0): return ZwaveRollershutter(hass, values, invert_buttons) - if values.primary.command_class == zwave.const.COMMAND_CLASS_SWITCH_BINARY: + if values.primary.command_class == COMMAND_CLASS_SWITCH_BINARY: return ZwaveGarageDoorSwitch(values) if values.primary.command_class == \ - zwave.const.COMMAND_CLASS_BARRIER_OPERATOR: + COMMAND_CLASS_BARRIER_OPERATOR: return ZwaveGarageDoorBarrier(values) return None -class ZwaveRollershutter(zwave.ZWaveDeviceEntity, CoverDevice): +class ZwaveRollershutter(ZWaveDeviceEntity, CoverDevice): """Representation of an Z-Wave cover.""" def __init__(self, hass, values, invert_buttons): """Initialize the Z-Wave rollershutter.""" ZWaveDeviceEntity.__init__(self, values, DOMAIN) - self._network = hass.data[zwave.const.DATA_NETWORK] + self._network = hass.data[DATA_NETWORK] self._open_id = None self._close_id = None self._current_position = None @@ -114,7 +117,7 @@ class ZwaveRollershutter(zwave.ZWaveDeviceEntity, CoverDevice): self._network.manager.releaseButton(self._open_id) -class ZwaveGarageDoorBase(zwave.ZWaveDeviceEntity, CoverDevice): +class ZwaveGarageDoorBase(ZWaveDeviceEntity, CoverDevice): """Base class for a Zwave garage door device.""" def __init__(self, values): From 22ab5a498f2938c67db8217eb71361f46b127828 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 8 Mar 2019 14:09:18 -0800 Subject: [PATCH 140/291] Change how we import config modules (#21824) --- homeassistant/components/config/__init__.py | 7 +++---- homeassistant/components/config/area_registry.py | 2 -- homeassistant/components/config/device_registry.py | 2 -- homeassistant/components/config/entity_registry.py | 2 -- 4 files changed, 3 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/config/__init__.py b/homeassistant/components/config/__init__.py index d7a1e641c67..efabd03b586 100644 --- a/homeassistant/components/config/__init__.py +++ b/homeassistant/components/config/__init__.py @@ -1,13 +1,13 @@ """Component to configure Home Assistant via an API.""" import asyncio +import importlib import os import voluptuous as vol from homeassistant.core import callback from homeassistant.const import EVENT_COMPONENT_LOADED, CONF_ID -from homeassistant.setup import ( - async_prepare_setup_platform, ATTR_COMPONENT) +from homeassistant.setup import ATTR_COMPONENT from homeassistant.components.http import HomeAssistantView from homeassistant.util.yaml import load_yaml, dump @@ -36,8 +36,7 @@ async def async_setup(hass, config): async def setup_panel(panel_name): """Set up a panel.""" - panel = await async_prepare_setup_platform( - hass, config, DOMAIN, panel_name) + panel = importlib.import_module('.{}'.format(panel_name), __name__) if not panel: return diff --git a/homeassistant/components/config/area_registry.py b/homeassistant/components/config/area_registry.py index 7f1bb938228..06fc3eae34d 100644 --- a/homeassistant/components/config/area_registry.py +++ b/homeassistant/components/config/area_registry.py @@ -8,8 +8,6 @@ from homeassistant.core import callback from homeassistant.helpers.area_registry import async_get_registry -DEPENDENCIES = ['websocket_api'] - WS_TYPE_LIST = 'config/area_registry/list' SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_LIST, diff --git a/homeassistant/components/config/device_registry.py b/homeassistant/components/config/device_registry.py index 9554f6aeee6..d9e55bbe67e 100644 --- a/homeassistant/components/config/device_registry.py +++ b/homeassistant/components/config/device_registry.py @@ -7,8 +7,6 @@ from homeassistant.components.websocket_api.decorators import ( from homeassistant.core import callback from homeassistant.helpers.device_registry import async_get_registry -DEPENDENCIES = ['websocket_api'] - WS_TYPE_LIST = 'config/device_registry/list' SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_LIST, diff --git a/homeassistant/components/config/entity_registry.py b/homeassistant/components/config/entity_registry.py index 39dd622540d..341b05f966b 100644 --- a/homeassistant/components/config/entity_registry.py +++ b/homeassistant/components/config/entity_registry.py @@ -9,8 +9,6 @@ from homeassistant.components.websocket_api.decorators import ( async_response, require_admin) from homeassistant.helpers import config_validation as cv -DEPENDENCIES = ['websocket_api'] - WS_TYPE_LIST = 'config/entity_registry/list' SCHEMA_WS_LIST = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_LIST, From 3ff2d99cd6b023e69810f52c5ce67255f4c5568c Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Fri, 8 Mar 2019 14:47:10 -0800 Subject: [PATCH 141/291] Load logger and system_log components as soon as possible (#21799) --- homeassistant/bootstrap.py | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index d37e3babac8..06f4fdd8788 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -28,8 +28,16 @@ ERROR_LOG_FILENAME = 'home-assistant.log' # hass.data key for logging information. DATA_LOGGING = 'logging' -FIRST_INIT_COMPONENT = {'system_log', 'recorder', 'mqtt', 'mqtt_eventstream', - 'logger', 'introduction', 'frontend', 'history'} +LOGGING_COMPONENT = {'logger', 'system_log'} + +FIRST_INIT_COMPONENT = { + 'recorder', + 'mqtt', + 'mqtt_eventstream', + 'introduction', + 'frontend', + 'history', +} def from_config_dict(config: Dict[str, Any], @@ -144,17 +152,25 @@ async def async_from_config_dict(config: Dict[str, Any], _LOGGER.info("Home Assistant core initialized") + # stage 0, load logging components + for component in components: + if component in LOGGING_COMPONENT: + hass.async_create_task( + async_setup_component(hass, component, config)) + + await hass.async_block_till_done() + # stage 1 for component in components: - if component not in FIRST_INIT_COMPONENT: - continue - hass.async_create_task(async_setup_component(hass, component, config)) + if component in FIRST_INIT_COMPONENT: + hass.async_create_task( + async_setup_component(hass, component, config)) await hass.async_block_till_done() # stage 2 for component in components: - if component in FIRST_INIT_COMPONENT: + if component in FIRST_INIT_COMPONENT or component in LOGGING_COMPONENT: continue hass.async_create_task(async_setup_component(hass, component, config)) From 3d8673dbf8d8aaae977c397fd00da73f2483b4d7 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Fri, 8 Mar 2019 14:50:24 -0800 Subject: [PATCH 142/291] Resolve auth_store loading race condition (#21794) * Add lock in auth_store._async_load() * Python 3.5 does not like assert_called_once() --- homeassistant/auth/auth_store.py | 8 ++++++++ tests/auth/test_auth_store.py | 22 +++++++++++++++++++++ tests/helpers/test_area_registry.py | 17 ++++++++++++++++ tests/helpers/test_device_registry.py | 16 +++++++++++++++ tests/helpers/test_entity_registry.py | 28 ++++++++++++++++----------- 5 files changed, 80 insertions(+), 11 deletions(-) diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index c6078e03f63..90c1b337f16 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -38,6 +38,7 @@ class AuthStore: self._perm_lookup = None # type: Optional[PermissionLookup] self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY, private=True) + self._lock = asyncio.Lock() async def async_get_groups(self) -> List[models.Group]: """Retrieve all users.""" @@ -271,6 +272,13 @@ class AuthStore: self._async_schedule_save() async def _async_load(self) -> None: + """Load the users.""" + async with self._lock: + if self._users is not None: + return + await self._async_load_task() + + async def _async_load_task(self) -> None: """Load the users.""" [ent_reg, data] = await asyncio.gather( self.hass.helpers.entity_registry.async_get_registry(), diff --git a/tests/auth/test_auth_store.py b/tests/auth/test_auth_store.py index 7e9df869a04..08530da324b 100644 --- a/tests/auth/test_auth_store.py +++ b/tests/auth/test_auth_store.py @@ -1,4 +1,8 @@ """Tests for the auth store.""" +import asyncio + +import asynctest + from homeassistant.auth import auth_store @@ -218,3 +222,21 @@ async def test_system_groups_store_id_and_name(hass, hass_storage): 'name': auth_store.GROUP_NAME_READ_ONLY, }, ] + + +async def test_loading_race_condition(hass): + """Test only one storage load called when concurrent loading occurred .""" + store = auth_store.AuthStore(hass) + with asynctest.patch( + 'homeassistant.helpers.entity_registry.async_get_registry', + ) as mock_registry, asynctest.patch( + 'homeassistant.helpers.storage.Store.async_load', + ) as mock_load: + results = await asyncio.gather( + store.async_get_users(), + store.async_get_users(), + ) + + mock_registry.assert_called_once_with(hass) + mock_load.assert_called_once_with() + assert results[0] == results[1] diff --git a/tests/helpers/test_area_registry.py b/tests/helpers/test_area_registry.py index 9f2801fe334..284cb2b3dbe 100644 --- a/tests/helpers/test_area_registry.py +++ b/tests/helpers/test_area_registry.py @@ -1,4 +1,7 @@ """Tests for the Area Registry.""" +import asyncio + +import asynctest import pytest from homeassistant.helpers import area_registry @@ -125,3 +128,17 @@ async def test_loading_area_from_storage(hass, hass_storage): registry = await area_registry.async_get_registry(hass) assert len(registry.areas) == 1 + + +async def test_loading_race_condition(hass): + """Test only one storage load called when concurrent loading occurred .""" + with asynctest.patch( + 'homeassistant.helpers.area_registry.AreaRegistry.async_load', + ) as mock_load: + results = await asyncio.gather( + area_registry.async_get_registry(hass), + area_registry.async_get_registry(hass), + ) + + mock_load.assert_called_once_with() + assert results[0] == results[1] diff --git a/tests/helpers/test_device_registry.py b/tests/helpers/test_device_registry.py index caf1dafdf8f..adfa05a021b 100644 --- a/tests/helpers/test_device_registry.py +++ b/tests/helpers/test_device_registry.py @@ -1,6 +1,8 @@ """Tests for the Device Registry.""" +import asyncio from unittest.mock import patch +import asynctest import pytest from homeassistant.helpers import device_registry @@ -370,3 +372,17 @@ async def test_update(registry): assert updated_entry != entry assert updated_entry.area_id == '12345A' assert updated_entry.name_by_user == 'Test Friendly Name' + + +async def test_loading_race_condition(hass): + """Test only one storage load called when concurrent loading occurred .""" + with asynctest.patch( + 'homeassistant.helpers.device_registry.DeviceRegistry.async_load', + ) as mock_load: + results = await asyncio.gather( + device_registry.async_get_registry(hass), + device_registry.async_get_registry(hass), + ) + + mock_load.assert_called_once_with() + assert results[0] == results[1] diff --git a/tests/helpers/test_entity_registry.py b/tests/helpers/test_entity_registry.py index b1c13a36c6d..3fb79f693bd 100644 --- a/tests/helpers/test_entity_registry.py +++ b/tests/helpers/test_entity_registry.py @@ -2,6 +2,7 @@ import asyncio from unittest.mock import patch +import asynctest import pytest from homeassistant.core import valid_entity_id @@ -19,7 +20,6 @@ def registry(hass): return mock_registry(hass) -@asyncio.coroutine def test_get_or_create_returns_same_entry(registry): """Make sure we do not duplicate entries.""" entry = registry.async_get_or_create('light', 'hue', '1234') @@ -30,7 +30,6 @@ def test_get_or_create_returns_same_entry(registry): assert entry.entity_id == 'light.hue_1234' -@asyncio.coroutine def test_get_or_create_suggested_object_id(registry): """Test that suggested_object_id works.""" entry = registry.async_get_or_create( @@ -39,7 +38,6 @@ def test_get_or_create_suggested_object_id(registry): assert entry.entity_id == 'light.beer' -@asyncio.coroutine def test_get_or_create_suggested_object_id_conflict_register(registry): """Test that we don't generate an entity id that is already registered.""" entry = registry.async_get_or_create( @@ -51,7 +49,6 @@ def test_get_or_create_suggested_object_id_conflict_register(registry): assert entry2.entity_id == 'light.beer_2' -@asyncio.coroutine def test_get_or_create_suggested_object_id_conflict_existing(hass, registry): """Test that we don't generate an entity id that currently exists.""" hass.states.async_set('light.hue_1234', 'on') @@ -59,7 +56,6 @@ def test_get_or_create_suggested_object_id_conflict_existing(hass, registry): assert entry.entity_id == 'light.hue_1234_2' -@asyncio.coroutine def test_create_triggers_save(hass, registry): """Test that registering entry triggers a save.""" with patch.object(registry, 'async_schedule_save') as mock_schedule_save: @@ -91,7 +87,6 @@ async def test_loading_saving_data(hass, registry): assert orig_entry2 == new_entry2 -@asyncio.coroutine def test_generate_entity_considers_registered_entities(registry): """Test that we don't create entity id that are already registered.""" entry = registry.async_get_or_create('light', 'hue', '1234') @@ -100,7 +95,6 @@ def test_generate_entity_considers_registered_entities(registry): 'light.hue_1234_2' -@asyncio.coroutine def test_generate_entity_considers_existing_entities(hass, registry): """Test that we don't create entity id that currently exists.""" hass.states.async_set('light.kitchen', 'on') @@ -108,7 +102,6 @@ def test_generate_entity_considers_existing_entities(hass, registry): 'light.kitchen_2' -@asyncio.coroutine def test_is_registered(registry): """Test that is_registered works.""" entry = registry.async_get_or_create('light', 'hue', '1234') @@ -166,7 +159,6 @@ async def test_loading_extra_values(hass, hass_storage): 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') @@ -176,7 +168,7 @@ def test_async_get_entity_id(registry): assert registry.async_get_entity_id('light', 'hue', '123') is None -async def test_updating_config_entry_id(registry): +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') @@ -186,7 +178,7 @@ async def test_updating_config_entry_id(registry): assert entry2.config_entry_id == 'mock-id-2' -async def test_removing_config_entry_id(registry): +def test_removing_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') @@ -265,3 +257,17 @@ async def test_loading_invalid_entity_id(hass, hass_storage): 'test', 'super_platform', 'id-invalid-start') assert valid_entity_id(entity_invalid_start.entity_id) + + +async def test_loading_race_condition(hass): + """Test only one storage load called when concurrent loading occurred .""" + with asynctest.patch( + 'homeassistant.helpers.entity_registry.EntityRegistry.async_load', + ) as mock_load: + results = await asyncio.gather( + entity_registry.async_get_registry(hass), + entity_registry.async_get_registry(hass), + ) + + mock_load.assert_called_once_with() + assert results[0] == results[1] From 31a4187cc0ac1438889e5550e656e58ec0a526b6 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Fri, 8 Mar 2019 14:51:13 -0800 Subject: [PATCH 143/291] Log if aiohttp hits error during IndieAuth (#21780) * Log if aiohttp hits error during IndieAuth * Add content of redirect_url into error log Co-Authored-By: awarecan --- homeassistant/components/auth/indieauth.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/auth/indieauth.py b/homeassistant/components/auth/indieauth.py index 30432a612a4..1437685692b 100644 --- a/homeassistant/components/auth/indieauth.py +++ b/homeassistant/components/auth/indieauth.py @@ -1,4 +1,5 @@ """Helpers to resolve client ID/secret.""" +import logging import asyncio from ipaddress import ip_address from html.parser import HTMLParser @@ -9,6 +10,8 @@ from aiohttp.client_exceptions import ClientError from homeassistant.util.network import is_local +_LOGGER = logging.getLogger(__name__) + async def verify_redirect_uri(hass, client_id, redirect_uri): """Verify that the client and redirect uri match.""" @@ -78,7 +81,8 @@ async def fetch_redirect_uris(hass, url): if chunks == 10: break - except (asyncio.TimeoutError, ClientError): + except (asyncio.TimeoutError, ClientError) as ex: + _LOGGER.error("Error while looking up redirect_uri %s: %s", url, ex) pass # Authorization endpoints verifying that a redirect_uri is allowed for use From 0f189809a9d13df6f1897b6856ab4a460cf9c660 Mon Sep 17 00:00:00 2001 From: Finbarr Brady Date: Fri, 8 Mar 2019 23:47:06 +0000 Subject: [PATCH 144/291] Add support for Cisco Mobility Express (#21531) * Move cisco me to new layout * Add docstring * Move items out of init method and pass the controller instance to the scanner in get_scanner * Update homeassistant/components/cisco_mobility_express/device_tracker.py Co-Authored-By: fbradyirl * Update homeassistant/components/cisco_mobility_express/device_tracker.py Co-Authored-By: fbradyirl * Update homeassistant/components/cisco_mobility_express/device_tracker.py Co-Authored-By: fbradyirl * Update homeassistant/components/cisco_mobility_express/device_tracker.py Co-Authored-By: fbradyirl * Fix build error * Cleanup based on comments. --- .coveragerc | 1 + .../cisco_mobility_express/__init__.py | 1 + .../cisco_mobility_express/device_tracker.py | 83 +++++++++++++++++++ requirements_all.txt | 3 + 4 files changed, 88 insertions(+) create mode 100644 homeassistant/components/cisco_mobility_express/__init__.py create mode 100644 homeassistant/components/cisco_mobility_express/device_tracker.py diff --git a/.coveragerc b/.coveragerc index 17dc2328e8b..1b0058a9f98 100644 --- a/.coveragerc +++ b/.coveragerc @@ -68,6 +68,7 @@ omit = homeassistant/components/camera/xiaomi.py homeassistant/components/camera/yi.py homeassistant/components/cast/* + homeassistant/components/cisco_mobility_express/device_tracker.py homeassistant/components/climate/coolmaster.py homeassistant/components/climate/ephember.py homeassistant/components/climate/eq3btsmart.py diff --git a/homeassistant/components/cisco_mobility_express/__init__.py b/homeassistant/components/cisco_mobility_express/__init__.py new file mode 100644 index 00000000000..625a71a5b05 --- /dev/null +++ b/homeassistant/components/cisco_mobility_express/__init__.py @@ -0,0 +1 @@ +"""Component to embed Cisco Mobility Express.""" diff --git a/homeassistant/components/cisco_mobility_express/device_tracker.py b/homeassistant/components/cisco_mobility_express/device_tracker.py new file mode 100644 index 00000000000..60f8761aeeb --- /dev/null +++ b/homeassistant/components/cisco_mobility_express/device_tracker.py @@ -0,0 +1,83 @@ +"""Support for Cisco Mobility Express.""" +import logging + +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_USERNAME, CONF_PASSWORD, CONF_SSL, CONF_VERIFY_SSL) + + +REQUIREMENTS = ['ciscomobilityexpress==0.1.2'] + + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_SSL = False +DEFAULT_VERIFY_SSL = True + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_SSL, default=DEFAULT_SSL): cv.boolean, + vol.Optional(CONF_VERIFY_SSL, default=DEFAULT_VERIFY_SSL): cv.boolean, +}) + + +def get_scanner(hass, config): + """Validate the configuration and return a Cisco ME scanner.""" + from ciscomobilityexpress.ciscome import CiscoMobilityExpress + config = config[DOMAIN] + + controller = CiscoMobilityExpress( + config[CONF_HOST], + config[CONF_USERNAME], + config[CONF_PASSWORD], + config.get(CONF_SSL), + config.get(CONF_VERIFY_SSL)) + if not controller.is_logged_in(): + return None + return CiscoMEDeviceScanner(controller) + + +class CiscoMEDeviceScanner(DeviceScanner): + """This class scans for devices associated to a Cisco ME controller.""" + + def __init__(self, controller): + """Initialize the scanner.""" + self.controller = controller + self.last_results = {} + + def scan_devices(self): + """Scan for new devices and return a list with found device IDs.""" + self._update_info() + + return [device.macaddr for device in self.last_results] + + def get_device_name(self, device): + """Return the name of the given device or None if we don't know.""" + name = next(( + result.clId for result in self.last_results + if result.macaddr == device), None) + return name + + def get_extra_attributes(self, device): + """ + Get extra attributes of a device. + + Some known extra attributes that may be returned in the device tuple + include SSID, PT (eg 802.11ac), devtype (eg iPhone 7) among others. + """ + device = next(( + result for result in self.last_results + if result.macaddr == device), None) + return device._asdict() + + def _update_info(self): + """Check the Cisco ME controller for devices.""" + self.last_results = self.controller.get_associated_devices() + _LOGGER.debug("Cisco Mobility Express controller returned:" + " %s", self.last_results) diff --git a/requirements_all.txt b/requirements_all.txt index 35ec19f7c96..e3d23b14159 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -265,6 +265,9 @@ buienradar==0.91 # homeassistant.components.calendar.caldav caldav==0.5.0 +# homeassistant.components.cisco_mobility_express.device_tracker +ciscomobilityexpress==0.1.2 + # homeassistant.components.notify.ciscospark ciscosparkapi==0.4.2 From d8ac761bb6aee7a998e883f9005a648728a13bf4 Mon Sep 17 00:00:00 2001 From: Mike Megally Date: Fri, 8 Mar 2019 16:35:38 -0800 Subject: [PATCH 145/291] Synology sensor quick return if attr is null (#21709) * Quick return if attr is null There are some case where attr is null. Returning null doesn't change anything (in my case this is mapped to a volume that doesn't exist, not sure what others are seeing). If you have confirmed you hass instance for C instead of F you do not see this error. * update == to is * whitespace --- homeassistant/components/sensor/synologydsm.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/sensor/synologydsm.py b/homeassistant/components/sensor/synologydsm.py index 2b443738230..0d5a253483f 100644 --- a/homeassistant/components/sensor/synologydsm.py +++ b/homeassistant/components/sensor/synologydsm.py @@ -230,6 +230,9 @@ class SynoNasStorageSensor(SynoNasSensor): attr = getattr( self._api.storage, self.var_id)(self.monitor_device) + if attr is None: + return None + if self._api.temp_unit == TEMP_CELSIUS: return attr From 76d11e4b746b70b8bcb231273831b16651feead6 Mon Sep 17 00:00:00 2001 From: engrbm87 Date: Sat, 9 Mar 2019 02:48:54 +0200 Subject: [PATCH 146/291] fix empty TOPIC_BASE issue (#21740) * fix empty TOPIC_BASE issue if the value of the TOPIC_BASE is empty then we need to remove "~" from the topic value if it exists. by doing `if base:` on line 239 the condition will be false if the value is empty so the '~' will not be stripped from the topic value. I simply removed the `if base:` line and added `if TOPIC_BASE in payload:` * Update homeassistant/components/mqtt/discovery.py Co-Authored-By: engrbm87 --- homeassistant/components/mqtt/discovery.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 688912070bd..885c14f609f 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -235,8 +235,8 @@ async def async_start(hass: HomeAssistantType, discovery_topic, hass_config, key = DEVICE_ABBREVIATIONS.get(key, key) device[key] = device.pop(abbreviated_key) - base = payload.pop(TOPIC_BASE, None) - if base: + if TOPIC_BASE in payload: + base = payload.pop(TOPIC_BASE) for key, value in payload.items(): if isinstance(value, str) and value: if value[0] == TOPIC_BASE and key.endswith('_topic'): From 012c657a9c28c28b36aaacdfb58870716cc6e7dc Mon Sep 17 00:00:00 2001 From: William Scanlon Date: Fri, 8 Mar 2019 22:24:17 -0500 Subject: [PATCH 147/291] Updated to pyeconet 0.0.10 (#21837) --- homeassistant/components/water_heater/econet.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/water_heater/econet.py b/homeassistant/components/water_heater/econet.py index efc21798859..90176842bf1 100644 --- a/homeassistant/components/water_heater/econet.py +++ b/homeassistant/components/water_heater/econet.py @@ -13,7 +13,7 @@ from homeassistant.const import ( TEMP_FAHRENHEIT) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyeconet==0.0.9'] +REQUIREMENTS = ['pyeconet==0.0.10'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index e3d23b14159..0d37e676dbf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1013,7 +1013,7 @@ pydukeenergy==0.0.6 pyebox==1.1.4 # homeassistant.components.water_heater.econet -pyeconet==0.0.9 +pyeconet==0.0.10 # homeassistant.components.switch.edimax pyedimax==0.1 From 113db9afd47e5db905940a82715033a0be24018b Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 8 Mar 2019 20:25:35 -0700 Subject: [PATCH 148/291] Fix config entry exception in Ambient PWS (#21836) --- .../components/ambient_station/__init__.py | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 70f6ce9fbba..c7b001121da 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -296,6 +296,7 @@ class AmbientStation: def __init__(self, hass, config_entry, client, monitored_conditions): """Initialize.""" self._config_entry = config_entry + self._entry_setup_complete = False self._hass = hass self._watchdog_listener = None self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY @@ -362,12 +363,18 @@ class AmbientStation: 'name', station['macAddress']), } - for component in ('binary_sensor', 'sensor'): - self._hass.async_create_task( - self._hass.config_entries.async_forward_entry_setup( - self._config_entry, component)) + # If the websocket disconnects and reconnects, the on_subscribed + # hanlder will get called again; in that case, we don't want to + # attempt forward setup of the config entry (because it will have + # already been done): + if not self._entry_setup_complete: + for component in ('binary_sensor', 'sensor'): + self._hass.async_create_task( + self._hass.config_entries.async_forward_entry_setup( + self._config_entry, component)) + self._entry_setup_complete = True - self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY + self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY self.client.websocket.on_connect(on_connect) self.client.websocket.on_data(on_data) From c5734eecc7559992e7aa29abb6a41de241484533 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Fri, 8 Mar 2019 23:20:07 -0600 Subject: [PATCH 149/291] Update dependencies to receive data on webhook callbacks (#21838) --- homeassistant/components/smartthings/__init__.py | 9 ++++++--- requirements_all.txt | 4 ++-- requirements_test_all.txt | 4 ++-- tests/components/smartthings/conftest.py | 3 ++- tests/components/smartthings/test_init.py | 16 ++++++++++++---- 5 files changed, 24 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index c1c8b12ccaa..3734baae6f4 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -27,7 +27,7 @@ from .smartapp import ( setup_smartapp, setup_smartapp_endpoint, smartapp_sync_subscriptions, validate_installed_app) -REQUIREMENTS = ['pysmartapp==0.3.0', 'pysmartthings==0.6.3'] +REQUIREMENTS = ['pysmartapp==0.3.1', 'pysmartthings==0.6.7'] DEPENDENCIES = ['webhook'] _LOGGER = logging.getLogger(__name__) @@ -315,7 +315,8 @@ class DeviceBroker: if not device: continue device.status.apply_attribute_update( - evt.component_id, evt.capability, evt.attribute, evt.value) + evt.component_id, evt.capability, evt.attribute, evt.value, + data=evt.data) # Fire events for buttons if evt.capability == Capability.button and \ @@ -325,7 +326,8 @@ class DeviceBroker: 'device_id': evt.device_id, 'location_id': evt.location_id, 'value': evt.value, - 'name': device.label + 'name': device.label, + 'data': evt.data } self._hass.bus.async_fire(EVENT_BUTTON, data) _LOGGER.debug("Fired button event: %s", data) @@ -337,6 +339,7 @@ class DeviceBroker: 'capability': evt.capability, 'attribute': evt.attribute, 'value': evt.value, + 'data': evt.data } _LOGGER.debug("Push update received: %s", data) diff --git a/requirements_all.txt b/requirements_all.txt index 0d37e676dbf..ac1883a4b73 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1265,10 +1265,10 @@ pysher==1.0.1 pysma==0.3.1 # homeassistant.components.smartthings -pysmartapp==0.3.0 +pysmartapp==0.3.1 # homeassistant.components.smartthings -pysmartthings==0.6.3 +pysmartthings==0.6.7 # homeassistant.components.device_tracker.snmp # homeassistant.components.sensor.snmp diff --git a/requirements_test_all.txt b/requirements_test_all.txt index dddef010a9a..bca0bd4365f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -224,10 +224,10 @@ pyps4-homeassistant==0.4.8 pyqwikswitch==0.8 # homeassistant.components.smartthings -pysmartapp==0.3.0 +pysmartapp==0.3.1 # homeassistant.components.smartthings -pysmartthings==0.6.3 +pysmartthings==0.6.7 # homeassistant.components.sonos pysonos==0.0.8 diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 27e833bff25..67c35ba8232 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -326,7 +326,7 @@ def scene_fixture(scene_factory): def event_factory_fixture(): """Fixture for creating mock devices.""" def _factory(device_id, event_type="DEVICE_EVENT", capability='', - attribute='Updated', value='Value'): + attribute='Updated', value='Value', data=None): event = Mock() event.event_type = event_type event.device_id = device_id @@ -334,6 +334,7 @@ def event_factory_fixture(): event.capability = capability event.attribute = attribute event.value = value + event.data = data event.location_id = str(uuid4()) return event return _factory diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index 67b83ec0ca9..dfb596998b7 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -344,16 +344,21 @@ async def test_broker_regenerates_token( async def test_event_handler_dispatches_updated_devices( - hass, config_entry, device_factory, event_request_factory): + hass, config_entry, device_factory, event_request_factory, + event_factory): """Test the event handler dispatches updated devices.""" devices = [ device_factory('Bedroom 1 Switch', ['switch']), device_factory('Bathroom 1', ['switch']), device_factory('Sensor', ['motionSensor']), + device_factory('Lock', ['lock']) ] device_ids = [devices[0].device_id, devices[1].device_id, - devices[2].device_id] - request = event_request_factory(device_ids) + devices[2].device_id, devices[3].device_id] + event = event_factory(devices[3].device_id, capability='lock', + attribute='lock', value='locked', + data={'codeId': '1'}) + request = event_request_factory(device_ids=device_ids, events=[event]) config_entry.data[CONF_INSTALLED_APP_ID] = request.installed_app_id called = False @@ -374,6 +379,8 @@ async def test_event_handler_dispatches_updated_devices( assert called for device in devices: assert device.status.values['Updated'] == 'Value' + assert devices[3].status.attributes['lock'].value == 'locked' + assert devices[3].status.attributes['lock'].data == {'codeId': '1'} async def test_event_handler_ignores_other_installed_app( @@ -417,7 +424,8 @@ async def test_event_handler_fires_button_events( 'device_id': device.device_id, 'location_id': event.location_id, 'value': 'pushed', - 'name': device.label + 'name': device.label, + 'data': None } hass.bus.async_listen(EVENT_BUTTON, handler) broker = smartthings.DeviceBroker( From 49eaa34e03fb4d77abd1b702297bfe3c7c6d675d Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 8 Mar 2019 22:36:41 -0700 Subject: [PATCH 150/291] Fixed a misspelling in a docstring (#21846) --- homeassistant/components/ambient_station/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index c7b001121da..545415f9d5d 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -364,7 +364,7 @@ class AmbientStation: } # If the websocket disconnects and reconnects, the on_subscribed - # hanlder will get called again; in that case, we don't want to + # handler will get called again; in that case, we don't want to # attempt forward setup of the config entry (because it will have # already been done): if not self._entry_setup_complete: From 9ab0753cf7fffb41ad95ef8a28616a1aaf9e0bb6 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Fri, 8 Mar 2019 23:44:56 -0800 Subject: [PATCH 151/291] mobile_app improvements (#21607) * First webhook commands for getting and deleting single registrations * Keep a list of deleted webhook IDs so we can 410 if the webhook receives traffic in the future * Return a empty JSON object instead of None * Split up mobile_app bits into individual files * Add typing * Sort keys * Remove unused async_setup_entry * New decorator method of registering webhooks * Add tests for cloud hook forwarding and improve error handling for cloud hooks * Initial implementation of platform specific logic * Add get registrations by user ID websocket call, minor style fixes * Stop using resp dictionary during registration * Move mobile_app/ios.py to ios/mobile_app.py * Log any errors encountered during webhook * Improve update registration call * Split up mobile_app tests to match split up component * Fix tests * Remove integration_map in favor of component name in registration * Add a few helper functions for custom logic components to use * Load the app_component platform at device registration or component setup time * Remove extraneous function * Use guard function for checking if component is in device * Inline websocket schemas * Rename ATTR_s used in storage to DATA_ prefix * squash flake8 and pylint issues * Remove ios.mobile_app platform * Dont mark websocket_api as a dependency * Return standard empty_okay_response with 400 if no JSON sent * Ensure deleted webhook IDs are registered at launch * Remove the creation of cloudhooks during handle_webhook * Rename device to registration everywhere applicable * Dont check if cloud is logged in, just check if cloud is in components * Dont ever use cloudhook_id * Remove component loading logic for a later PR * Cast exception to string * Remove unused functions --- .../components/mobile_app/__init__.py | 358 +----------------- homeassistant/components/mobile_app/const.py | 104 +++++ .../components/mobile_app/helpers.py | 103 +++++ .../components/mobile_app/http_api.py | 78 ++++ .../components/mobile_app/webhook.py | 162 ++++++++ .../components/mobile_app/websocket_api.py | 143 +++++++ tests/components/mobile_app/__init__.py | 52 +++ tests/components/mobile_app/const.py | 49 +++ tests/components/mobile_app/test_http_api.py | 59 +++ tests/components/mobile_app/test_init.py | 275 -------------- tests/components/mobile_app/test_webhook.py | 145 +++++++ .../mobile_app/test_websocket_api.py | 107 ++++++ 12 files changed, 1022 insertions(+), 613 deletions(-) create mode 100644 homeassistant/components/mobile_app/const.py create mode 100644 homeassistant/components/mobile_app/helpers.py create mode 100644 homeassistant/components/mobile_app/http_api.py create mode 100644 homeassistant/components/mobile_app/webhook.py create mode 100644 homeassistant/components/mobile_app/websocket_api.py create mode 100644 tests/components/mobile_app/const.py create mode 100644 tests/components/mobile_app/test_http_api.py delete mode 100644 tests/components/mobile_app/test_init.py create mode 100644 tests/components/mobile_app/test_webhook.py create mode 100644 tests/components/mobile_app/test_websocket_api.py diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 19a81b4aa45..30f83f343c3 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -1,355 +1,37 @@ -"""Support for native mobile apps.""" -import logging -import json -from functools import partial +"""Integrates Native Apps to Home Assistant.""" +from homeassistant.helpers.typing import ConfigType, HomeAssistantType -import voluptuous as vol -from aiohttp.web import json_response, Response -from aiohttp.web_exceptions import HTTPBadRequest +from .const import (DATA_DELETED_IDS, DATA_REGISTRATIONS, DATA_STORE, DOMAIN, + STORAGE_KEY, STORAGE_VERSION) -from homeassistant import config_entries -from homeassistant.auth.util import generate_secret -import homeassistant.core as ha -from homeassistant.core import Context -from homeassistant.components import webhook -from homeassistant.components.http import HomeAssistantView -from homeassistant.components.http.data_validator import RequestDataValidator -from homeassistant.components.device_tracker import ( - DOMAIN as DEVICE_TRACKER_DOMAIN, SERVICE_SEE as DEVICE_TRACKER_SEE, - SERVICE_SEE_PAYLOAD_SCHEMA as SEE_SCHEMA) -from homeassistant.const import (ATTR_DOMAIN, ATTR_SERVICE, ATTR_SERVICE_DATA, - HTTP_BAD_REQUEST, HTTP_CREATED, - HTTP_INTERNAL_SERVER_ERROR, CONF_WEBHOOK_ID) -from homeassistant.exceptions import (HomeAssistantError, ServiceNotFound, - TemplateError) -from homeassistant.helpers import config_validation as cv, template -from homeassistant.helpers.typing import HomeAssistantType - -REQUIREMENTS = ['PyNaCl==1.3.0'] - -_LOGGER = logging.getLogger(__name__) - -DOMAIN = 'mobile_app' +from .http_api import register_http_handlers +from .webhook import register_deleted_webhooks, setup_registration +from .websocket_api import register_websocket_handlers DEPENDENCIES = ['device_tracker', 'http', 'webhook'] -STORAGE_KEY = DOMAIN -STORAGE_VERSION = 1 - -CONF_SECRET = 'secret' -CONF_USER_ID = 'user_id' - -ATTR_APP_DATA = 'app_data' -ATTR_APP_ID = 'app_id' -ATTR_APP_NAME = 'app_name' -ATTR_APP_VERSION = 'app_version' -ATTR_DEVICE_NAME = 'device_name' -ATTR_MANUFACTURER = 'manufacturer' -ATTR_MODEL = 'model' -ATTR_OS_VERSION = 'os_version' -ATTR_SUPPORTS_ENCRYPTION = 'supports_encryption' - -ATTR_EVENT_DATA = 'event_data' -ATTR_EVENT_TYPE = 'event_type' - -ATTR_TEMPLATE = 'template' -ATTR_TEMPLATE_VARIABLES = 'variables' - -ATTR_WEBHOOK_DATA = 'data' -ATTR_WEBHOOK_ENCRYPTED = 'encrypted' -ATTR_WEBHOOK_ENCRYPTED_DATA = 'encrypted_data' -ATTR_WEBHOOK_TYPE = 'type' - -WEBHOOK_TYPE_CALL_SERVICE = 'call_service' -WEBHOOK_TYPE_FIRE_EVENT = 'fire_event' -WEBHOOK_TYPE_RENDER_TEMPLATE = 'render_template' -WEBHOOK_TYPE_UPDATE_LOCATION = 'update_location' -WEBHOOK_TYPE_UPDATE_REGISTRATION = 'update_registration' - -WEBHOOK_TYPES = [WEBHOOK_TYPE_CALL_SERVICE, WEBHOOK_TYPE_FIRE_EVENT, - WEBHOOK_TYPE_RENDER_TEMPLATE, WEBHOOK_TYPE_UPDATE_LOCATION, - WEBHOOK_TYPE_UPDATE_REGISTRATION] - -REGISTER_DEVICE_SCHEMA = vol.Schema({ - vol.Optional(ATTR_APP_DATA, default={}): dict, - vol.Required(ATTR_APP_ID): cv.string, - vol.Optional(ATTR_APP_NAME): cv.string, - vol.Required(ATTR_APP_VERSION): cv.string, - vol.Required(ATTR_DEVICE_NAME): cv.string, - vol.Required(ATTR_MANUFACTURER): cv.string, - vol.Required(ATTR_MODEL): cv.string, - vol.Optional(ATTR_OS_VERSION): cv.string, - vol.Required(ATTR_SUPPORTS_ENCRYPTION, default=False): cv.boolean, -}) - -UPDATE_DEVICE_SCHEMA = vol.Schema({ - vol.Optional(ATTR_APP_DATA, default={}): dict, - vol.Required(ATTR_APP_VERSION): cv.string, - vol.Required(ATTR_DEVICE_NAME): cv.string, - vol.Required(ATTR_MANUFACTURER): cv.string, - vol.Required(ATTR_MODEL): cv.string, - vol.Optional(ATTR_OS_VERSION): cv.string, -}) - -WEBHOOK_PAYLOAD_SCHEMA = vol.Schema({ - vol.Required(ATTR_WEBHOOK_TYPE): vol.In(WEBHOOK_TYPES), - vol.Required(ATTR_WEBHOOK_DATA, default={}): dict, - vol.Optional(ATTR_WEBHOOK_ENCRYPTED, default=False): cv.boolean, - vol.Optional(ATTR_WEBHOOK_ENCRYPTED_DATA): cv.string, -}) - -CALL_SERVICE_SCHEMA = vol.Schema({ - vol.Required(ATTR_DOMAIN): cv.string, - vol.Required(ATTR_SERVICE): cv.string, - vol.Optional(ATTR_SERVICE_DATA, default={}): dict, -}) - -FIRE_EVENT_SCHEMA = vol.Schema({ - vol.Required(ATTR_EVENT_TYPE): cv.string, - vol.Optional(ATTR_EVENT_DATA, default={}): dict, -}) - -RENDER_TEMPLATE_SCHEMA = vol.Schema({ - vol.Required(ATTR_TEMPLATE): cv.string, - vol.Optional(ATTR_TEMPLATE_VARIABLES, default={}): dict, -}) - -WEBHOOK_SCHEMAS = { - WEBHOOK_TYPE_CALL_SERVICE: CALL_SERVICE_SCHEMA, - WEBHOOK_TYPE_FIRE_EVENT: FIRE_EVENT_SCHEMA, - WEBHOOK_TYPE_RENDER_TEMPLATE: RENDER_TEMPLATE_SCHEMA, - WEBHOOK_TYPE_UPDATE_LOCATION: SEE_SCHEMA, - WEBHOOK_TYPE_UPDATE_REGISTRATION: UPDATE_DEVICE_SCHEMA, -} +REQUIREMENTS = ['PyNaCl==1.3.0'] -def get_cipher(): - """Return decryption function and length of key. - - Async friendly. - """ - from nacl.secret import SecretBox - from nacl.encoding import Base64Encoder - - def decrypt(ciphertext, key): - """Decrypt ciphertext using key.""" - return SecretBox(key).decrypt(ciphertext, encoder=Base64Encoder) - return (SecretBox.KEY_SIZE, decrypt) - - -def _decrypt_payload(key, ciphertext): - """Decrypt encrypted payload.""" - try: - keylen, decrypt = get_cipher() - except OSError: - _LOGGER.warning( - "Ignoring encrypted payload because libsodium not installed") - return None - - if key is None: - _LOGGER.warning( - "Ignoring encrypted payload because no decryption key known") - return None - - key = key.encode("utf-8") - key = key[:keylen] - key = key.ljust(keylen, b'\0') - - try: - message = decrypt(ciphertext, key) - message = json.loads(message.decode("utf-8")) - _LOGGER.debug("Successfully decrypted mobile_app payload") - return message - except ValueError: - _LOGGER.warning("Ignoring encrypted payload because unable to decrypt") - return None - - -def context(device): - """Generate a context from a request.""" - return Context(user_id=device[CONF_USER_ID]) - - -async def handle_webhook(store, hass: HomeAssistantType, webhook_id: str, - request): - """Handle webhook callback.""" - device = hass.data[DOMAIN][webhook_id] - - try: - req_data = await request.json() - except ValueError: - _LOGGER.warning('Received invalid JSON from mobile_app') - return json_response([], status=HTTP_BAD_REQUEST) - - try: - req_data = WEBHOOK_PAYLOAD_SCHEMA(req_data) - except vol.Invalid as ex: - err = vol.humanize.humanize_error(req_data, ex) - _LOGGER.error('Received invalid webhook payload: %s', err) - return Response(status=200) - - webhook_type = req_data[ATTR_WEBHOOK_TYPE] - - webhook_payload = req_data.get(ATTR_WEBHOOK_DATA, {}) - - if req_data[ATTR_WEBHOOK_ENCRYPTED]: - enc_data = req_data[ATTR_WEBHOOK_ENCRYPTED_DATA] - webhook_payload = _decrypt_payload(device[CONF_SECRET], enc_data) - - try: - data = WEBHOOK_SCHEMAS[webhook_type](webhook_payload) - except vol.Invalid as ex: - err = vol.humanize.humanize_error(webhook_payload, ex) - _LOGGER.error('Received invalid webhook payload: %s', err) - return Response(status=200) - - if webhook_type == WEBHOOK_TYPE_CALL_SERVICE: - try: - await hass.services.async_call(data[ATTR_DOMAIN], - data[ATTR_SERVICE], - data[ATTR_SERVICE_DATA], - blocking=True, - context=context(device)) - except (vol.Invalid, ServiceNotFound): - raise HTTPBadRequest() - - return Response(status=200) - - if webhook_type == WEBHOOK_TYPE_FIRE_EVENT: - event_type = data[ATTR_EVENT_TYPE] - hass.bus.async_fire(event_type, data[ATTR_EVENT_DATA], - ha.EventOrigin.remote, context=context(device)) - return Response(status=200) - - if webhook_type == WEBHOOK_TYPE_RENDER_TEMPLATE: - try: - tpl = template.Template(data[ATTR_TEMPLATE], hass) - rendered = tpl.async_render(data.get(ATTR_TEMPLATE_VARIABLES)) - return json_response({"rendered": rendered}) - except (ValueError, TemplateError) as ex: - return json_response(({"error": ex}), status=HTTP_BAD_REQUEST) - - if webhook_type == WEBHOOK_TYPE_UPDATE_LOCATION: - await hass.services.async_call(DEVICE_TRACKER_DOMAIN, - DEVICE_TRACKER_SEE, data, - blocking=True, context=context(device)) - return Response(status=200) - - if webhook_type == WEBHOOK_TYPE_UPDATE_REGISTRATION: - data[ATTR_APP_ID] = device[ATTR_APP_ID] - data[ATTR_APP_NAME] = device[ATTR_APP_NAME] - data[ATTR_SUPPORTS_ENCRYPTION] = device[ATTR_SUPPORTS_ENCRYPTION] - data[CONF_SECRET] = device[CONF_SECRET] - data[CONF_USER_ID] = device[CONF_USER_ID] - data[CONF_WEBHOOK_ID] = device[CONF_WEBHOOK_ID] - - hass.data[DOMAIN][webhook_id] = data - - try: - await store.async_save(hass.data[DOMAIN]) - except HomeAssistantError as ex: - _LOGGER.error("Error updating mobile_app registration: %s", ex) - return Response(status=200) - - return json_response(safe_device(data)) - - -def supports_encryption(): - """Test if we support encryption.""" - try: - import nacl # noqa pylint: disable=unused-import - return True - except OSError: - return False - - -def safe_device(device: dict): - """Return a device without webhook_id or secret.""" - return { - ATTR_APP_DATA: device[ATTR_APP_DATA], - ATTR_APP_ID: device[ATTR_APP_ID], - ATTR_APP_NAME: device[ATTR_APP_NAME], - ATTR_APP_VERSION: device[ATTR_APP_VERSION], - ATTR_DEVICE_NAME: device[ATTR_DEVICE_NAME], - ATTR_MANUFACTURER: device[ATTR_MANUFACTURER], - ATTR_MODEL: device[ATTR_MODEL], - ATTR_OS_VERSION: device[ATTR_OS_VERSION], - ATTR_SUPPORTS_ENCRYPTION: device[ATTR_SUPPORTS_ENCRYPTION], - } - - -def register_device_webhook(hass: HomeAssistantType, store, device): - """Register the webhook for a device.""" - device_name = 'Mobile App: {}'.format(device[ATTR_DEVICE_NAME]) - webhook_id = device[CONF_WEBHOOK_ID] - webhook.async_register(hass, DOMAIN, device_name, webhook_id, - partial(handle_webhook, store)) - - -async def async_setup(hass, config): +async def async_setup(hass: HomeAssistantType, config: ConfigType): """Set up the mobile app component.""" - conf = config.get(DOMAIN) - store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) app_config = await store.async_load() if app_config is None: - app_config = {} + app_config = {DATA_DELETED_IDS: [], DATA_REGISTRATIONS: {}} - hass.data[DOMAIN] = app_config + if hass.data.get(DOMAIN) is None: + hass.data[DOMAIN] = {DATA_DELETED_IDS: [], DATA_REGISTRATIONS: {}} - for device in app_config.values(): - register_device_webhook(hass, store, device) + hass.data[DOMAIN][DATA_DELETED_IDS] = app_config[DATA_DELETED_IDS] + hass.data[DOMAIN][DATA_REGISTRATIONS] = app_config[DATA_REGISTRATIONS] + hass.data[DOMAIN][DATA_STORE] = store - if conf is not None: - hass.async_create_task(hass.config_entries.flow.async_init( - DOMAIN, context={'source': config_entries.SOURCE_IMPORT})) + for registration in app_config[DATA_REGISTRATIONS].values(): + setup_registration(hass, store, registration) - hass.http.register_view(DevicesView(store)) + register_http_handlers(hass, store) + register_websocket_handlers(hass) + register_deleted_webhooks(hass, store) return True - - -async def async_setup_entry(hass, entry): - """Set up an mobile_app entry.""" - return True - - -class DevicesView(HomeAssistantView): - """A view that accepts device registration requests.""" - - url = '/api/mobile_app/devices' - name = 'api:mobile_app:register-device' - - def __init__(self, store): - """Initialize the view.""" - self._store = store - - @RequestDataValidator(REGISTER_DEVICE_SCHEMA) - async def post(self, request, data): - """Handle the POST request for device registration.""" - hass = request.app['hass'] - - resp = {} - - webhook_id = generate_secret() - - data[CONF_WEBHOOK_ID] = resp[CONF_WEBHOOK_ID] = webhook_id - - if data[ATTR_SUPPORTS_ENCRYPTION] and supports_encryption(): - secret = generate_secret(16) - - data[CONF_SECRET] = resp[CONF_SECRET] = secret - - data[CONF_USER_ID] = request['hass_user'].id - - hass.data[DOMAIN][webhook_id] = data - - try: - await self._store.async_save(hass.data[DOMAIN]) - except HomeAssistantError: - return self.json_message("Error saving device.", - HTTP_INTERNAL_SERVER_ERROR) - - register_device_webhook(hass, self._store, data) - - return self.json(resp, status_code=HTTP_CREATED) diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py new file mode 100644 index 00000000000..d2f32b8877c --- /dev/null +++ b/homeassistant/components/mobile_app/const.py @@ -0,0 +1,104 @@ +"""Constants for mobile_app.""" +import voluptuous as vol + +from homeassistant.components.device_tracker import SERVICE_SEE_PAYLOAD_SCHEMA +from homeassistant.const import (ATTR_DOMAIN, ATTR_SERVICE, ATTR_SERVICE_DATA) +from homeassistant.helpers import config_validation as cv + +DOMAIN = 'mobile_app' + +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 + +CONF_CLOUDHOOK_URL = 'cloudhook_url' +CONF_SECRET = 'secret' +CONF_USER_ID = 'user_id' + +DATA_DELETED_IDS = 'deleted_ids' +DATA_REGISTRATIONS = 'registrations' +DATA_STORE = 'store' + +ATTR_APP_COMPONENT = 'app_component' +ATTR_APP_DATA = 'app_data' +ATTR_APP_ID = 'app_id' +ATTR_APP_NAME = 'app_name' +ATTR_APP_VERSION = 'app_version' +ATTR_DEVICE_NAME = 'device_name' +ATTR_MANUFACTURER = 'manufacturer' +ATTR_MODEL = 'model' +ATTR_OS_VERSION = 'os_version' +ATTR_SUPPORTS_ENCRYPTION = 'supports_encryption' + +ATTR_EVENT_DATA = 'event_data' +ATTR_EVENT_TYPE = 'event_type' + +ATTR_TEMPLATE = 'template' +ATTR_TEMPLATE_VARIABLES = 'variables' + +ATTR_WEBHOOK_DATA = 'data' +ATTR_WEBHOOK_ENCRYPTED = 'encrypted' +ATTR_WEBHOOK_ENCRYPTED_DATA = 'encrypted_data' +ATTR_WEBHOOK_TYPE = 'type' + +WEBHOOK_TYPE_CALL_SERVICE = 'call_service' +WEBHOOK_TYPE_FIRE_EVENT = 'fire_event' +WEBHOOK_TYPE_RENDER_TEMPLATE = 'render_template' +WEBHOOK_TYPE_UPDATE_LOCATION = 'update_location' +WEBHOOK_TYPE_UPDATE_REGISTRATION = 'update_registration' + +WEBHOOK_TYPES = [WEBHOOK_TYPE_CALL_SERVICE, WEBHOOK_TYPE_FIRE_EVENT, + WEBHOOK_TYPE_RENDER_TEMPLATE, WEBHOOK_TYPE_UPDATE_LOCATION, + WEBHOOK_TYPE_UPDATE_REGISTRATION] + +REGISTRATION_SCHEMA = vol.Schema({ + vol.Optional(ATTR_APP_COMPONENT): cv.string, + vol.Optional(ATTR_APP_DATA, default={}): dict, + vol.Required(ATTR_APP_ID): cv.string, + vol.Optional(ATTR_APP_NAME): cv.string, + vol.Required(ATTR_APP_VERSION): cv.string, + vol.Required(ATTR_DEVICE_NAME): cv.string, + vol.Required(ATTR_MANUFACTURER): cv.string, + vol.Required(ATTR_MODEL): cv.string, + vol.Optional(ATTR_OS_VERSION): cv.string, + vol.Required(ATTR_SUPPORTS_ENCRYPTION, default=False): cv.boolean, +}) + +UPDATE_REGISTRATION_SCHEMA = vol.Schema({ + vol.Optional(ATTR_APP_DATA, default={}): dict, + vol.Required(ATTR_APP_VERSION): cv.string, + vol.Required(ATTR_DEVICE_NAME): cv.string, + vol.Required(ATTR_MANUFACTURER): cv.string, + vol.Required(ATTR_MODEL): cv.string, + vol.Optional(ATTR_OS_VERSION): cv.string, +}) + +WEBHOOK_PAYLOAD_SCHEMA = vol.Schema({ + vol.Required(ATTR_WEBHOOK_TYPE): cv.string, # vol.In(WEBHOOK_TYPES) + vol.Required(ATTR_WEBHOOK_DATA, default={}): dict, + vol.Optional(ATTR_WEBHOOK_ENCRYPTED, default=False): cv.boolean, + vol.Optional(ATTR_WEBHOOK_ENCRYPTED_DATA): cv.string, +}) + +CALL_SERVICE_SCHEMA = vol.Schema({ + vol.Required(ATTR_DOMAIN): cv.string, + vol.Required(ATTR_SERVICE): cv.string, + vol.Optional(ATTR_SERVICE_DATA, default={}): dict, +}) + +FIRE_EVENT_SCHEMA = vol.Schema({ + vol.Required(ATTR_EVENT_TYPE): cv.string, + vol.Optional(ATTR_EVENT_DATA, default={}): dict, +}) + +RENDER_TEMPLATE_SCHEMA = vol.Schema({ + vol.Required(ATTR_TEMPLATE): cv.string, + vol.Optional(ATTR_TEMPLATE_VARIABLES, default={}): dict, +}) + +WEBHOOK_SCHEMAS = { + WEBHOOK_TYPE_CALL_SERVICE: CALL_SERVICE_SCHEMA, + WEBHOOK_TYPE_FIRE_EVENT: FIRE_EVENT_SCHEMA, + WEBHOOK_TYPE_RENDER_TEMPLATE: RENDER_TEMPLATE_SCHEMA, + WEBHOOK_TYPE_UPDATE_LOCATION: SERVICE_SEE_PAYLOAD_SCHEMA, + WEBHOOK_TYPE_UPDATE_REGISTRATION: UPDATE_REGISTRATION_SCHEMA, +} diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py new file mode 100644 index 00000000000..82e6c1b6afa --- /dev/null +++ b/homeassistant/components/mobile_app/helpers.py @@ -0,0 +1,103 @@ +"""Helpers for mobile_app.""" +import logging +import json +from typing import Callable, Dict, Tuple + +from aiohttp.web import Response + +from homeassistant.core import Context +from homeassistant.helpers.typing import HomeAssistantType + +from .const import (ATTR_APP_DATA, ATTR_APP_ID, ATTR_APP_NAME, + ATTR_APP_VERSION, DATA_DELETED_IDS, ATTR_DEVICE_NAME, + ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, + DATA_REGISTRATIONS, ATTR_SUPPORTS_ENCRYPTION, + CONF_USER_ID, DOMAIN) + +_LOGGER = logging.getLogger(__name__) + + +def get_cipher() -> Tuple[int, Callable]: + """Return decryption function and length of key. + + Async friendly. + """ + from nacl.secret import SecretBox + from nacl.encoding import Base64Encoder + + def decrypt(ciphertext, key): + """Decrypt ciphertext using key.""" + return SecretBox(key).decrypt(ciphertext, encoder=Base64Encoder) + return (SecretBox.KEY_SIZE, decrypt) + + +def _decrypt_payload(key: str, ciphertext: str) -> Dict[str, str]: + """Decrypt encrypted payload.""" + try: + keylen, decrypt = get_cipher() + except OSError: + _LOGGER.warning( + "Ignoring encrypted payload because libsodium not installed") + return None + + if key is None: + _LOGGER.warning( + "Ignoring encrypted payload because no decryption key known") + return None + + key = key.encode("utf-8") + key = key[:keylen] + key = key.ljust(keylen, b'\0') + + try: + message = decrypt(ciphertext, key) + message = json.loads(message.decode("utf-8")) + _LOGGER.debug("Successfully decrypted mobile_app payload") + return message + except ValueError: + _LOGGER.warning("Ignoring encrypted payload because unable to decrypt") + return None + + +def registration_context(registration: Dict) -> Context: + """Generate a context from a request.""" + return Context(user_id=registration[CONF_USER_ID]) + + +def empty_okay_response(headers: Dict = None, status: int = 200) -> Response: + """Return a Response with empty JSON object and a 200.""" + return Response(body='{}', status=status, content_type='application/json', + headers=headers) + + +def supports_encryption() -> bool: + """Test if we support encryption.""" + try: + import nacl # noqa pylint: disable=unused-import + return True + except OSError: + return False + + +def safe_registration(registration: Dict) -> Dict: + """Return a registration without sensitive values.""" + # Sensitive values: webhook_id, secret, cloudhook_url + return { + ATTR_APP_DATA: registration[ATTR_APP_DATA], + ATTR_APP_ID: registration[ATTR_APP_ID], + ATTR_APP_NAME: registration[ATTR_APP_NAME], + ATTR_APP_VERSION: registration[ATTR_APP_VERSION], + ATTR_DEVICE_NAME: registration[ATTR_DEVICE_NAME], + ATTR_MANUFACTURER: registration[ATTR_MANUFACTURER], + ATTR_MODEL: registration[ATTR_MODEL], + ATTR_OS_VERSION: registration[ATTR_OS_VERSION], + ATTR_SUPPORTS_ENCRYPTION: registration[ATTR_SUPPORTS_ENCRYPTION], + } + + +def savable_state(hass: HomeAssistantType) -> Dict: + """Return a clean object containing things that should be saved.""" + return { + DATA_DELETED_IDS: hass.data[DOMAIN][DATA_DELETED_IDS], + DATA_REGISTRATIONS: hass.data[DOMAIN][DATA_REGISTRATIONS] + } diff --git a/homeassistant/components/mobile_app/http_api.py b/homeassistant/components/mobile_app/http_api.py new file mode 100644 index 00000000000..4ae473876fc --- /dev/null +++ b/homeassistant/components/mobile_app/http_api.py @@ -0,0 +1,78 @@ +"""Provides an HTTP API for mobile_app.""" +from typing import Dict + +from aiohttp.web import Response, Request + +from homeassistant.auth.util import generate_secret +from homeassistant.components.cloud import async_create_cloudhook +from homeassistant.components.http import HomeAssistantView +from homeassistant.components.http.data_validator import RequestDataValidator +from homeassistant.const import (HTTP_CREATED, HTTP_INTERNAL_SERVER_ERROR, + CONF_WEBHOOK_ID) + +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import HomeAssistantType + +from .const import (DATA_REGISTRATIONS, ATTR_SUPPORTS_ENCRYPTION, + CONF_CLOUDHOOK_URL, CONF_SECRET, CONF_USER_ID, + DOMAIN, REGISTRATION_SCHEMA) + +from .helpers import supports_encryption, savable_state + +from .webhook import setup_registration + + +def register_http_handlers(hass: HomeAssistantType, store: Store) -> bool: + """Register the HTTP handlers/views.""" + hass.http.register_view(RegistrationsView(store)) + return True + + +class RegistrationsView(HomeAssistantView): + """A view that accepts registration requests.""" + + url = '/api/mobile_app/registrations' + name = 'api:mobile_app:register' + + def __init__(self, store: Store) -> None: + """Initialize the view.""" + self._store = store + + @RequestDataValidator(REGISTRATION_SCHEMA) + async def post(self, request: Request, data: Dict) -> Response: + """Handle the POST request for registration.""" + hass = request.app['hass'] + + webhook_id = generate_secret() + + if "cloud" in hass.config.components: + cloudhook = await async_create_cloudhook(hass, webhook_id) + + if cloudhook is not None: + data[CONF_CLOUDHOOK_URL] = cloudhook[CONF_CLOUDHOOK_URL] + + data[CONF_WEBHOOK_ID] = webhook_id + + if data[ATTR_SUPPORTS_ENCRYPTION] and supports_encryption(): + secret = generate_secret(16) + + data[CONF_SECRET] = secret + + data[CONF_USER_ID] = request['hass_user'].id + + hass.data[DOMAIN][DATA_REGISTRATIONS][webhook_id] = data + + try: + await self._store.async_save(savable_state(hass)) + except HomeAssistantError: + return self.json_message("Error saving registration.", + HTTP_INTERNAL_SERVER_ERROR) + + setup_registration(hass, self._store, data) + + return self.json({ + CONF_CLOUDHOOK_URL: data.get(CONF_CLOUDHOOK_URL), + CONF_SECRET: data.get(CONF_SECRET), + CONF_WEBHOOK_ID: data[CONF_WEBHOOK_ID], + }, status_code=HTTP_CREATED) diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py new file mode 100644 index 00000000000..a5496c4395d --- /dev/null +++ b/homeassistant/components/mobile_app/webhook.py @@ -0,0 +1,162 @@ +"""Webhook handlers for mobile_app.""" +from functools import partial +import logging +from typing import Dict + +from aiohttp.web import HTTPBadRequest, json_response, Response, Request +import voluptuous as vol + +from homeassistant.components.device_tracker import (DOMAIN as DT_DOMAIN, + SERVICE_SEE as DT_SEE) +from homeassistant.components.webhook import async_register as webhook_register + +from homeassistant.const import (ATTR_DOMAIN, ATTR_SERVICE, ATTR_SERVICE_DATA, + CONF_WEBHOOK_ID, HTTP_BAD_REQUEST) +from homeassistant.core import EventOrigin +from homeassistant.exceptions import (HomeAssistantError, ServiceNotFound, + TemplateError) +from homeassistant.helpers import template +from homeassistant.helpers.discovery import load_platform +from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import HomeAssistantType + +from .const import (ATTR_APP_COMPONENT, DATA_DELETED_IDS, + ATTR_DEVICE_NAME, ATTR_EVENT_DATA, ATTR_EVENT_TYPE, + DATA_REGISTRATIONS, ATTR_TEMPLATE, ATTR_TEMPLATE_VARIABLES, + ATTR_WEBHOOK_DATA, ATTR_WEBHOOK_ENCRYPTED, + ATTR_WEBHOOK_ENCRYPTED_DATA, ATTR_WEBHOOK_TYPE, + CONF_SECRET, DOMAIN, WEBHOOK_PAYLOAD_SCHEMA, + WEBHOOK_SCHEMAS, WEBHOOK_TYPE_CALL_SERVICE, + WEBHOOK_TYPE_FIRE_EVENT, WEBHOOK_TYPE_RENDER_TEMPLATE, + WEBHOOK_TYPE_UPDATE_LOCATION, + WEBHOOK_TYPE_UPDATE_REGISTRATION) + +from .helpers import (_decrypt_payload, empty_okay_response, + registration_context, safe_registration, savable_state) + + +_LOGGER = logging.getLogger(__name__) + + +def register_deleted_webhooks(hass: HomeAssistantType, store: Store): + """Register previously deleted webhook IDs so we can return 410.""" + for deleted_id in hass.data[DOMAIN][DATA_DELETED_IDS]: + try: + webhook_register(hass, DOMAIN, "Deleted Webhook", deleted_id, + partial(handle_webhook, store)) + except ValueError: + pass + + +def setup_registration(hass: HomeAssistantType, store: Store, + registration: Dict) -> None: + """Register the webhook for a registration and loads the app component.""" + registration_name = 'Mobile App: {}'.format(registration[ATTR_DEVICE_NAME]) + webhook_id = registration[CONF_WEBHOOK_ID] + webhook_register(hass, DOMAIN, registration_name, webhook_id, + partial(handle_webhook, store)) + + if ATTR_APP_COMPONENT in registration: + load_platform(hass, registration[ATTR_APP_COMPONENT], DOMAIN, {}, + {DOMAIN: {}}) + + +async def handle_webhook(store: Store, hass: HomeAssistantType, + webhook_id: str, request: Request) -> Response: + """Handle webhook callback.""" + if webhook_id in hass.data[DOMAIN][DATA_DELETED_IDS]: + return Response(status=410) + + headers = {} + + registration = hass.data[DOMAIN][DATA_REGISTRATIONS][webhook_id] + + try: + req_data = await request.json() + except ValueError: + _LOGGER.warning('Received invalid JSON from mobile_app') + return empty_okay_response(status=HTTP_BAD_REQUEST) + + try: + req_data = WEBHOOK_PAYLOAD_SCHEMA(req_data) + except vol.Invalid as ex: + err = vol.humanize.humanize_error(req_data, ex) + _LOGGER.error('Received invalid webhook payload: %s', err) + return empty_okay_response() + + webhook_type = req_data[ATTR_WEBHOOK_TYPE] + + webhook_payload = req_data.get(ATTR_WEBHOOK_DATA, {}) + + if req_data[ATTR_WEBHOOK_ENCRYPTED]: + enc_data = req_data[ATTR_WEBHOOK_ENCRYPTED_DATA] + webhook_payload = _decrypt_payload(registration[CONF_SECRET], enc_data) + + try: + data = WEBHOOK_SCHEMAS[webhook_type](webhook_payload) + except vol.Invalid as ex: + err = vol.humanize.humanize_error(webhook_payload, ex) + _LOGGER.error('Received invalid webhook payload: %s', err) + return empty_okay_response(headers=headers) + + context = registration_context(registration) + + if webhook_type == WEBHOOK_TYPE_CALL_SERVICE: + try: + await hass.services.async_call(data[ATTR_DOMAIN], + data[ATTR_SERVICE], + data[ATTR_SERVICE_DATA], + blocking=True, context=context) + # noqa: E722 pylint: disable=broad-except + except (vol.Invalid, ServiceNotFound, Exception) as ex: + _LOGGER.error("Error when calling service during mobile_app " + "webhook (device name: %s): %s", + registration[ATTR_DEVICE_NAME], ex) + raise HTTPBadRequest() + + return empty_okay_response(headers=headers) + + if webhook_type == WEBHOOK_TYPE_FIRE_EVENT: + event_type = data[ATTR_EVENT_TYPE] + hass.bus.async_fire(event_type, data[ATTR_EVENT_DATA], + EventOrigin.remote, + context=context) + return empty_okay_response(headers=headers) + + if webhook_type == WEBHOOK_TYPE_RENDER_TEMPLATE: + try: + tpl = template.Template(data[ATTR_TEMPLATE], hass) + rendered = tpl.async_render(data.get(ATTR_TEMPLATE_VARIABLES)) + return json_response({"rendered": rendered}, headers=headers) + # noqa: E722 pylint: disable=broad-except + except (ValueError, TemplateError, Exception) as ex: + _LOGGER.error("Error when rendering template during mobile_app " + "webhook (device name: %s): %s", + registration[ATTR_DEVICE_NAME], ex) + return json_response(({"error": str(ex)}), status=HTTP_BAD_REQUEST, + headers=headers) + + if webhook_type == WEBHOOK_TYPE_UPDATE_LOCATION: + try: + await hass.services.async_call(DT_DOMAIN, + DT_SEE, data, + blocking=True, context=context) + # noqa: E722 pylint: disable=broad-except + except (vol.Invalid, ServiceNotFound, Exception) as ex: + _LOGGER.error("Error when updating location during mobile_app " + "webhook (device name: %s): %s", + registration[ATTR_DEVICE_NAME], ex) + return empty_okay_response(headers=headers) + + if webhook_type == WEBHOOK_TYPE_UPDATE_REGISTRATION: + new_registration = {**registration, **data} + + hass.data[DOMAIN][DATA_REGISTRATIONS][webhook_id] = new_registration + + try: + await store.async_save(savable_state(hass)) + except HomeAssistantError as ex: + _LOGGER.error("Error updating mobile_app registration: %s", ex) + return empty_okay_response() + + return json_response(safe_registration(new_registration)) diff --git a/homeassistant/components/mobile_app/websocket_api.py b/homeassistant/components/mobile_app/websocket_api.py new file mode 100644 index 00000000000..5f6a25cbcec --- /dev/null +++ b/homeassistant/components/mobile_app/websocket_api.py @@ -0,0 +1,143 @@ +"""Websocket API for mobile_app.""" +import voluptuous as vol + +from homeassistant.components.cloud import async_delete_cloudhook +from homeassistant.components.websocket_api import (ActiveConnection, + async_register_command, + async_response, + error_message, + result_message, + websocket_command, + ws_require_user) +from homeassistant.components.websocket_api.const import (ERR_INVALID_FORMAT, + ERR_NOT_FOUND, + ERR_UNAUTHORIZED) +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import config_validation as cv +from homeassistant.helpers.typing import HomeAssistantType + +from .const import (CONF_CLOUDHOOK_URL, CONF_USER_ID, DATA_DELETED_IDS, + DATA_REGISTRATIONS, DATA_STORE, DOMAIN) + +from .helpers import safe_registration, savable_state + + +def register_websocket_handlers(hass: HomeAssistantType) -> bool: + """Register the websocket handlers.""" + async_register_command(hass, websocket_get_registration) + + async_register_command(hass, websocket_get_user_registrations) + + async_register_command(hass, websocket_delete_registration) + + return True + + +@ws_require_user() +@async_response +@websocket_command({ + vol.Required('type'): 'mobile_app/get_registration', + vol.Required(CONF_WEBHOOK_ID): cv.string, +}) +async def websocket_get_registration( + hass: HomeAssistantType, connection: ActiveConnection, + msg: dict) -> None: + """Return the registration for the given webhook_id.""" + user = connection.user + + webhook_id = msg.get(CONF_WEBHOOK_ID) + if webhook_id is None: + connection.send_error(msg['id'], ERR_INVALID_FORMAT, + "Webhook ID not provided") + return + + registration = hass.data[DOMAIN][DATA_REGISTRATIONS].get(webhook_id) + + if registration is None: + connection.send_error(msg['id'], ERR_NOT_FOUND, + "Webhook ID not found in storage") + return + + if registration[CONF_USER_ID] != user.id and not user.is_admin: + return error_message( + msg['id'], ERR_UNAUTHORIZED, 'User is not registration owner') + + connection.send_message( + result_message(msg['id'], safe_registration(registration))) + + +@ws_require_user() +@async_response +@websocket_command({ + vol.Required('type'): 'mobile_app/get_user_registrations', + vol.Optional(CONF_USER_ID): cv.string, +}) +async def websocket_get_user_registrations( + hass: HomeAssistantType, connection: ActiveConnection, + msg: dict) -> None: + """Return all registrations or just registrations for given user ID.""" + user_id = msg.get(CONF_USER_ID, connection.user.id) + + if user_id != connection.user.id and not connection.user.is_admin: + # If user ID is provided and is not current user ID and current user + # isn't an admin user + connection.send_error(msg['id'], ERR_UNAUTHORIZED, "Unauthorized") + return + + user_registrations = [] + + for registration in hass.data[DOMAIN][DATA_REGISTRATIONS].values(): + if connection.user.is_admin or registration[CONF_USER_ID] is user_id: + user_registrations.append(safe_registration(registration)) + + connection.send_message( + result_message(msg['id'], user_registrations)) + + +@ws_require_user() +@async_response +@websocket_command({ + vol.Required('type'): 'mobile_app/delete_registration', + vol.Required(CONF_WEBHOOK_ID): cv.string, +}) +async def websocket_delete_registration(hass: HomeAssistantType, + connection: ActiveConnection, + msg: dict) -> None: + """Delete the registration for the given webhook_id.""" + user = connection.user + + webhook_id = msg.get(CONF_WEBHOOK_ID) + if webhook_id is None: + connection.send_error(msg['id'], ERR_INVALID_FORMAT, + "Webhook ID not provided") + return + + registration = hass.data[DOMAIN][DATA_REGISTRATIONS].get(webhook_id) + + if registration is None: + connection.send_error(msg['id'], ERR_NOT_FOUND, + "Webhook ID not found in storage") + return + + if registration[CONF_USER_ID] != user.id and not user.is_admin: + return error_message( + msg['id'], ERR_UNAUTHORIZED, 'User is not registration owner') + + del hass.data[DOMAIN][DATA_REGISTRATIONS][webhook_id] + + hass.data[DOMAIN][DATA_DELETED_IDS].append(webhook_id) + + store = hass.data[DOMAIN][DATA_STORE] + + try: + await store.async_save(savable_state(hass)) + except HomeAssistantError: + return error_message( + msg['id'], 'internal_error', 'Error deleting registration') + + if (CONF_CLOUDHOOK_URL in registration and + "cloud" in hass.config.components): + await async_delete_cloudhook(hass, webhook_id) + + connection.send_message(result_message(msg['id'], 'ok')) diff --git a/tests/components/mobile_app/__init__.py b/tests/components/mobile_app/__init__.py index becdc2841f3..02107eafb81 100644 --- a/tests/components/mobile_app/__init__.py +++ b/tests/components/mobile_app/__init__.py @@ -1 +1,53 @@ """Tests for mobile_app component.""" +# pylint: disable=redefined-outer-name,unused-import +import pytest + +from homeassistant.setup import async_setup_component + +from homeassistant.components.mobile_app.const import (DATA_DELETED_IDS, + DATA_REGISTRATIONS, + CONF_SECRET, + CONF_USER_ID, DOMAIN, + STORAGE_KEY, + STORAGE_VERSION) +from homeassistant.const import CONF_WEBHOOK_ID + + +@pytest.fixture +def webhook_client(hass, aiohttp_client, hass_storage, hass_admin_user): + """mobile_app mock client.""" + hass_storage[STORAGE_KEY] = { + 'version': STORAGE_VERSION, + 'data': { + DATA_REGISTRATIONS: { + 'mobile_app_test': { + CONF_SECRET: '58eb127991594dad934d1584bdee5f27', + 'supports_encryption': True, + CONF_WEBHOOK_ID: 'mobile_app_test', + 'device_name': 'Test Device', + CONF_USER_ID: hass_admin_user.id, + } + }, + DATA_DELETED_IDS: [], + } + } + + assert hass.loop.run_until_complete(async_setup_component( + hass, DOMAIN, { + DOMAIN: {} + })) + + return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + + +@pytest.fixture +async def authed_api_client(hass, hass_client): + """Provide an authenticated client for mobile_app to use.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + return await hass_client() + + +@pytest.fixture(autouse=True) +async def setup_ws(hass): + """Configure the websocket_api component.""" + assert await async_setup_component(hass, 'websocket_api', {}) diff --git a/tests/components/mobile_app/const.py b/tests/components/mobile_app/const.py new file mode 100644 index 00000000000..423af7929a4 --- /dev/null +++ b/tests/components/mobile_app/const.py @@ -0,0 +1,49 @@ +"""Constants for mobile_app tests.""" +CALL_SERVICE = { + 'type': 'call_service', + 'data': { + 'domain': 'test', + 'service': 'mobile_app', + 'service_data': { + 'foo': 'bar' + } + } +} + +FIRE_EVENT = { + 'type': 'fire_event', + 'data': { + 'event_type': 'test_event', + 'event_data': { + 'hello': 'yo world' + } + } +} + +REGISTER = { + 'app_data': {'foo': 'bar'}, + 'app_id': 'io.homeassistant.mobile_app_test', + 'app_name': 'Mobile App Tests', + 'app_version': '1.0.0', + 'device_name': 'Test 1', + 'manufacturer': 'mobile_app', + 'model': 'Test', + 'os_version': '1.0', + 'supports_encryption': True +} + +RENDER_TEMPLATE = { + 'type': 'render_template', + 'data': { + 'template': 'Hello world' + } +} + +UPDATE = { + 'app_data': {'foo': 'bar'}, + 'app_version': '2.0.0', + 'device_name': 'Test 1', + 'manufacturer': 'mobile_app', + 'model': 'Test', + 'os_version': '1.0' +} diff --git a/tests/components/mobile_app/test_http_api.py b/tests/components/mobile_app/test_http_api.py new file mode 100644 index 00000000000..96b1a9d8cf4 --- /dev/null +++ b/tests/components/mobile_app/test_http_api.py @@ -0,0 +1,59 @@ +"""Tests for the mobile_app HTTP API.""" +# pylint: disable=redefined-outer-name,unused-import +import pytest + +from homeassistant.components.mobile_app.const import CONF_SECRET +from homeassistant.const import CONF_WEBHOOK_ID + +from .const import REGISTER +from . import authed_api_client # noqa: F401 + + +async def test_registration(hass_client, authed_api_client): # noqa: F811 + """Test that registrations happen.""" + try: + # pylint: disable=unused-import + from nacl.secret import SecretBox # noqa: F401 + from nacl.encoding import Base64Encoder # noqa: F401 + except (ImportError, OSError): + pytest.skip("libnacl/libsodium is not installed") + return + + import json + + resp = await authed_api_client.post( + '/api/mobile_app/registrations', json=REGISTER + ) + + assert resp.status == 201 + register_json = await resp.json() + assert CONF_WEBHOOK_ID in register_json + assert CONF_SECRET in register_json + + keylen = SecretBox.KEY_SIZE + key = register_json[CONF_SECRET].encode("utf-8") + key = key[:keylen] + key = key.ljust(keylen, b'\0') + + payload = json.dumps({'template': 'Hello world'}).encode("utf-8") + + data = SecretBox(key).encrypt(payload, + encoder=Base64Encoder).decode("utf-8") + + container = { + 'type': 'render_template', + 'encrypted': True, + 'encrypted_data': data, + } + + webhook_client = await hass_client() + + resp = await webhook_client.post( + '/api/webhook/{}'.format(register_json[CONF_WEBHOOK_ID]), + json=container + ) + + assert resp.status == 200 + + webhook_json = await resp.json() + assert webhook_json == {'rendered': 'Hello world'} diff --git a/tests/components/mobile_app/test_init.py b/tests/components/mobile_app/test_init.py deleted file mode 100644 index d0c1ae02c6c..00000000000 --- a/tests/components/mobile_app/test_init.py +++ /dev/null @@ -1,275 +0,0 @@ -"""Test the mobile_app_http platform.""" -import pytest - -from homeassistant.setup import async_setup_component - -from homeassistant.const import CONF_WEBHOOK_ID -from homeassistant.components.mobile_app import (DOMAIN, STORAGE_KEY, - STORAGE_VERSION, - CONF_SECRET, CONF_USER_ID) -from homeassistant.core import callback - -from tests.common import async_mock_service - -FIRE_EVENT = { - 'type': 'fire_event', - 'data': { - 'event_type': 'test_event', - 'event_data': { - 'hello': 'yo world' - } - } -} - -RENDER_TEMPLATE = { - 'type': 'render_template', - 'data': { - 'template': 'Hello world' - } -} - -CALL_SERVICE = { - 'type': 'call_service', - 'data': { - 'domain': 'test', - 'service': 'mobile_app', - 'service_data': { - 'foo': 'bar' - } - } -} - -REGISTER = { - 'app_data': {'foo': 'bar'}, - 'app_id': 'io.homeassistant.mobile_app_test', - 'app_name': 'Mobile App Tests', - 'app_version': '1.0.0', - 'device_name': 'Test 1', - 'manufacturer': 'mobile_app', - 'model': 'Test', - 'os_version': '1.0', - 'supports_encryption': True -} - -UPDATE = { - 'app_data': {'foo': 'bar'}, - 'app_version': '2.0.0', - 'device_name': 'Test 1', - 'manufacturer': 'mobile_app', - 'model': 'Test', - 'os_version': '1.0' -} - -# pylint: disable=redefined-outer-name - - -@pytest.fixture -def mobile_app_client(hass, aiohttp_client, hass_storage, hass_admin_user): - """mobile_app mock client.""" - hass_storage[STORAGE_KEY] = { - 'version': STORAGE_VERSION, - 'data': { - 'mobile_app_test': { - CONF_SECRET: '58eb127991594dad934d1584bdee5f27', - 'supports_encryption': True, - CONF_WEBHOOK_ID: 'mobile_app_test', - 'device_name': 'Test Device', - CONF_USER_ID: hass_admin_user.id, - } - } - } - - assert hass.loop.run_until_complete(async_setup_component( - hass, DOMAIN, { - DOMAIN: {} - })) - - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) - - -@pytest.fixture -async def mock_api_client(hass, hass_client): - """Provide an authenticated client for mobile_app to use.""" - await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - return await hass_client() - - -async def test_handle_render_template(mobile_app_client): - """Test that we render templates properly.""" - resp = await mobile_app_client.post( - '/api/webhook/mobile_app_test', - json=RENDER_TEMPLATE - ) - - assert resp.status == 200 - - json = await resp.json() - assert json == {'rendered': 'Hello world'} - - -async def test_handle_call_services(hass, mobile_app_client): - """Test that we call services properly.""" - calls = async_mock_service(hass, 'test', 'mobile_app') - - resp = await mobile_app_client.post( - '/api/webhook/mobile_app_test', - json=CALL_SERVICE - ) - - assert resp.status == 200 - - assert len(calls) == 1 - - -async def test_handle_fire_event(hass, mobile_app_client): - """Test that we can fire events.""" - events = [] - - @callback - def store_event(event): - """Helepr to store events.""" - events.append(event) - - hass.bus.async_listen('test_event', store_event) - - resp = await mobile_app_client.post( - '/api/webhook/mobile_app_test', - json=FIRE_EVENT - ) - - assert resp.status == 200 - text = await resp.text() - assert text == "" - - assert len(events) == 1 - assert events[0].data['hello'] == 'yo world' - - -async def test_update_registration(mobile_app_client, hass_client): - """Test that a we can update an existing registration via webhook.""" - mock_api_client = await hass_client() - register_resp = await mock_api_client.post( - '/api/mobile_app/devices', json=REGISTER - ) - - assert register_resp.status == 201 - register_json = await register_resp.json() - - webhook_id = register_json[CONF_WEBHOOK_ID] - - update_container = { - 'type': 'update_registration', - 'data': UPDATE - } - - update_resp = await mobile_app_client.post( - '/api/webhook/{}'.format(webhook_id), json=update_container - ) - - assert update_resp.status == 200 - update_json = await update_resp.json() - assert update_json['app_version'] == '2.0.0' - assert CONF_WEBHOOK_ID not in update_json - assert CONF_SECRET not in update_json - - -async def test_returns_error_incorrect_json(mobile_app_client, caplog): - """Test that an error is returned when JSON is invalid.""" - resp = await mobile_app_client.post( - '/api/webhook/mobile_app_test', - data='not json' - ) - - assert resp.status == 400 - json = await resp.json() - assert json == [] - assert 'invalid JSON' in caplog.text - - -async def test_handle_decryption(mobile_app_client): - """Test that we can encrypt/decrypt properly.""" - try: - # pylint: disable=unused-import - from nacl.secret import SecretBox # noqa: F401 - from nacl.encoding import Base64Encoder # noqa: F401 - except (ImportError, OSError): - pytest.skip("libnacl/libsodium is not installed") - return - - import json - - keylen = SecretBox.KEY_SIZE - key = "58eb127991594dad934d1584bdee5f27".encode("utf-8") - key = key[:keylen] - key = key.ljust(keylen, b'\0') - - payload = json.dumps({'template': 'Hello world'}).encode("utf-8") - - data = SecretBox(key).encrypt(payload, - encoder=Base64Encoder).decode("utf-8") - - container = { - 'type': 'render_template', - 'encrypted': True, - 'encrypted_data': data, - } - - resp = await mobile_app_client.post( - '/api/webhook/mobile_app_test', - json=container - ) - - assert resp.status == 200 - - json = await resp.json() - assert json == {'rendered': 'Hello world'} - - -async def test_register_device(hass_client, mock_api_client): - """Test that a device can be registered.""" - try: - # pylint: disable=unused-import - from nacl.secret import SecretBox # noqa: F401 - from nacl.encoding import Base64Encoder # noqa: F401 - except (ImportError, OSError): - pytest.skip("libnacl/libsodium is not installed") - return - - import json - - resp = await mock_api_client.post( - '/api/mobile_app/devices', json=REGISTER - ) - - assert resp.status == 201 - register_json = await resp.json() - assert CONF_WEBHOOK_ID in register_json - assert CONF_SECRET in register_json - - keylen = SecretBox.KEY_SIZE - key = register_json[CONF_SECRET].encode("utf-8") - key = key[:keylen] - key = key.ljust(keylen, b'\0') - - payload = json.dumps({'template': 'Hello world'}).encode("utf-8") - - data = SecretBox(key).encrypt(payload, - encoder=Base64Encoder).decode("utf-8") - - container = { - 'type': 'render_template', - 'encrypted': True, - 'encrypted_data': data, - } - - mobile_app_client = await hass_client() - - resp = await mobile_app_client.post( - '/api/webhook/{}'.format(register_json[CONF_WEBHOOK_ID]), - json=container - ) - - assert resp.status == 200 - - webhook_json = await resp.json() - assert webhook_json == {'rendered': 'Hello world'} diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py new file mode 100644 index 00000000000..f2e838fb3cb --- /dev/null +++ b/tests/components/mobile_app/test_webhook.py @@ -0,0 +1,145 @@ +"""Webhook tests for mobile_app.""" +# pylint: disable=redefined-outer-name,unused-import +import pytest + +from homeassistant.components.mobile_app.const import CONF_SECRET +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.core import callback + +from tests.common import async_mock_service + +from . import authed_api_client, webhook_client # noqa: F401 + +from .const import (CALL_SERVICE, FIRE_EVENT, REGISTER, RENDER_TEMPLATE, + UPDATE) + + +async def test_webhook_handle_render_template(webhook_client): # noqa: F811 + """Test that we render templates properly.""" + resp = await webhook_client.post( + '/api/webhook/mobile_app_test', + json=RENDER_TEMPLATE + ) + + assert resp.status == 200 + + json = await resp.json() + assert json == {'rendered': 'Hello world'} + + +async def test_webhook_handle_call_services(hass, webhook_client): # noqa: E501 F811 + """Test that we call services properly.""" + calls = async_mock_service(hass, 'test', 'mobile_app') + + resp = await webhook_client.post( + '/api/webhook/mobile_app_test', + json=CALL_SERVICE + ) + + assert resp.status == 200 + + assert len(calls) == 1 + + +async def test_webhook_handle_fire_event(hass, webhook_client): # noqa: F811 + """Test that we can fire events.""" + events = [] + + @callback + def store_event(event): + """Helepr to store events.""" + events.append(event) + + hass.bus.async_listen('test_event', store_event) + + resp = await webhook_client.post( + '/api/webhook/mobile_app_test', + json=FIRE_EVENT + ) + + assert resp.status == 200 + json = await resp.json() + assert json == {} + + assert len(events) == 1 + assert events[0].data['hello'] == 'yo world' + + +async def test_webhook_update_registration(webhook_client, hass_client): # noqa: E501 F811 + """Test that a we can update an existing registration via webhook.""" + authed_api_client = await hass_client() # noqa: F811 + register_resp = await authed_api_client.post( + '/api/mobile_app/registrations', json=REGISTER + ) + + assert register_resp.status == 201 + register_json = await register_resp.json() + + webhook_id = register_json[CONF_WEBHOOK_ID] + + update_container = { + 'type': 'update_registration', + 'data': UPDATE + } + + update_resp = await webhook_client.post( + '/api/webhook/{}'.format(webhook_id), json=update_container + ) + + assert update_resp.status == 200 + update_json = await update_resp.json() + assert update_json['app_version'] == '2.0.0' + assert CONF_WEBHOOK_ID not in update_json + assert CONF_SECRET not in update_json + + +async def test_webhook_returns_error_incorrect_json(webhook_client, caplog): # noqa: E501 F811 + """Test that an error is returned when JSON is invalid.""" + resp = await webhook_client.post( + '/api/webhook/mobile_app_test', + data='not json' + ) + + assert resp.status == 400 + json = await resp.json() + assert json == {} + assert 'invalid JSON' in caplog.text + + +async def test_webhook_handle_decryption(webhook_client): # noqa: F811 + """Test that we can encrypt/decrypt properly.""" + try: + # pylint: disable=unused-import + from nacl.secret import SecretBox # noqa: F401 + from nacl.encoding import Base64Encoder # noqa: F401 + except (ImportError, OSError): + pytest.skip("libnacl/libsodium is not installed") + return + + import json + + keylen = SecretBox.KEY_SIZE + key = "58eb127991594dad934d1584bdee5f27".encode("utf-8") + key = key[:keylen] + key = key.ljust(keylen, b'\0') + + payload = json.dumps({'template': 'Hello world'}).encode("utf-8") + + data = SecretBox(key).encrypt(payload, + encoder=Base64Encoder).decode("utf-8") + + container = { + 'type': 'render_template', + 'encrypted': True, + 'encrypted_data': data, + } + + resp = await webhook_client.post( + '/api/webhook/mobile_app_test', + json=container + ) + + assert resp.status == 200 + + json = await resp.json() + assert json == {'rendered': 'Hello world'} diff --git a/tests/components/mobile_app/test_websocket_api.py b/tests/components/mobile_app/test_websocket_api.py new file mode 100644 index 00000000000..614fd33974b --- /dev/null +++ b/tests/components/mobile_app/test_websocket_api.py @@ -0,0 +1,107 @@ +"""Test the mobile_app websocket API.""" +# pylint: disable=redefined-outer-name,unused-import +from homeassistant.components.mobile_app.const import (CONF_SECRET, DOMAIN) +from homeassistant.components.websocket_api.const import TYPE_RESULT +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.setup import async_setup_component + +from . import authed_api_client, setup_ws, webhook_client # noqa: F401 +from .const import (CALL_SERVICE, REGISTER) + + +async def test_webocket_get_registration(hass, setup_ws, authed_api_client, # noqa: E501 F811 + hass_ws_client): + """Test get_registration websocket command.""" + register_resp = await authed_api_client.post( + '/api/mobile_app/registrations', json=REGISTER + ) + + assert register_resp.status == 201 + register_json = await register_resp.json() + assert CONF_WEBHOOK_ID in register_json + assert CONF_SECRET in register_json + + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'mobile_app/get_registration', + CONF_WEBHOOK_ID: register_json[CONF_WEBHOOK_ID], + }) + + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] + assert msg['result']['app_id'] == 'io.homeassistant.mobile_app_test' + + +async def test_webocket_get_user_registrations(hass, aiohttp_client, + hass_ws_client, + hass_read_only_access_token): + """Test get_user_registrations websocket command from admin perspective.""" + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + user_api_client = await aiohttp_client(hass.http.app, headers={ + 'Authorization': "Bearer {}".format(hass_read_only_access_token) + }) + + # First a read only user registers. + register_resp = await user_api_client.post( + '/api/mobile_app/registrations', json=REGISTER + ) + + assert register_resp.status == 201 + register_json = await register_resp.json() + assert CONF_WEBHOOK_ID in register_json + assert CONF_SECRET in register_json + + # Then the admin user attempts to access it. + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'mobile_app/get_user_registrations', + }) + + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] + assert len(msg['result']) == 1 + + +async def test_webocket_delete_registration(hass, hass_client, + hass_ws_client, webhook_client): # noqa: E501 F811 + """Test delete_registration websocket command.""" + authed_api_client = await hass_client() # noqa: F811 + register_resp = await authed_api_client.post( + '/api/mobile_app/registrations', json=REGISTER + ) + + assert register_resp.status == 201 + register_json = await register_resp.json() + assert CONF_WEBHOOK_ID in register_json + assert CONF_SECRET in register_json + + webhook_id = register_json[CONF_WEBHOOK_ID] + + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'mobile_app/delete_registration', + CONF_WEBHOOK_ID: webhook_id, + }) + + msg = await client.receive_json() + + assert msg['id'] == 5 + assert msg['type'] == TYPE_RESULT + assert msg['success'] + assert msg['result'] == 'ok' + + ensure_four_ten_gone = await webhook_client.post( + '/api/webhook/{}'.format(webhook_id), json=CALL_SERVICE + ) + + assert ensure_four_ten_gone.status == 410 From bbd01968ba7f86d67386eaeb03ac757d18f00253 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Fri, 8 Mar 2019 23:56:37 -0800 Subject: [PATCH 152/291] Override http.trusted_networks by auth_provider.trusted_networks (#21844) --- homeassistant/components/http/__init__.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 4928ae2ab17..93afbc04396 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -211,6 +211,14 @@ class HomeAssistantHTTP: "legacy_api_password support has been enabled. If you don't " "require it, remove the 'api_password' from your http config.") + for prv in hass.auth.auth_providers: + if prv.type == 'trusted_networks': + # auth_provider.trusted_networks will override + # http.trusted_networks, http.trusted_networks will be + # removed from future release + trusted_networks = prv.trusted_networks + break + setup_auth(app, trusted_networks, api_password if hass.auth.support_legacy else None) From be989ebb7e74cbe889e109bf3300e75fd9df87d9 Mon Sep 17 00:00:00 2001 From: Hackashaq666 Date: Sat, 9 Mar 2019 11:17:28 -0500 Subject: [PATCH 153/291] Update honeywell.py to read current humidity for US Thermostats (#21728) * Update honeywell.py Add thermostat humidity reading available in somecomfort for US thermostats. * Update honeywell.py * Update honeywell.py --- homeassistant/components/climate/honeywell.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/climate/honeywell.py b/homeassistant/components/climate/honeywell.py index dbcbebff566..a76f992a76a 100644 --- a/homeassistant/components/climate/honeywell.py +++ b/homeassistant/components/climate/honeywell.py @@ -273,6 +273,11 @@ class HoneywellUSThermostat(ClimateDevice): """Return the current temperature.""" return self._device.current_temperature + @property + def current_humidity(self): + """Return the current humidity.""" + return self._device.current_humidity + @property def target_temperature(self): """Return the temperature we try to reach.""" From 458548daeca01e2e1b71a795bfe7348f7bad69d0 Mon Sep 17 00:00:00 2001 From: Willem Burgers Date: Sat, 9 Mar 2019 17:51:15 +0100 Subject: [PATCH 154/291] Fix TypeError (#21734) * timediff is of type timedelta. Divide by timedelta does not work. - convert a timedelta to int - make sure the test inputs real timestamps * Convert the total_seconds to decimal and round the result readings are of type Decimal, so fix test to reflect that * split line into multiple statements Line too long * use total_seconds instead of timediff * Make both values float instead of Decimal --- homeassistant/components/sensor/dsmr.py | 3 ++- tests/components/sensor/test_dsmr.py | 11 ++++++----- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/sensor/dsmr.py b/homeassistant/components/sensor/dsmr.py index 8a24793a157..6319a68b0c8 100644 --- a/homeassistant/components/sensor/dsmr.py +++ b/homeassistant/components/sensor/dsmr.py @@ -351,7 +351,8 @@ class DerivativeDSMREntity(DSMREntity): # Recalculate the rate diff = current_reading - self._previous_reading timediff = timestamp - self._previous_timestamp - self._state = diff / timediff * 3600 + total_seconds = timediff.total_seconds() + self._state = round(float(diff) / total_seconds * 3600, 3) self._previous_reading = current_reading self._previous_timestamp = timestamp diff --git a/tests/components/sensor/test_dsmr.py b/tests/components/sensor/test_dsmr.py index 69e05df1d92..c2ea61e5bb4 100644 --- a/tests/components/sensor/test_dsmr.py +++ b/tests/components/sensor/test_dsmr.py @@ -6,6 +6,7 @@ Entity to be updated with new values. """ import asyncio +import datetime from decimal import Decimal from unittest.mock import Mock @@ -104,8 +105,8 @@ def test_derivative(): entity.telegram = { '1.0.0': MBusObject([ - {'value': 1551642213}, - {'value': 745.695, 'unit': 'm3'}, + {'value': datetime.datetime.fromtimestamp(1551642213)}, + {'value': Decimal(745.695), 'unit': 'm3'}, ]) } yield from entity.async_update() @@ -115,13 +116,13 @@ def test_derivative(): entity.telegram = { '1.0.0': MBusObject([ - {'value': 1551642543}, - {'value': 745.698, 'unit': 'm3'}, + {'value': datetime.datetime.fromtimestamp(1551642543)}, + {'value': Decimal(745.698), 'unit': 'm3'}, ]) } yield from entity.async_update() - assert abs(entity.state - 0.03272) < 0.00001, \ + assert abs(entity.state - 0.033) < 0.00001, \ 'state should be hourly usage calculated from first and second update' assert entity.unit_of_measurement == 'm3/h' From fc818267634aed998bba7733de4eb0021669167e Mon Sep 17 00:00:00 2001 From: emontnemery Date: Sat, 9 Mar 2019 18:52:22 +0100 Subject: [PATCH 155/291] Introduce Entity.async_write_ha_state() to not miss state transition (#21590) * Copy state in schedule_update_ha_state * Lint * Fix broken test * Review comment, improve docstring * Preserve order of state updates * Rewrite * Break up async_update_ha_state * Update binary_sensor.py * Review comments * Update docstring * hass -> ha * Update entity.py * Update entity.py --- .../components/mqtt/binary_sensor.py | 6 ++-- homeassistant/helpers/entity.py | 35 +++++++++++++++++-- tests/components/cast/test_media_player.py | 4 +-- 3 files changed, 38 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index cb93712776c..103958376c0 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -117,7 +117,7 @@ class MqttBinarySensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, await self.availability_discovery_update(config) await self.device_info_discovery_update(config) await self._subscribe_topics() - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def _subscribe_topics(self): """(Re)Subscribe to topics.""" @@ -130,7 +130,7 @@ class MqttBinarySensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, """Switch device off after a delay.""" self._delay_listener = None self._state = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() @callback def state_message_received(_topic, payload, _qos): @@ -159,7 +159,7 @@ class MqttBinarySensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._delay_listener = evt.async_call_later( self.hass, off_delay, off_delay_listener) - self.async_schedule_update_ha_state() + self.async_write_ha_state() self._sub_state = await subscription.async_subscribe_topics( self.hass, self._sub_state, diff --git a/homeassistant/helpers/entity.py b/homeassistant/helpers/entity.py index dd9677f6515..4ef5513baf7 100644 --- a/homeassistant/helpers/entity.py +++ b/homeassistant/helpers/entity.py @@ -222,6 +222,23 @@ class Entity: _LOGGER.exception("Update for %s fails", self.entity_id) return + self._async_write_ha_state() + + @callback + def async_write_ha_state(self): + """Write the state to the state machine.""" + if self.hass is None: + raise RuntimeError("Attribute hass is None for {}".format(self)) + + if self.entity_id is None: + raise NoEntitySpecifiedError( + "No entity id specified for entity {}".format(self.name)) + + self._async_write_ha_state() + + @callback + def _async_write_ha_state(self): + """Write the state to the state machine.""" start = timer() if not self.available: @@ -311,13 +328,27 @@ class Entity: def schedule_update_ha_state(self, force_refresh=False): """Schedule an update ha state change task. - That avoid executor dead looks. + Scheduling the update avoids executor deadlocks. + + Entity state and attributes are read when the update ha state change + task is executed. + If state is changed more than once before the ha state change task has + been executed, the intermediate state transitions will be missed. """ self.hass.add_job(self.async_update_ha_state(force_refresh)) @callback def async_schedule_update_ha_state(self, force_refresh=False): - """Schedule an update ha state change task.""" + """Schedule an update ha state change task. + + This method must be run in the event loop. + Scheduling the update avoids executor deadlocks. + + Entity state and attributes are read when the update ha state change + task is executed. + If state is changed more than once before the ha state change task has + been executed, the intermediate state transitions will be missed. + """ self.hass.async_create_task(self.async_update_ha_state(force_refresh)) async def async_device_update(self, warning=True): diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index b5d6220904f..66a975a226e 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -275,16 +275,16 @@ async def test_entity_media_states(hass: HomeAssistantType): state = hass.states.get('media_player.speaker') assert state.state == 'playing' - entity.new_media_status(media_status) media_status.player_is_playing = False media_status.player_is_paused = True + entity.new_media_status(media_status) await hass.async_block_till_done() state = hass.states.get('media_player.speaker') assert state.state == 'paused' - entity.new_media_status(media_status) media_status.player_is_paused = False media_status.player_is_idle = True + entity.new_media_status(media_status) await hass.async_block_till_done() state = hass.states.get('media_player.speaker') assert state.state == 'idle' From 4d9cf15c45e90310cafd624740b48cf5a6b4c11a Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Sat, 9 Mar 2019 19:00:10 +0100 Subject: [PATCH 156/291] Fix authorization header in cors (#21662) * Fix authorization headers in cors * Use aiohttp authorization header instead of custom const --- homeassistant/components/http/cors.py | 4 ++-- tests/components/http/test_cors.py | 14 +++++++++++++- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/http/cors.py b/homeassistant/components/http/cors.py index 6da3b0e51d7..1ef70b5e022 100644 --- a/homeassistant/components/http/cors.py +++ b/homeassistant/components/http/cors.py @@ -1,5 +1,5 @@ """Provide CORS support for the HTTP component.""" -from aiohttp.hdrs import ACCEPT, CONTENT_TYPE, ORIGIN +from aiohttp.hdrs import ACCEPT, CONTENT_TYPE, ORIGIN, AUTHORIZATION from homeassistant.const import ( HTTP_HEADER_HA_AUTH, HTTP_HEADER_X_REQUESTED_WITH) @@ -7,7 +7,7 @@ from homeassistant.core import callback ALLOWED_CORS_HEADERS = [ ORIGIN, ACCEPT, HTTP_HEADER_X_REQUESTED_WITH, CONTENT_TYPE, - HTTP_HEADER_HA_AUTH] + HTTP_HEADER_HA_AUTH, AUTHORIZATION] @callback diff --git a/tests/components/http/test_cors.py b/tests/components/http/test_cors.py index c95146d5cca..e17fb105efe 100644 --- a/tests/components/http/test_cors.py +++ b/tests/components/http/test_cors.py @@ -7,11 +7,14 @@ from aiohttp.hdrs import ( ACCESS_CONTROL_ALLOW_HEADERS, ACCESS_CONTROL_REQUEST_HEADERS, ACCESS_CONTROL_REQUEST_METHOD, + AUTHORIZATION, ORIGIN ) import pytest -from homeassistant.const import HTTP_HEADER_HA_AUTH +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 @@ -84,6 +87,15 @@ async def test_cors_requests(client): assert req.headers[ACCESS_CONTROL_ALLOW_ORIGIN] == \ TRUSTED_ORIGIN + # With auth token in headers + req = await client.get('/', headers={ + AUTHORIZATION: 'Bearer some-token', + ORIGIN: TRUSTED_ORIGIN + }) + assert req.status == 200 + assert req.headers[ACCESS_CONTROL_ALLOW_ORIGIN] == \ + TRUSTED_ORIGIN + async def test_cors_preflight_allowed(client): """Test cross origin resource sharing preflight (OPTIONS) request.""" From 226be659105405ab7ea0a28b2e7aff849a7864e7 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Sat, 9 Mar 2019 12:04:13 -0800 Subject: [PATCH 157/291] Only commit if need. (#21848) --- homeassistant/components/recorder/util.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/recorder/util.py b/homeassistant/components/recorder/util.py index 449f910fda9..c96cfe78dd2 100644 --- a/homeassistant/components/recorder/util.py +++ b/homeassistant/components/recorder/util.py @@ -20,12 +20,16 @@ def session_scope(*, hass=None, session=None): if session is None: raise RuntimeError('Session required') + need_rollback = False try: yield session - session.commit() + if session.transaction: + need_rollback = True + session.commit() except Exception as err: # pylint: disable=broad-except _LOGGER.error("Error executing query: %s", err) - session.rollback() + if need_rollback: + session.rollback() raise finally: session.close() From ac5ccd651cfaa8a0a144ba1fde82b22c0be44d0f Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Sat, 9 Mar 2019 15:14:58 -0500 Subject: [PATCH 158/291] Bump quirks for ZHA and handle resulting battery % change (#21869) * bump quirks and handle battery change * move inside guard * round battery --- homeassistant/components/zha/__init__.py | 2 +- homeassistant/components/zha/device_entity.py | 3 +++ requirements_all.txt | 2 +- 3 files changed, 5 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index cafbae13421..ec4d9082dc5 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -32,7 +32,7 @@ REQUIREMENTS = [ 'bellows-homeassistant==0.7.1', 'zigpy-homeassistant==0.3.0', 'zigpy-xbee-homeassistant==0.1.2', - 'zha-quirks==0.0.6', + 'zha-quirks==0.0.7', 'zigpy-deconz==0.1.2' ] diff --git a/homeassistant/components/zha/device_entity.py b/homeassistant/components/zha/device_entity.py index 5632c849d59..b3fe7a72526 100644 --- a/homeassistant/components/zha/device_entity.py +++ b/homeassistant/components/zha/device_entity.py @@ -147,4 +147,7 @@ class ZhaDeviceEntity(ZhaEntity): battery = await self._battery_channel.get_attribute_value( 'battery_percentage_remaining') if battery is not None: + # per zcl specs battery percent is reported at 200% ¯\_(ツ)_/¯ + battery = battery / 2 + battery = int(round(battery)) self._device_state_attributes['battery_level'] = battery diff --git a/requirements_all.txt b/requirements_all.txt index ac1883a4b73..b845f432c59 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1826,7 +1826,7 @@ zengge==0.2 zeroconf==0.21.3 # homeassistant.components.zha -zha-quirks==0.0.6 +zha-quirks==0.0.7 # homeassistant.components.climate.zhong_hong zhong_hong_hvac==1.0.9 From f4f0d363cad626846ef359dcadf3da3d45d32005 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 9 Mar 2019 12:15:16 -0800 Subject: [PATCH 159/291] Better cloud check (#21875) --- homeassistant/components/cloud/__init__.py | 21 +++++++--- .../components/mobile_app/http_api.py | 8 ++-- tests/components/cloud/test_init.py | 39 +++++++++++++++---- 3 files changed, 49 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 85ed7391ec7..954cc8385f6 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -91,27 +91,36 @@ class CloudNotAvailable(HomeAssistantError): @bind_hass @callback -def async_is_logged_in(hass): +def async_is_logged_in(hass) -> bool: """Test if user is logged in.""" return DOMAIN in hass.data and hass.data[DOMAIN].is_logged_in @bind_hass -async def async_create_cloudhook(hass, webhook_id): +@callback +def async_active_subscription(hass) -> bool: + """Test if user has an active subscription.""" + return \ + async_is_logged_in(hass) and not hass.data[DOMAIN].subscription_expired + + +@bind_hass +async def async_create_cloudhook(hass, webhook_id: str) -> str: """Create a cloudhook.""" if not async_is_logged_in(hass): raise CloudNotAvailable - return await hass.data[DOMAIN].cloudhooks.async_create(webhook_id) + hook = await hass.data[DOMAIN].cloudhooks.async_create(webhook_id, True) + return hook['cloudhook_url'] @bind_hass -async def async_delete_cloudhook(hass, webhook_id): +async def async_delete_cloudhook(hass, webhook_id: str) -> None: """Delete a cloudhook.""" - if not async_is_logged_in(hass): + if DOMAIN not in hass.data: raise CloudNotAvailable - return await hass.data[DOMAIN].cloudhooks.async_delete(webhook_id) + await hass.data[DOMAIN].cloudhooks.async_delete(webhook_id) def is_cloudhook_request(request): diff --git a/homeassistant/components/mobile_app/http_api.py b/homeassistant/components/mobile_app/http_api.py index 4ae473876fc..8de1d954605 100644 --- a/homeassistant/components/mobile_app/http_api.py +++ b/homeassistant/components/mobile_app/http_api.py @@ -46,11 +46,9 @@ class RegistrationsView(HomeAssistantView): webhook_id = generate_secret() - if "cloud" in hass.config.components: - cloudhook = await async_create_cloudhook(hass, webhook_id) - - if cloudhook is not None: - data[CONF_CLOUDHOOK_URL] = cloudhook[CONF_CLOUDHOOK_URL] + if hass.components.cloud.async_active_subscription(): + data[CONF_CLOUDHOOK_URL] = \ + await async_create_cloudhook(hass, webhook_id) data[CONF_WEBHOOK_ID] = webhook_id diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 2418e091740..0780826afd3 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -190,10 +190,9 @@ async def test_create_cloudhook_no_login(hass): assert len(mock_create.mock_calls) == 0 -async def test_delete_cloudhook_no_login(hass): +async def test_delete_cloudhook_no_setup(hass): """Test delete cloudhook when not logged in.""" - assert await async_setup_component(hass, 'cloud', {}) - coro = mock_coro({'yo': 'hey'}) + coro = mock_coro() with patch('homeassistant.components.cloud.cloudhooks.' 'Cloudhooks.async_delete', return_value=coro) as mock_delete, \ pytest.raises(cloud.CloudNotAvailable): @@ -205,28 +204,27 @@ async def test_delete_cloudhook_no_login(hass): async def test_create_cloudhook(hass): """Test create cloudhook.""" assert await async_setup_component(hass, 'cloud', {}) - coro = mock_coro({'yo': 'hey'}) + coro = mock_coro({'cloudhook_url': 'hello'}) with patch('homeassistant.components.cloud.cloudhooks.' 'Cloudhooks.async_create', return_value=coro) as mock_create, \ patch('homeassistant.components.cloud.async_is_logged_in', return_value=True): result = await hass.components.cloud.async_create_cloudhook('hello') - assert result == {'yo': 'hey'} + assert result == 'hello' assert len(mock_create.mock_calls) == 1 async def test_delete_cloudhook(hass): """Test delete cloudhook.""" assert await async_setup_component(hass, 'cloud', {}) - coro = mock_coro({'yo': 'hey'}) + coro = mock_coro() with patch('homeassistant.components.cloud.cloudhooks.' 'Cloudhooks.async_delete', return_value=coro) as mock_delete, \ patch('homeassistant.components.cloud.async_is_logged_in', return_value=True): - result = await hass.components.cloud.async_delete_cloudhook('hello') + await hass.components.cloud.async_delete_cloudhook('hello') - assert result == {'yo': 'hey'} assert len(mock_delete.mock_calls) == 1 @@ -244,3 +242,28 @@ async def test_async_logged_in(hass): # Cloud loaded, logged in assert hass.components.cloud.async_is_logged_in() is True + + +async def test_async_active_subscription(hass): + """Test if is_logged_in works.""" + # Cloud not loaded + assert hass.components.cloud.async_active_subscription() is False + + assert await async_setup_component(hass, 'cloud', {}) + + # Cloud loaded, not logged in + assert hass.components.cloud.async_active_subscription() is False + + hass.data['cloud'].id_token = "some token" + + # Cloud loaded, logged in, invalid sub + with patch('jose.jwt.get_unverified_claims', return_value={ + 'custom:sub-exp': '{}-12-31'.format(utcnow().year - 1) + }): + assert hass.components.cloud.async_active_subscription() is False + + # Cloud loaded, logged in, valid sub + with patch('jose.jwt.get_unverified_claims', return_value={ + 'custom:sub-exp': '{}-01-01'.format(utcnow().year + 1) + }): + assert hass.components.cloud.async_active_subscription() is True From 896075fa1c62308e9d11ae857fbf853eb4672abb Mon Sep 17 00:00:00 2001 From: Zak Date: Sat, 9 Mar 2019 15:12:29 -0600 Subject: [PATCH 160/291] Add ClearPass Policy Manger device tracker (#21673) * Adding ClearPass Policy Manger device tracker. Amending author * Cleaned redundant code * Updated .coveragerc * Updated requirements_all.txt * Implemented suggested changes partially. * Implemented more suggested changes. * Hound was unhappy * Implement further suggested changes. * Make Hound happy. * Satisfy Travic CI * Satisfy Travis CI #2 * Hound barking * pylint else: return * Implemented suggested changes minus AccessToken * Removed access token logging * Removed throttle import * Removed period from debug string * Make travis happy :( * Moved source to new component structure. * Forgot to rename source. --- .coveragerc | 1 + .../components/cppm_tracker/__init__.py | 1 + .../components/cppm_tracker/device_tracker.py | 85 +++++++++++++++++++ requirements_all.txt | 3 + 4 files changed, 90 insertions(+) create mode 100644 homeassistant/components/cppm_tracker/__init__.py create mode 100755 homeassistant/components/cppm_tracker/device_tracker.py diff --git a/.coveragerc b/.coveragerc index 1b0058a9f98..fdf9c2962cc 100644 --- a/.coveragerc +++ b/.coveragerc @@ -110,6 +110,7 @@ omit = homeassistant/components/device_tracker/bt_home_hub_5.py homeassistant/components/device_tracker/bt_smarthub.py homeassistant/components/device_tracker/cisco_ios.py + homeassistant/components/cppm_tracker/device_tracker.py homeassistant/components/device_tracker/ddwrt.py homeassistant/components/device_tracker/fritz.py homeassistant/components/device_tracker/google_maps.py diff --git a/homeassistant/components/cppm_tracker/__init__.py b/homeassistant/components/cppm_tracker/__init__.py new file mode 100644 index 00000000000..cb6aa87881d --- /dev/null +++ b/homeassistant/components/cppm_tracker/__init__.py @@ -0,0 +1 @@ +"""Add support for ClearPass Policy Manager.""" diff --git a/homeassistant/components/cppm_tracker/device_tracker.py b/homeassistant/components/cppm_tracker/device_tracker.py new file mode 100755 index 00000000000..2ca0ebf62e5 --- /dev/null +++ b/homeassistant/components/cppm_tracker/device_tracker.py @@ -0,0 +1,85 @@ +""" +Support for ClearPass Policy Manager. + +Allows tracking devices with CPPM. +""" +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, DOMAIN +) +from homeassistant.const import ( + CONF_HOST, CONF_API_KEY +) + +REQUIREMENTS = ['clearpasspy==1.0.2'] + +SCAN_INTERVAL = timedelta(seconds=120) + +CLIENT_ID = 'client_id' + +GRANT_TYPE = 'client_credentials' + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_HOST): cv.string, + vol.Required(CLIENT_ID): cv.string, + vol.Required(CONF_API_KEY): cv.string, +}) + +_LOGGER = logging.getLogger(__name__) + + +def get_scanner(hass, config): + """Initialize Scanner.""" + from clearpasspy import ClearPass + data = { + 'server': config[DOMAIN][CONF_HOST], + 'grant_type': GRANT_TYPE, + 'secret': config[DOMAIN][CONF_API_KEY], + 'client': config[DOMAIN][CLIENT_ID] + } + cppm = ClearPass(data) + if cppm.access_token is None: + return None + _LOGGER.debug("Successfully received Access Token") + return CPPMDeviceScanner(cppm) + + +class CPPMDeviceScanner(DeviceScanner): + """Initialize class.""" + + def __init__(self, cppm): + """Initialize class.""" + self._cppm = cppm + self.results = None + + def scan_devices(self): + """Initialize scanner.""" + self.get_cppm_data() + return [device['mac'] for device in self.results] + + def get_device_name(self, device): + """Retrieve device name.""" + name = next(( + result['name'] for result in self.results + if result['mac'] == device), None) + return name + + def get_cppm_data(self): + """Retrieve data from Aruba Clearpass and return parsed result.""" + endpoints = self._cppm.get_endpoints(100)['_embedded']['items'] + devices = [] + for item in endpoints: + if self._cppm.online_status(item['mac_address']): + device = { + 'mac': item['mac_address'], + 'name': item['mac_address'] + } + devices.append(device) + else: + continue + _LOGGER.debug("Devices: %s", devices) + self.results = devices diff --git a/requirements_all.txt b/requirements_all.txt index b845f432c59..8476bc1f1ab 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -271,6 +271,9 @@ ciscomobilityexpress==0.1.2 # homeassistant.components.notify.ciscospark ciscosparkapi==0.4.2 +# homeassistant.components.cppm_tracker.device_tracker +clearpasspy==1.0.2 + # homeassistant.components.sensor.co2signal co2signal==0.4.2 From 2d2abc783151ea7a876c6ffd0cbc9e5062834c9a Mon Sep 17 00:00:00 2001 From: Daniel Shokouhi Date: Sat, 9 Mar 2019 14:06:35 -0800 Subject: [PATCH 161/291] Fix botvac when no map exists (#21877) --- homeassistant/components/neato/vacuum.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/neato/vacuum.py b/homeassistant/components/neato/vacuum.py index 990c79552b4..2f2f3904947 100644 --- a/homeassistant/components/neato/vacuum.py +++ b/homeassistant/components/neato/vacuum.py @@ -187,10 +187,12 @@ class NeatoConnectedVacuum(StateVacuumDevice): if self._robot_has_map: if self._state['availableServices']['maps'] != "basic-1": - robot_map_id = self._robot_maps[self._robot_serial][0]['id'] + if self._robot_maps[self._robot_serial]: + robot_map_id = ( + self._robot_maps[self._robot_serial][0]['id']) - self._robot_boundaries = self.robot.get_map_boundaries( - robot_map_id).json() + self._robot_boundaries = self.robot.get_map_boundaries( + robot_map_id).json() @property def name(self): From c927cd9c14d0f4de09d7ec10b269321569d21560 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sat, 9 Mar 2019 18:43:16 -0600 Subject: [PATCH 162/291] Add SmartThings climate support for Air Conditioners (#21840) * Add support for Samsung Air Conditioners to Climate * Changes per review feedback. * Fix py3.5 compat list sorting in test --- .../components/smartthings/climate.py | 204 ++++++++++++++++-- tests/components/smartthings/test_climate.py | 152 +++++++++++-- 2 files changed, 324 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index f660e905274..bcf2dc02cb0 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -7,9 +7,10 @@ from homeassistant.components.climate import ( DOMAIN as CLIMATE_DOMAIN, ClimateDevice) from homeassistant.components.climate.const import ( ATTR_OPERATION_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - STATE_AUTO, STATE_COOL, STATE_ECO, STATE_HEAT, SUPPORT_FAN_MODE, - SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, - SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW) + STATE_AUTO, STATE_COOL, STATE_DRY, STATE_ECO, STATE_FAN_ONLY, STATE_HEAT, + SUPPORT_FAN_MODE, SUPPORT_ON_OFF, SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_HIGH, + SUPPORT_TARGET_TEMPERATURE_LOW) from homeassistant.const import ( ATTR_TEMPERATURE, STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT) @@ -35,6 +36,25 @@ STATE_TO_MODE = { STATE_HEAT: 'heat', STATE_OFF: 'off' } + +AC_MODE_TO_STATE = { + 'auto': STATE_AUTO, + 'cool': STATE_COOL, + 'dry': STATE_DRY, + 'heat': STATE_HEAT, + 'fanOnly': STATE_FAN_ONLY +} +STATE_TO_AC_MODE = {v: k for k, v in AC_MODE_TO_STATE.items()} + +SPEED_TO_FAN_MODE = { + 0: 'auto', + 1: 'low', + 2: 'medium', + 3: 'high', + 4: 'turbo' +} +FAN_MODE_TO_SPEED = {v: k for k, v in SPEED_TO_FAN_MODE.items()} + UNIT_MAP = { 'C': TEMP_CELSIUS, 'F': TEMP_FAHRENHEIT @@ -51,10 +71,26 @@ async def async_setup_platform( async def async_setup_entry(hass, config_entry, async_add_entities): """Add climate entities for a config entry.""" + from pysmartthings import Capability + + ac_capabilities = [ + Capability.air_conditioner_mode, + Capability.fan_speed, + Capability.switch, + Capability.temperature_measurement, + Capability.thermostat_cooling_setpoint] + broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] - async_add_entities( - [SmartThingsThermostat(device) for device in broker.devices.values() - if broker.any_assigned(device.device_id, CLIMATE_DOMAIN)], True) + entities = [] + for device in broker.devices.values(): + if not broker.any_assigned(device.device_id, CLIMATE_DOMAIN): + continue + if all(capability in device.capabilities + for capability in ac_capabilities): + entities.append(SmartThingsAirConditioner(device)) + else: + entities.append(SmartThingsThermostat(device)) + async_add_entities(entities, True) def get_capabilities(capabilities: Sequence[str]) -> Optional[Sequence[str]]: @@ -62,28 +98,41 @@ def get_capabilities(capabilities: Sequence[str]) -> Optional[Sequence[str]]: from pysmartthings import Capability supported = [ - Capability.thermostat, + Capability.air_conditioner_mode, + Capability.demand_response_load_control, + Capability.fan_speed, + Capability.power_consumption_report, + Capability.relative_humidity_measurement, + Capability.switch, Capability.temperature_measurement, + Capability.thermostat, Capability.thermostat_cooling_setpoint, + Capability.thermostat_fan_mode, Capability.thermostat_heating_setpoint, Capability.thermostat_mode, - Capability.relative_humidity_measurement, - Capability.thermostat_operating_state, - Capability.thermostat_fan_mode - ] + Capability.thermostat_operating_state] # Can have this legacy/deprecated capability if Capability.thermostat in capabilities: return supported - # Or must have all of these - climate_capabilities = [ + # Or must have all of these thermostat capabilities + thermostat_capabilities = [ Capability.temperature_measurement, Capability.thermostat_cooling_setpoint, Capability.thermostat_heating_setpoint, Capability.thermostat_mode] if all(capability in capabilities - for capability in climate_capabilities): + for capability in thermostat_capabilities): + return supported + # Or must have all of these A/C capabilities + ac_capabilities = [ + Capability.air_conditioner_mode, + Capability.fan_speed, + Capability.switch, + Capability.temperature_measurement, + Capability.thermostat_cooling_setpoint] + if all(capability in capabilities + for capability in ac_capabilities): return supported - return None @@ -254,5 +303,128 @@ class SmartThingsThermostat(SmartThingsEntity, ClimateDevice): @property def temperature_unit(self): """Return the unit of measurement.""" + from pysmartthings import Attribute return UNIT_MAP.get( - self._device.status.attributes['temperature'].unit) + self._device.status.attributes[Attribute.temperature].unit) + + +class SmartThingsAirConditioner(SmartThingsEntity, ClimateDevice): + """Define a SmartThings Air Conditioner.""" + + async def async_set_fan_mode(self, fan_mode): + """Set new target fan mode.""" + await self._device.set_fan_speed( + FAN_MODE_TO_SPEED[fan_mode], set_status=True) + # State is set optimistically in the command above, therefore update + # the entity state ahead of receiving the confirming push updates + self.async_schedule_update_ha_state() + + async def async_set_operation_mode(self, operation_mode): + """Set new target operation mode.""" + await self._device.set_air_conditioner_mode( + STATE_TO_AC_MODE[operation_mode], set_status=True) + # State is set optimistically in the command above, therefore update + # the entity state ahead of receiving the confirming push updates + self.async_schedule_update_ha_state() + + async def async_set_temperature(self, **kwargs): + """Set new target temperature.""" + tasks = [] + # operation mode + operation_mode = kwargs.get(ATTR_OPERATION_MODE) + if operation_mode: + tasks.append(self.async_set_operation_mode(operation_mode)) + # temperature + tasks.append(self._device.set_cooling_setpoint( + kwargs[ATTR_TEMPERATURE], set_status=True)) + await asyncio.gather(*tasks) + # State is set optimistically in the command above, therefore update + # the entity state ahead of receiving the confirming push updates + self.async_schedule_update_ha_state() + + async def async_turn_on(self): + """Turn device on.""" + await self._device.switch_on(set_status=True) + # State is set optimistically in the command above, therefore update + # the entity state ahead of receiving the confirming push updates + self.async_schedule_update_ha_state() + + async def async_turn_off(self): + """Turn device off.""" + await self._device.switch_off(set_status=True) + # State is set optimistically in the command above, therefore update + # the entity state ahead of receiving the confirming push updates + self.async_schedule_update_ha_state() + + @property + def current_fan_mode(self): + """Return the fan setting.""" + return SPEED_TO_FAN_MODE.get(self._device.status.fan_speed) + + @property + def current_operation(self): + """Return current operation ie. heat, cool, idle.""" + return AC_MODE_TO_STATE.get(self._device.status.air_conditioner_mode) + + @property + def current_temperature(self): + """Return the current temperature.""" + return self._device.status.temperature + + @property + def device_state_attributes(self): + """ + Return device specific state attributes. + + Include attributes from the Demand Response Load Control (drlc) + and Power Consumption capabilities. + """ + attributes = [ + 'drlc_status_duration', + 'drlc_status_level', + 'drlc_status_start', + 'drlc_status_override', + 'power_consumption_start', + 'power_consumption_power', + 'power_consumption_energy', + 'power_consumption_end' + ] + state_attributes = {} + for attribute in attributes: + value = getattr(self._device.status, attribute) + if value is not None: + state_attributes[attribute] = value + return state_attributes + + @property + def fan_list(self): + """Return the list of available fan modes.""" + return list(FAN_MODE_TO_SPEED) + + @property + def is_on(self): + """Return true if on.""" + return self._device.status.switch + + @property + def operation_list(self): + """Return the list of available operation modes.""" + return list(STATE_TO_AC_MODE) + + @property + def supported_features(self): + """Return the supported features.""" + return SUPPORT_OPERATION_MODE | SUPPORT_TARGET_TEMPERATURE \ + | SUPPORT_FAN_MODE | SUPPORT_ON_OFF + + @property + def target_temperature(self): + """Return the temperature we try to reach.""" + return self._device.status.cooling_setpoint + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + from pysmartthings import Attribute + return UNIT_MAP.get( + self._device.status.attributes[Attribute.temperature].unit) diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index 29134d6ba6a..8789b3e7730 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -13,14 +13,15 @@ from homeassistant.components.climate.const import ( ATTR_FAN_MODE, ATTR_OPERATION_LIST, ATTR_OPERATION_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN as CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, SERVICE_SET_OPERATION_MODE, SERVICE_SET_TEMPERATURE, - STATE_AUTO, STATE_COOL, STATE_ECO, STATE_HEAT, SUPPORT_FAN_MODE, - SUPPORT_OPERATION_MODE, SUPPORT_TARGET_TEMPERATURE, - SUPPORT_TARGET_TEMPERATURE_HIGH, SUPPORT_TARGET_TEMPERATURE_LOW) + STATE_AUTO, STATE_COOL, STATE_DRY, STATE_ECO, STATE_FAN_ONLY, STATE_HEAT, + SUPPORT_FAN_MODE, SUPPORT_ON_OFF, SUPPORT_OPERATION_MODE, + SUPPORT_TARGET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE_HIGH, + SUPPORT_TARGET_TEMPERATURE_LOW) from homeassistant.components.smartthings import climate from homeassistant.components.smartthings.const import DOMAIN from homeassistant.const import ( - ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, STATE_OFF, - STATE_UNKNOWN) + ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, + SERVICE_TURN_OFF, SERVICE_TURN_ON, STATE_OFF, STATE_UNKNOWN) from .conftest import setup_platform @@ -115,6 +116,41 @@ def buggy_thermostat_fixture(device_factory): return device +@pytest.fixture(name="air_conditioner") +def air_conditioner_fixture(device_factory): + """Fixture returns a air conditioner.""" + device = device_factory( + "Air Conditioner", + capabilities=[ + Capability.air_conditioner_mode, + Capability.demand_response_load_control, + Capability.fan_speed, + Capability.power_consumption_report, + Capability.switch, + Capability.temperature_measurement, + Capability.thermostat_cooling_setpoint], + status={ + Attribute.air_conditioner_mode: 'auto', + Attribute.drlc_status: { + "duration": 0, + "drlcLevel": -1, + "start": "1970-01-01T00:00:00Z", + "override": False + }, + Attribute.fan_speed: 2, + Attribute.power_consumption: { + "start": "2019-02-24T21:03:04Z", + "power": 0, + "energy": 500, + "end": "2019-02-26T02:05:55Z" + }, + Attribute.switch: 'on', + Attribute.cooling_setpoint: 23} + ) + device.status.attributes[Attribute.temperature] = Status(24, 'C', None) + return device + + async def test_async_setup_platform(): """Test setup platform does nothing (it uses config entries).""" await climate.async_setup_platform(None, None, None) @@ -195,28 +231,61 @@ async def test_buggy_thermostat_invalid_mode(hass, buggy_thermostat): assert state.attributes[ATTR_OPERATION_LIST] == {'heat'} -async def test_set_fan_mode(hass, thermostat): +async def test_air_conditioner_entity_state(hass, air_conditioner): + """Tests when an invalid operation mode is included.""" + await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) + state = hass.states.get('climate.air_conditioner') + assert state.state == STATE_AUTO + assert state.attributes[ATTR_SUPPORTED_FEATURES] == \ + SUPPORT_OPERATION_MODE | SUPPORT_FAN_MODE | \ + SUPPORT_TARGET_TEMPERATURE | SUPPORT_ON_OFF + assert sorted(state.attributes[ATTR_OPERATION_LIST]) == [ + STATE_AUTO, STATE_COOL, STATE_DRY, STATE_FAN_ONLY, STATE_HEAT] + assert state.attributes[ATTR_FAN_MODE] == 'medium' + assert sorted(state.attributes[ATTR_FAN_LIST]) == \ + ['auto', 'high', 'low', 'medium', 'turbo'] + assert state.attributes[ATTR_TEMPERATURE] == 23 + assert state.attributes[ATTR_CURRENT_TEMPERATURE] == 24 + assert state.attributes['drlc_status_duration'] == 0 + assert state.attributes['drlc_status_level'] == -1 + assert state.attributes['drlc_status_start'] == '1970-01-01T00:00:00Z' + assert state.attributes['drlc_status_override'] is False + assert state.attributes['power_consumption_start'] == \ + '2019-02-24T21:03:04Z' + assert state.attributes['power_consumption_power'] == 0 + assert state.attributes['power_consumption_energy'] == 500 + assert state.attributes['power_consumption_end'] == '2019-02-26T02:05:55Z' + + +async def test_set_fan_mode(hass, thermostat, air_conditioner): """Test the fan mode is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) + await setup_platform(hass, CLIMATE_DOMAIN, + devices=[thermostat, air_conditioner]) + entity_ids = ['climate.thermostat', 'climate.air_conditioner'] await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_FAN_MODE, { - ATTR_ENTITY_ID: 'climate.thermostat', + ATTR_ENTITY_ID: entity_ids, ATTR_FAN_MODE: 'auto'}, blocking=True) - state = hass.states.get('climate.thermostat') - assert state.attributes[ATTR_FAN_MODE] == 'auto' + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state.attributes[ATTR_FAN_MODE] == 'auto', entity_id -async def test_set_operation_mode(hass, thermostat): +async def test_set_operation_mode(hass, thermostat, air_conditioner): """Test the operation mode is set successfully.""" - await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) + await setup_platform(hass, CLIMATE_DOMAIN, + devices=[thermostat, air_conditioner]) + entity_ids = ['climate.thermostat', 'climate.air_conditioner'] await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_OPERATION_MODE, { - ATTR_ENTITY_ID: 'climate.thermostat', - ATTR_OPERATION_MODE: STATE_ECO}, + ATTR_ENTITY_ID: entity_ids, + ATTR_OPERATION_MODE: STATE_COOL}, blocking=True) - state = hass.states.get('climate.thermostat') - assert state.state == STATE_ECO + + for entity_id in entity_ids: + state = hass.states.get(entity_id) + assert state.state == STATE_COOL, entity_id async def test_set_temperature_heat_mode(hass, thermostat): @@ -262,6 +331,32 @@ async def test_set_temperature(hass, thermostat): assert state.attributes[ATTR_TARGET_TEMP_LOW] == 22.2 +async def test_set_temperature_ac(hass, air_conditioner): + """Test the temperature is set successfully.""" + await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) + await hass.services.async_call( + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { + ATTR_ENTITY_ID: 'climate.air_conditioner', + ATTR_TEMPERATURE: 27}, + blocking=True) + state = hass.states.get('climate.air_conditioner') + assert state.attributes[ATTR_TEMPERATURE] == 27 + + +async def test_set_temperature_ac_with_mode(hass, air_conditioner): + """Test the temperature is set successfully.""" + await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) + await hass.services.async_call( + CLIMATE_DOMAIN, SERVICE_SET_TEMPERATURE, { + ATTR_ENTITY_ID: 'climate.air_conditioner', + ATTR_TEMPERATURE: 27, + ATTR_OPERATION_MODE: STATE_COOL}, + blocking=True) + state = hass.states.get('climate.air_conditioner') + assert state.attributes[ATTR_TEMPERATURE] == 27 + assert state.state == STATE_COOL + + async def test_set_temperature_with_mode(hass, thermostat): """Test the temperature and mode is set successfully.""" await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) @@ -278,6 +373,31 @@ async def test_set_temperature_with_mode(hass, thermostat): assert state.state == STATE_AUTO +async def test_set_turn_off(hass, air_conditioner): + """Test the a/c is turned off successfully.""" + await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) + state = hass.states.get('climate.air_conditioner') + assert state.state == STATE_AUTO + await hass.services.async_call( + CLIMATE_DOMAIN, SERVICE_TURN_OFF, + blocking=True) + state = hass.states.get('climate.air_conditioner') + assert state.state == STATE_OFF + + +async def test_set_turn_on(hass, air_conditioner): + """Test the a/c is turned on successfully.""" + air_conditioner.status.update_attribute_value(Attribute.switch, 'off') + await setup_platform(hass, CLIMATE_DOMAIN, devices=[air_conditioner]) + state = hass.states.get('climate.air_conditioner') + assert state.state == STATE_OFF + await hass.services.async_call( + CLIMATE_DOMAIN, SERVICE_TURN_ON, + blocking=True) + state = hass.states.get('climate.air_conditioner') + assert state.state == STATE_AUTO + + async def test_entity_and_device_attributes(hass, thermostat): """Test the attributes of the entries are correct.""" await setup_platform(hass, CLIMATE_DOMAIN, devices=[thermostat]) From 5ace55ea8df964051e8d6a762bc54304804c7874 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sat, 9 Mar 2019 18:45:15 -0600 Subject: [PATCH 163/291] Add SmartThings sensor support for Three Axis (#21841) * Add support for Three Axis to sensor platform * Changes per review feedback. * Remove unnecessary KeyError except * Fix lint issue in line wrapping --- .../components/smartthings/sensor.py | 55 ++++++++++++++++--- tests/components/smartthings/test_sensor.py | 37 ++++++++++++- 2 files changed, 83 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 2c5a8b7ef39..4b78e8f40ff 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -125,6 +125,8 @@ CAPABILITY_TO_SENSORS = { 'thermostatSetpoint': [ Map('thermostatSetpoint', "Thermostat Setpoint", TEMP_CELSIUS, DEVICE_CLASS_TEMPERATURE)], + 'threeAxis': [ + Map('threeAxis', "Three Axis", None, None)], 'tvChannel': [ Map('tvChannel', "Tv Channel", None, None)], 'tvocMeasurement': [ @@ -147,6 +149,8 @@ UNITS = { 'F': TEMP_FAHRENHEIT } +THREE_AXIS_NAMES = ['X Coordinate', 'Y Coordinate', 'Z Coordinate'] + async def async_setup_platform( hass, config, async_add_entities, discovery_info=None): @@ -156,16 +160,22 @@ async def async_setup_platform( async def async_setup_entry(hass, config_entry, async_add_entities): """Add binary sensors for a config entry.""" + from pysmartthings import Capability broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] sensors = [] for device in broker.devices.values(): for capability in broker.get_assigned(device.device_id, 'sensor'): - maps = CAPABILITY_TO_SENSORS[capability] - sensors.extend([ - SmartThingsSensor( - device, m.attribute, m.name, m.default_unit, - m.device_class) - for m in maps]) + if capability == Capability.three_axis: + sensors.extend( + [SmartThingsThreeAxisSensor(device, index) + for index in range(len(THREE_AXIS_NAMES))]) + else: + maps = CAPABILITY_TO_SENSORS[capability] + sensors.extend([ + SmartThingsSensor( + device, m.attribute, m.name, m.default_unit, + m.device_class) + for m in maps]) async_add_entities(sensors) @@ -176,7 +186,7 @@ def get_capabilities(capabilities: Sequence[str]) -> Optional[Sequence[str]]: class SmartThingsSensor(SmartThingsEntity): - """Define a SmartThings Binary Sensor.""" + """Define a SmartThings Sensor.""" def __init__(self, device, attribute: str, name: str, default_unit: str, device_class: str): @@ -212,3 +222,34 @@ class SmartThingsSensor(SmartThingsEntity): """Return the unit this state is expressed in.""" unit = self._device.status.attributes[self._attribute].unit return UNITS.get(unit, unit) if unit else self._default_unit + + +class SmartThingsThreeAxisSensor(SmartThingsEntity): + """Define a SmartThings Three Axis Sensor.""" + + def __init__(self, device, index): + """Init the class.""" + super().__init__(device) + self._index = index + + @property + def name(self) -> str: + """Return the name of the binary sensor.""" + return '{} {}'.format( + self._device.label, THREE_AXIS_NAMES[self._index]) + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return '{}.{}'.format( + self._device.device_id, THREE_AXIS_NAMES[self._index]) + + @property + def state(self): + """Return the state of the sensor.""" + from pysmartthings import Attribute + three_axis = self._device.status.attributes[Attribute.three_axis].value + try: + return three_axis[self._index] + except (TypeError, IndexError): + return None diff --git a/tests/components/smartthings/test_sensor.py b/tests/components/smartthings/test_sensor.py index 879aae1994d..1ae9c0e9e73 100644 --- a/tests/components/smartthings/test_sensor.py +++ b/tests/components/smartthings/test_sensor.py @@ -11,7 +11,8 @@ from homeassistant.components.sensor import ( from homeassistant.components.smartthings import sensor from homeassistant.components.smartthings.const import ( DOMAIN, SIGNAL_SMARTTHINGS_UPDATE) -from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT +from homeassistant.const import ( + ATTR_FRIENDLY_NAME, ATTR_UNIT_OF_MEASUREMENT, STATE_UNKNOWN) from homeassistant.helpers.dispatcher import async_dispatcher_send from .conftest import setup_platform @@ -34,7 +35,7 @@ async def test_async_setup_platform(): async def test_entity_state(hass, device_factory): - """Tests the state attributes properly match the light types.""" + """Tests the state attributes properly match the sensor types.""" device = device_factory('Sensor 1', [Capability.battery], {Attribute.battery: 100}) await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) @@ -45,6 +46,38 @@ async def test_entity_state(hass, device_factory): device.label + " Battery" +async def test_entity_three_axis_state(hass, device_factory): + """Tests the state attributes properly match the three axis types.""" + device = device_factory('Three Axis', [Capability.three_axis], + {Attribute.three_axis: [100, 75, 25]}) + await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) + state = hass.states.get('sensor.three_axis_x_coordinate') + assert state.state == '100' + assert state.attributes[ATTR_FRIENDLY_NAME] ==\ + device.label + " X Coordinate" + state = hass.states.get('sensor.three_axis_y_coordinate') + assert state.state == '75' + assert state.attributes[ATTR_FRIENDLY_NAME] ==\ + device.label + " Y Coordinate" + state = hass.states.get('sensor.three_axis_z_coordinate') + assert state.state == '25' + assert state.attributes[ATTR_FRIENDLY_NAME] ==\ + device.label + " Z Coordinate" + + +async def test_entity_three_axis_invalid_state(hass, device_factory): + """Tests the state attributes properly match the three axis types.""" + device = device_factory('Three Axis', [Capability.three_axis], + {Attribute.three_axis: []}) + await setup_platform(hass, SENSOR_DOMAIN, devices=[device]) + state = hass.states.get('sensor.three_axis_x_coordinate') + assert state.state == STATE_UNKNOWN + state = hass.states.get('sensor.three_axis_y_coordinate') + assert state.state == STATE_UNKNOWN + state = hass.states.get('sensor.three_axis_z_coordinate') + assert state.state == STATE_UNKNOWN + + async def test_entity_and_device_attributes(hass, device_factory): """Test the attributes of the entity are correct.""" # Arrange From 14b05b0a91bbdbee61e9b5f8cdddd0db7537c052 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 9 Mar 2019 19:52:50 -0800 Subject: [PATCH 164/291] Fix incorrect 2nd param --- homeassistant/components/cloud/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 954cc8385f6..1d46eb91b86 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -110,7 +110,7 @@ async def async_create_cloudhook(hass, webhook_id: str) -> str: if not async_is_logged_in(hass): raise CloudNotAvailable - hook = await hass.data[DOMAIN].cloudhooks.async_create(webhook_id, True) + hook = await hass.data[DOMAIN].cloudhooks.async_create(webhook_id) return hook['cloudhook_url'] From 5b2c6648fbbffb84bfbe6d3888fc9b93cf917f04 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 9 Mar 2019 20:07:29 -0800 Subject: [PATCH 165/291] Add user group (#21832) * Add user group * Rename system group to plural --- homeassistant/auth/auth_store.py | 29 +++++++++++++++++-- homeassistant/auth/const.py | 1 + .../auth/permissions/system_policies.py | 4 +++ .../auth/permissions/test_system_policies.py | 11 +++++++ tests/auth/test_auth_store.py | 24 ++++++++++++--- 5 files changed, 63 insertions(+), 6 deletions(-) diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 90c1b337f16..08ff2d7bb52 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -11,13 +11,14 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.util import dt as dt_util from . import models -from .const import GROUP_ID_ADMIN, GROUP_ID_READ_ONLY +from .const import GROUP_ID_ADMIN, GROUP_ID_USER, GROUP_ID_READ_ONLY from .permissions import PermissionLookup, system_policies from .permissions.types import PolicyType # noqa: F401 STORAGE_VERSION = 1 STORAGE_KEY = 'auth' GROUP_NAME_ADMIN = 'Administrators' +GROUP_NAME_USER = "Users" GROUP_NAME_READ_ONLY = 'Read Only' @@ -305,6 +306,7 @@ class AuthStore: # 1. Data from a recent version which has a single group without policy # 2. Data from old version which has no groups has_admin_group = False + has_user_group = False has_read_only_group = False group_without_policy = None @@ -322,6 +324,13 @@ class AuthStore: policy = system_policies.ADMIN_POLICY system_generated = True + elif group_dict['id'] == GROUP_ID_USER: + has_user_group = True + + name = GROUP_NAME_USER + policy = system_policies.USER_POLICY + system_generated = True + elif group_dict['id'] == GROUP_ID_READ_ONLY: has_read_only_group = True @@ -369,6 +378,10 @@ class AuthStore: read_only_group = _system_read_only_group() groups[read_only_group.id] = read_only_group + if not has_user_group: + user_group = _system_user_group() + groups[user_group.id] = user_group + for user_dict in data['users']: # Collect the users group. user_groups = [] @@ -483,7 +496,7 @@ class AuthStore: 'name': group.name } # type: Dict[str, Any] - if group.id not in (GROUP_ID_READ_ONLY, GROUP_ID_ADMIN): + if not group.system_generated: g_dict['policy'] = group.policy groups.append(g_dict) @@ -536,6 +549,8 @@ class AuthStore: groups = OrderedDict() # type: Dict[str, models.Group] admin_group = _system_admin_group() groups[admin_group.id] = admin_group + user_group = _system_user_group() + groups[user_group.id] = user_group read_only_group = _system_read_only_group() groups[read_only_group.id] = read_only_group self._groups = groups @@ -551,6 +566,16 @@ def _system_admin_group() -> models.Group: ) +def _system_user_group() -> models.Group: + """Create system user group.""" + return models.Group( + name=GROUP_NAME_USER, + id=GROUP_ID_USER, + policy=system_policies.USER_POLICY, + system_generated=True, + ) + + def _system_read_only_group() -> models.Group: """Create read only group.""" return models.Group( diff --git a/homeassistant/auth/const.py b/homeassistant/auth/const.py index 519669ead85..ef2d54ccbab 100644 --- a/homeassistant/auth/const.py +++ b/homeassistant/auth/const.py @@ -5,4 +5,5 @@ ACCESS_TOKEN_EXPIRATION = timedelta(minutes=30) MFA_SESSION_EXPIRATION = timedelta(minutes=5) GROUP_ID_ADMIN = 'system-admin' +GROUP_ID_USER = 'system-users' GROUP_ID_READ_ONLY = 'system-read-only' diff --git a/homeassistant/auth/permissions/system_policies.py b/homeassistant/auth/permissions/system_policies.py index 78da68c0d11..bf65c0a85a6 100644 --- a/homeassistant/auth/permissions/system_policies.py +++ b/homeassistant/auth/permissions/system_policies.py @@ -5,6 +5,10 @@ ADMIN_POLICY = { CAT_ENTITIES: True, } +USER_POLICY = { + CAT_ENTITIES: True, +} + READ_ONLY_POLICY = { CAT_ENTITIES: { SUBCAT_ALL: { diff --git a/tests/auth/permissions/test_system_policies.py b/tests/auth/permissions/test_system_policies.py index f6a68f0865a..dc2f1cd0d54 100644 --- a/tests/auth/permissions/test_system_policies.py +++ b/tests/auth/permissions/test_system_policies.py @@ -14,6 +14,17 @@ def test_admin_policy(): assert perms.check_entity('light.kitchen', 'edit') +def test_user_policy(): + """Test user policy works.""" + # Make sure it's valid + POLICY_SCHEMA(system_policies.USER_POLICY) + + perms = PolicyPermissions(system_policies.USER_POLICY, None) + assert perms.check_entity('light.kitchen', 'read') + assert perms.check_entity('light.kitchen', 'control') + assert perms.check_entity('light.kitchen', 'edit') + + def test_read_only_policy(): """Test read only policy works.""" # Make sure it's valid diff --git a/tests/auth/test_auth_store.py b/tests/auth/test_auth_store.py index 08530da324b..136bc3d62ec 100644 --- a/tests/auth/test_auth_store.py +++ b/tests/auth/test_auth_store.py @@ -64,7 +64,7 @@ async def test_loading_no_group_data_format(hass, hass_storage): store = auth_store.AuthStore(hass) groups = await store.async_get_groups() - assert len(groups) == 2 + assert len(groups) == 3 admin_group = groups[0] assert admin_group.name == auth_store.GROUP_NAME_ADMIN assert admin_group.system_generated @@ -73,6 +73,10 @@ async def test_loading_no_group_data_format(hass, hass_storage): assert read_group.name == auth_store.GROUP_NAME_READ_ONLY assert read_group.system_generated assert read_group.id == auth_store.GROUP_ID_READ_ONLY + user_group = groups[2] + assert user_group.name == auth_store.GROUP_NAME_USER + assert user_group.system_generated + assert user_group.id == auth_store.GROUP_ID_USER users = await store.async_get_users() assert len(users) == 2 @@ -157,7 +161,7 @@ async def test_loading_all_access_group_data_format(hass, hass_storage): store = auth_store.AuthStore(hass) groups = await store.async_get_groups() - assert len(groups) == 2 + assert len(groups) == 3 admin_group = groups[0] assert admin_group.name == auth_store.GROUP_NAME_ADMIN assert admin_group.system_generated @@ -166,6 +170,10 @@ async def test_loading_all_access_group_data_format(hass, hass_storage): assert read_group.name == auth_store.GROUP_NAME_READ_ONLY assert read_group.system_generated assert read_group.id == auth_store.GROUP_ID_READ_ONLY + user_group = groups[2] + assert user_group.name == auth_store.GROUP_NAME_USER + assert user_group.system_generated + assert user_group.id == auth_store.GROUP_ID_USER users = await store.async_get_users() assert len(users) == 2 @@ -189,12 +197,16 @@ async def test_loading_empty_data(hass, hass_storage): """Test we correctly load with no existing data.""" store = auth_store.AuthStore(hass) groups = await store.async_get_groups() - assert len(groups) == 2 + assert len(groups) == 3 admin_group = groups[0] assert admin_group.name == auth_store.GROUP_NAME_ADMIN assert admin_group.system_generated assert admin_group.id == auth_store.GROUP_ID_ADMIN - read_group = groups[1] + user_group = groups[1] + assert user_group.name == auth_store.GROUP_NAME_USER + assert user_group.system_generated + assert user_group.id == auth_store.GROUP_ID_USER + read_group = groups[2] assert read_group.name == auth_store.GROUP_NAME_READ_ONLY assert read_group.system_generated assert read_group.id == auth_store.GROUP_ID_READ_ONLY @@ -217,6 +229,10 @@ async def test_system_groups_store_id_and_name(hass, hass_storage): 'id': auth_store.GROUP_ID_ADMIN, 'name': auth_store.GROUP_NAME_ADMIN, }, + { + 'id': auth_store.GROUP_ID_USER, + 'name': auth_store.GROUP_NAME_USER, + }, { 'id': auth_store.GROUP_ID_READ_ONLY, 'name': auth_store.GROUP_NAME_READ_ONLY, From 5ffb471198c15127b8f117fa18f489f8c075ff6b Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Sat, 9 Mar 2019 23:09:09 -0500 Subject: [PATCH 166/291] Update ZHA state handling (#21866) * make device available if it was seen within 2 hours * more state restore * cleanup init * clean up storage stuff * fix tests * update state handling --- homeassistant/components/zha/__init__.py | 15 ++++++---- homeassistant/components/zha/binary_sensor.py | 28 +++++++++++++++++ .../components/zha/core/channels/__init__.py | 15 +++++----- homeassistant/components/zha/core/device.py | 22 +++++++++++--- homeassistant/components/zha/core/gateway.py | 30 ++++++++++++------- homeassistant/components/zha/core/store.py | 24 +++++++++++---- homeassistant/components/zha/device_entity.py | 1 + homeassistant/components/zha/entity.py | 25 +++++++++++++++- homeassistant/components/zha/fan.py | 14 +++++++++ homeassistant/components/zha/light.py | 20 ++++++++++++- homeassistant/components/zha/sensor.py | 27 ++++++++++++----- homeassistant/components/zha/switch.py | 14 +++++++++ tests/components/zha/conftest.py | 6 ++-- 13 files changed, 197 insertions(+), 44 deletions(-) diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index ec4d9082dc5..82efd564742 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -23,10 +23,11 @@ from .core.const import ( COMPONENTS, CONF_BAUDRATE, CONF_DATABASE, CONF_DEVICE_CONFIG, CONF_RADIO_TYPE, CONF_USB_PATH, DATA_ZHA, DATA_ZHA_BRIDGE_ID, DATA_ZHA_CONFIG, DATA_ZHA_CORE_COMPONENT, DATA_ZHA_DISPATCHERS, - DATA_ZHA_RADIO, DEFAULT_BAUDRATE, DEFAULT_DATABASE_NAME, + DATA_ZHA_RADIO, DEFAULT_BAUDRATE, DEFAULT_DATABASE_NAME, DATA_ZHA_GATEWAY, DEFAULT_RADIO_TYPE, DOMAIN, RadioType, DATA_ZHA_CORE_EVENTS, ENABLE_QUIRKS) from .core.gateway import establish_device_mappings from .core.channels.registry import populate_channel_registry +from .core.store import async_get_registry REQUIREMENTS = [ 'bellows-homeassistant==0.7.1', @@ -146,7 +147,8 @@ async def async_setup_entry(hass, config_entry): ClusterPersistingListener ) - zha_gateway = ZHAGateway(hass, config) + zha_storage = await async_get_registry(hass) + zha_gateway = ZHAGateway(hass, config, zha_storage) # Patch handle_message until zigpy can provide an event here def handle_message(sender, is_reply, profile, cluster, @@ -192,11 +194,14 @@ async def async_setup_entry(hass, config_entry): api.async_load_api(hass, application_controller, zha_gateway) - def zha_shutdown(event): - """Close radio.""" + async def async_zha_shutdown(event): + """Handle shutdown tasks.""" + await hass.data[DATA_ZHA][ + DATA_ZHA_GATEWAY].async_update_device_storage() hass.data[DATA_ZHA][DATA_ZHA_RADIO].close() - hass.bus.async_listen_once(ha_const.EVENT_HOMEASSISTANT_STOP, zha_shutdown) + hass.bus.async_listen_once( + ha_const.EVENT_HOMEASSISTANT_STOP, async_zha_shutdown) return True diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 30f730f3de4..7c08c758af2 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -7,6 +7,8 @@ at https://home-assistant.io/components/binary_sensor.zha/ import logging from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice +from homeassistant.const import STATE_ON +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from .core.const import ( DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, ON_OFF_CHANNEL, @@ -126,6 +128,14 @@ class BinarySensor(ZhaEntity, BinarySensorDevice): await self.async_accept_signal( self._attr_channel, SIGNAL_ATTR_UPDATED, self.async_set_state) + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + super().async_restore_last_state(last_state) + self._state = last_state.state == STATE_ON + if 'level' in last_state.attributes: + self._level = last_state.attributes['level'] + @property def is_on(self) -> bool: """Return if the switch is on based on the statemachine.""" @@ -166,3 +176,21 @@ class BinarySensor(ZhaEntity, BinarySensorDevice): ATTR_LEVEL: self._state and self._level or 0 }) return self._device_state_attributes + + async def async_update(self): + """Attempt to retrieve on off state from the binary sensor.""" + await super().async_update() + if self._level_channel: + self._level = await self._level_channel.get_attribute_value( + 'current_level') + if self._on_off_channel: + self._state = await self._on_off_channel.get_attribute_value( + 'on_off') + if self._zone_channel: + value = await self._zone_channel.get_attribute_value( + 'zone_status') + if value is not None: + self._state = value & 3 + if self._attr_channel: + self._state = await self._attr_channel.get_attribute_value( + self._attr_channel.value_attribute) diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index 59b433c5f61..92518bd33ff 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -20,7 +20,6 @@ from ..const import ( CLUSTER_REPORT_CONFIGS, REPORT_CONFIG_DEFAULT, SIGNAL_ATTR_UPDATED, ATTRIBUTE_CHANNEL, EVENT_RELAY_CHANNEL, ZDO_CHANNEL ) -from ..store import async_get_registry NODE_DESCRIPTOR_REQUEST = 0x0002 MAINS_POWERED = 1 @@ -221,14 +220,14 @@ class AttributeListeningChannel(ZigbeeChannel): self.name = ATTRIBUTE_CHANNEL attr = self._report_config[0].get('attr') if isinstance(attr, str): - self._value_attribute = get_attr_id_by_name(self.cluster, attr) + self.value_attribute = get_attr_id_by_name(self.cluster, attr) else: - self._value_attribute = attr + self.value_attribute = attr @callback def attribute_updated(self, attrid, value): """Handle attribute updates on this cluster.""" - if attrid == self._value_attribute: + if attrid == self.value_attribute: async_dispatcher_send( self._zha_device.hass, "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), @@ -288,8 +287,8 @@ class ZDOChannel: async def async_initialize(self, from_cache): """Initialize channel.""" - entry = (await async_get_registry( - self._zha_device.hass)).async_get_or_create(self._zha_device) + entry = self._zha_device.gateway.zha_storage.async_get_or_create( + self._zha_device) _LOGGER.debug("entry loaded from storage: %s", entry) if entry is not None: self.power_source = entry.power_source @@ -303,8 +302,8 @@ class ZDOChannel: # this previously so lets set it up so users don't have # to reconfigure every device. await self.async_get_node_descriptor(False) - entry = (await async_get_registry( - self._zha_device.hass)).async_update(self._zha_device) + entry = self._zha_device.gateway.zha_storage.async_update( + self._zha_device) _LOGGER.debug("entry after getting node desc in init: %s", entry) self._status = ChannelStatus.INITIALIZED diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index fb57b0dbf39..0ddb67484c6 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -20,7 +20,6 @@ from .const import ( QUIRK_CLASS, ZDO_CHANNEL, MANUFACTURER_CODE, POWER_SOURCE ) from .channels import EventRelayChannel, ZDOChannel -from .store import async_get_registry _LOGGER = logging.getLogger(__name__) @@ -69,6 +68,7 @@ class ZHADevice: self._zigpy_device.__class__.__module__, self._zigpy_device.__class__.__name__ ) + self._power_source = None self.status = DeviceStatus.CREATED @property @@ -120,7 +120,9 @@ class ZHADevice: @property def power_source(self): - """Return True if sensor is available.""" + """Return the power source for the device.""" + if self._power_source is not None: + return self._power_source if ZDO_CHANNEL in self.cluster_channels: return self.cluster_channels.get(ZDO_CHANNEL).power_source return None @@ -145,6 +147,14 @@ class ZHADevice: """Return True if sensor is available.""" return self._available + def set_available(self, available): + """Set availability from restore and prevent signals.""" + self._available = available + + def set_power_source(self, power_source): + """Set the power source.""" + self._power_source = power_source + def update_available(self, available): """Set sensor availability.""" if self._available != available and available: @@ -195,8 +205,7 @@ class ZHADevice: _LOGGER.debug('%s: started configuration', self.name) await self._execute_channel_tasks('async_configure') _LOGGER.debug('%s: completed configuration', self.name) - entry = (await async_get_registry( - self.hass)).async_create_or_update(self) + entry = self.gateway.zha_storage.async_create_or_update(self) _LOGGER.debug('%s: stored in registry: %s', self.name, entry) async def async_initialize(self, from_cache=False): @@ -253,6 +262,11 @@ class ZHADevice: if self._unsub: self._unsub() + @callback + def async_update_last_seen(self, last_seen): + """Set last seen on the zigpy device.""" + self._zigpy_device.last_seen = last_seen + @callback def async_get_clusters(self): """Get all clusters for this device.""" diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 42548d6bd1b..8a925ddfda4 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -45,13 +45,14 @@ EntityReference = collections.namedtuple( class ZHAGateway: """Gateway that handles events that happen on the ZHA Zigbee network.""" - def __init__(self, hass, config): + def __init__(self, hass, config, zha_storage): """Initialize the gateway.""" self._hass = hass self._config = config self._component = EntityComponent(_LOGGER, DOMAIN, hass) self._devices = {} self._device_registry = collections.defaultdict(list) + self.zha_storage = zha_storage hass.data[DATA_ZHA][DATA_ZHA_CORE_COMPONENT] = self._component hass.data[DATA_ZHA][DATA_ZHA_GATEWAY] = self @@ -125,12 +126,16 @@ class ZHAGateway: ) @callback - def _async_get_or_create_device(self, zigpy_device): + def _async_get_or_create_device(self, zigpy_device, is_new_join): """Get or create a ZHA device.""" zha_device = self._devices.get(zigpy_device.ieee) if zha_device is None: zha_device = ZHADevice(self._hass, zigpy_device, self) self._devices[zigpy_device.ieee] = zha_device + if not is_new_join: + entry = self.zha_storage.async_get_or_create(zha_device) + zha_device.async_update_last_seen(entry.last_seen) + zha_device.set_power_source(entry.power_source) return zha_device @callback @@ -149,9 +154,16 @@ class ZHAGateway: if device.status is DeviceStatus.INITIALIZED: device.update_available(True) + async def async_update_device_storage(self): + """Update the devices in the store.""" + for device in self.devices.values(): + self.zha_storage.async_update(device) + await self.zha_storage.async_save() + async def async_device_initialized(self, device, is_new_join): """Handle device joined and basic information discovered (async).""" - zha_device = self._async_get_or_create_device(device) + zha_device = self._async_get_or_create_device(device, is_new_join) + discovery_infos = [] for endpoint_id, endpoint in device.endpoints.items(): self._async_process_endpoint( @@ -162,10 +174,11 @@ class ZHAGateway: if is_new_join: # configure the device await zha_device.async_configure() - elif not zha_device.available and zha_device.power_source is not None\ + zha_device.update_available(True) + elif zha_device.power_source is not None\ and zha_device.power_source == MAINS_POWERED: - # the device is currently marked unavailable and it isn't a battery - # powered device so we should be able to update it now + # the device isn't a battery powered device so we should be able + # to update it now _LOGGER.debug( "attempting to request fresh state for %s %s", zha_device.name, @@ -187,11 +200,6 @@ class ZHAGateway: device_entity = _async_create_device_entity(zha_device) await self._component.async_add_entities([device_entity]) - if is_new_join: - # because it's a new join we can immediately mark the device as - # available. We do it here because the entities didn't exist above - zha_device.update_available(True) - @callback def _async_process_endpoint( self, endpoint_id, endpoint, discovery_infos, device, zha_device, diff --git a/homeassistant/components/zha/core/store.py b/homeassistant/components/zha/core/store.py index b13b6d8fd80..f3547cea8a4 100644 --- a/homeassistant/components/zha/core/store.py +++ b/homeassistant/components/zha/core/store.py @@ -28,6 +28,7 @@ class ZhaDeviceEntry: ieee = attr.ib(type=str, default=None) power_source = attr.ib(type=int, default=None) manufacturer_code = attr.ib(type=int, default=None) + last_seen = attr.ib(type=float, default=None) class ZhaDeviceStorage: @@ -46,7 +47,8 @@ class ZhaDeviceStorage: name=device.name, ieee=str(device.ieee), power_source=device.power_source, - manufacturer_code=device.manufacturer_code + manufacturer_code=device.manufacturer_code, + last_seen=device.last_seen ) self.devices[device_entry.ieee] = device_entry @@ -68,10 +70,13 @@ class ZhaDeviceStorage: return self.async_update(device) return self.async_create(device) - async def async_delete(self, ieee: str) -> None: + @callback + def async_delete(self, device) -> None: """Delete ZhaDeviceEntry.""" - del self.devices[ieee] - self.async_schedule_save() + ieee_str = str(device.ieee) + if ieee_str in self.devices: + del self.devices[ieee_str] + self.async_schedule_save() @callback def async_update(self, device) -> ZhaDeviceEntry: @@ -87,6 +92,8 @@ class ZhaDeviceStorage: if device.manufacturer_code != old.manufacturer_code: changes['manufacturer_code'] = device.manufacturer_code + changes['last_seen'] = device.last_seen + new = self.devices[ieee_str] = attr.evolve(old, **changes) self.async_schedule_save() return new @@ -103,7 +110,9 @@ class ZhaDeviceStorage: name=device['name'], ieee=device['ieee'], power_source=device['power_source'], - manufacturer_code=device['manufacturer_code'] + manufacturer_code=device['manufacturer_code'], + last_seen=device['last_seen'] if 'last_seen' in device + else None ) self.devices = devices @@ -113,6 +122,10 @@ class ZhaDeviceStorage: """Schedule saving the registry of zha devices.""" self._store.async_delay_save(self._data_to_save, SAVE_DELAY) + async def async_save(self) -> None: + """Save the registry of zha devices.""" + await self._store.async_save(self._data_to_save()) + @callback def _data_to_save(self) -> dict: """Return data for the registry of zha devices to store in a file.""" @@ -124,6 +137,7 @@ class ZhaDeviceStorage: 'ieee': entry.ieee, 'power_source': entry.power_source, 'manufacturer_code': entry.manufacturer_code, + 'last_seen': entry.last_seen } for entry in self.devices.values() ] diff --git a/homeassistant/components/zha/device_entity.py b/homeassistant/components/zha/device_entity.py index b3fe7a72526..7563481bbb7 100644 --- a/homeassistant/components/zha/device_entity.py +++ b/homeassistant/components/zha/device_entity.py @@ -98,6 +98,7 @@ class ZhaDeviceEntity(ZhaEntity): async def async_added_to_hass(self): """Run when about to be added to hass.""" await super().async_added_to_hass() + await self.async_check_recently_seen() if self._battery_channel: await self.async_accept_signal( self._battery_channel, SIGNAL_STATE_ATTR, diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index 2f5aed4ca29..d0848222549 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -6,23 +6,28 @@ https://home-assistant.io/components/zha/ """ import logging +import time +from homeassistant.core import callback from homeassistant.helpers import entity from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.util import slugify from .core.const import ( DOMAIN, ATTR_MANUFACTURER, DATA_ZHA, DATA_ZHA_BRIDGE_ID, MODEL, NAME, SIGNAL_REMOVE ) +from .core.channels import MAINS_POWERED _LOGGER = logging.getLogger(__name__) ENTITY_SUFFIX = 'entity_suffix' +RESTART_GRACE_PERIOD = 7200 # 2 hours -class ZhaEntity(entity.Entity): +class ZhaEntity(RestoreEntity, entity.Entity): """A base class for ZHA entities.""" _domain = None # Must be overridden by subclasses @@ -136,6 +141,7 @@ class ZhaEntity(entity.Entity): async def async_added_to_hass(self): """Run when about to be added to hass.""" await super().async_added_to_hass() + await self.async_check_recently_seen() await self.async_accept_signal( None, "{}_{}".format(self.zha_device.available_signal, 'entity'), self.async_set_available, @@ -149,11 +155,28 @@ class ZhaEntity(entity.Entity): self._zha_device.ieee, self.entity_id, self._zha_device, self.cluster_channels, self.device_info) + async def async_check_recently_seen(self): + """Check if the device was seen within the last 2 hours.""" + last_state = await self.async_get_last_state() + if last_state and self._zha_device.last_seen and ( + time.time() - self._zha_device.last_seen < + RESTART_GRACE_PERIOD): + self.async_set_available(True) + if self.zha_device.power_source != MAINS_POWERED: + # mains powered devices will get real time state + self.async_restore_last_state(last_state) + self._zha_device.set_available(True) + async def async_will_remove_from_hass(self) -> None: """Disconnect entity object when removed.""" for unsub in self._unsubs: unsub() + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + pass + async def async_update(self): """Retrieve latest state.""" for channel in self.cluster_channels: diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index 761dfaede1e..73989ef32b4 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -6,6 +6,7 @@ at https://home-assistant.io/components/fan.zha/ """ import logging +from homeassistant.core import callback from homeassistant.components.fan import ( DOMAIN, SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SPEED_OFF, SUPPORT_SET_SPEED, FanEntity) @@ -92,6 +93,11 @@ class ZhaFan(ZhaEntity, FanEntity): await self.async_accept_signal( self._fan_channel, SIGNAL_ATTR_UPDATED, self.async_set_state) + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + self._state = VALUE_TO_SPEED.get(last_state.state, last_state.state) + @property def supported_features(self) -> int: """Flag supported features.""" @@ -139,3 +145,11 @@ class ZhaFan(ZhaEntity, FanEntity): """Set the speed of the fan.""" await self._fan_channel.async_set_speed(SPEED_TO_VALUE[speed]) self.async_set_state(speed) + + async def async_update(self): + """Attempt to retrieve on off state from the fan.""" + await super().async_update() + if self._fan_channel: + state = await self._fan_channel.get_attribute_value('fan_mode') + if state is not None: + self._state = VALUE_TO_SPEED.get(state, self._state) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index a87912eb213..8b2cd349b9d 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -8,6 +8,8 @@ from datetime import timedelta import logging from homeassistant.components import light +from homeassistant.const import STATE_ON +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.util.color as color_util from .const import ( @@ -156,6 +158,17 @@ class Light(ZhaEntity, light.Light): await self.async_accept_signal( self._level_channel, SIGNAL_SET_LEVEL, self.set_level) + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + self._state = last_state.state == STATE_ON + if 'brightness' in last_state.attributes: + self._brightness = last_state.attributes['brightness'] + if 'color_temp' in last_state.attributes: + self._color_temp = last_state.attributes['color_temp'] + if 'hs_color' in last_state.attributes: + self._hs_color = last_state.attributes['hs_color'] + async def async_turn_on(self, **kwargs): """Turn the entity on.""" transition = kwargs.get(light.ATTR_TRANSITION) @@ -227,5 +240,10 @@ class Light(ZhaEntity, light.Light): async def async_update(self): """Attempt to retrieve on off state from the light.""" + await super().async_update() if self._on_off_channel: - await self._on_off_channel.async_update() + self._state = await self._on_off_channel.get_attribute_value( + 'on_off') + if self._level_channel: + self._brightness = await self._level_channel.get_attribute_value( + 'current_level') diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 16ca8ec3d71..56ce97c87a0 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -6,8 +6,11 @@ at https://home-assistant.io/components/sensor.zha/ """ import logging +from homeassistant.core import callback from homeassistant.components.sensor import DOMAIN -from homeassistant.const import TEMP_CELSIUS, POWER_WATT +from homeassistant.const import ( + TEMP_CELSIUS, POWER_WATT, ATTR_UNIT_OF_MEASUREMENT +) from homeassistant.helpers.dispatcher import async_dispatcher_connect from .core.const import ( DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, HUMIDITY, TEMPERATURE, @@ -133,22 +136,22 @@ class Sensor(ZhaEntity): def __init__(self, unique_id, zha_device, channels, **kwargs): """Init this sensor.""" super().__init__(unique_id, zha_device, channels, **kwargs) - sensor_type = kwargs.get(SENSOR_TYPE, GENERIC) - self._unit = UNIT_REGISTRY.get(sensor_type) + self._sensor_type = kwargs.get(SENSOR_TYPE, GENERIC) + self._unit = UNIT_REGISTRY.get(self._sensor_type) self._formatter_function = FORMATTER_FUNC_REGISTRY.get( - sensor_type, + self._sensor_type, pass_through_formatter ) self._force_update = FORCE_UPDATE_REGISTRY.get( - sensor_type, + self._sensor_type, False ) self._should_poll = POLLING_REGISTRY.get( - sensor_type, + self._sensor_type, False ) self._channel = self.cluster_channels.get( - CHANNEL_REGISTRY.get(sensor_type, ATTRIBUTE_CHANNEL) + CHANNEL_REGISTRY.get(self._sensor_type, ATTRIBUTE_CHANNEL) ) async def async_added_to_hass(self): @@ -176,5 +179,15 @@ class Sensor(ZhaEntity): def async_set_state(self, state): """Handle state update from channel.""" + # this is necessary because HA saves the unit based on what shows in + # the UI and not based on what the sensor has configured so we need + # to flip it back after state restoration + self._unit = UNIT_REGISTRY.get(self._sensor_type) self._state = self._formatter_function(state) self.async_schedule_update_ha_state() + + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + self._state = last_state.state + self._unit = last_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 63a0cad93ab..f1bf671a43d 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -7,6 +7,8 @@ at https://home-assistant.io/components/switch.zha/ import logging from homeassistant.components.switch import DOMAIN, SwitchDevice +from homeassistant.const import STATE_ON +from homeassistant.core import callback from homeassistant.helpers.dispatcher import async_dispatcher_connect from .core.const import ( DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, ON_OFF_CHANNEL, @@ -100,3 +102,15 @@ class Switch(ZhaEntity, SwitchDevice): await super().async_added_to_hass() await self.async_accept_signal( self._on_off_channel, SIGNAL_ATTR_UPDATED, self.async_set_state) + + @callback + def async_restore_last_state(self, last_state): + """Restore previous state.""" + self._state = last_state.state == STATE_ON + + async def async_update(self): + """Attempt to retrieve on off state from the switch.""" + await super().async_update() + if self._on_off_channel: + self._state = await self._on_off_channel.get_attribute_value( + 'on_off') diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index bd594941da1..de05c89bbb0 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -10,6 +10,7 @@ from homeassistant.components.zha.core.gateway import establish_device_mappings from homeassistant.components.zha.core.channels.registry \ import populate_channel_registry from .common import async_setup_entry +from homeassistant.components.zha.core.store import async_get_registry @pytest.fixture(name='config_entry') @@ -22,7 +23,7 @@ def config_entry_fixture(hass): @pytest.fixture(name='zha_gateway') -def zha_gateway_fixture(hass): +async def zha_gateway_fixture(hass): """Fixture representing a zha gateway. Create a ZHAGateway object that can be used to interact with as if we @@ -34,7 +35,8 @@ def zha_gateway_fixture(hass): hass.data[DATA_ZHA][component] = ( hass.data[DATA_ZHA].get(component, {}) ) - return ZHAGateway(hass, {}) + zha_storage = await async_get_registry(hass) + return ZHAGateway(hass, {}, zha_storage) @pytest.fixture(autouse=True) From 5fbe2d5477e1d0dc5ff987f70d628e890bd6b885 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 9 Mar 2019 21:26:26 -0800 Subject: [PATCH 167/291] Update translations --- .../daikin/.translations/zh-Hans.json | 2 +- .../components/deconz/.translations/de.json | 2 +- .../emulated_roku/.translations/zh-Hans.json | 4 +- .../components/esphome/.translations/de.json | 6 ++- .../components/esphome/.translations/nl.json | 4 ++ .../components/esphome/.translations/sv.json | 2 +- .../esphome/.translations/zh-Hans.json | 6 +-- .../geofency/.translations/zh-Hans.json | 5 +- .../gpslogger/.translations/cs.json | 18 +++++++ .../gpslogger/.translations/nl.json | 6 +++ .../gpslogger/.translations/zh-Hans.json | 3 ++ .../homekit_controller/.translations/ca.json | 33 ++++++++++++ .../homekit_controller/.translations/de.json | 33 ++++++++++++ .../homekit_controller/.translations/en.json | 54 +++++++++---------- .../homekit_controller/.translations/ko.json | 33 ++++++++++++ .../homekit_controller/.translations/lb.json | 30 +++++++++++ .../homekit_controller/.translations/no.json | 33 ++++++++++++ .../homekit_controller/.translations/pl.json | 11 ++++ .../homekit_controller/.translations/ru.json | 33 ++++++++++++ .../.translations/zh-Hans.json | 33 ++++++++++++ .../.translations/zh-Hant.json | 33 ++++++++++++ .../homematicip_cloud/.translations/de.json | 2 +- .../components/ios/.translations/cs.json | 3 ++ .../components/locative/.translations/cs.json | 18 +++++++ .../components/locative/.translations/nl.json | 1 + .../locative/.translations/zh-Hans.json | 2 +- .../owntracks/.translations/cs.json | 17 ++++++ .../point/.translations/zh-Hans.json | 6 +-- .../components/ps4/.translations/nl.json | 17 ++++++ .../components/ps4/.translations/zh-Hans.json | 31 +++++++++++ .../tellduslive/.translations/de.json | 1 + .../tellduslive/.translations/zh-Hans.json | 9 ++-- .../components/toon/.translations/de.json | 34 ++++++++++++ .../components/toon/.translations/lt.json | 11 ++++ .../components/toon/.translations/nl.json | 34 ++++++++++++ .../components/tplink/.translations/nl.json | 15 ++++++ .../components/upnp/.translations/cs.json | 8 ++- 37 files changed, 546 insertions(+), 47 deletions(-) create mode 100644 homeassistant/components/gpslogger/.translations/cs.json create mode 100644 homeassistant/components/homekit_controller/.translations/ca.json create mode 100644 homeassistant/components/homekit_controller/.translations/de.json create mode 100644 homeassistant/components/homekit_controller/.translations/ko.json create mode 100644 homeassistant/components/homekit_controller/.translations/lb.json create mode 100644 homeassistant/components/homekit_controller/.translations/no.json create mode 100644 homeassistant/components/homekit_controller/.translations/pl.json create mode 100644 homeassistant/components/homekit_controller/.translations/ru.json create mode 100644 homeassistant/components/homekit_controller/.translations/zh-Hans.json create mode 100644 homeassistant/components/homekit_controller/.translations/zh-Hant.json create mode 100644 homeassistant/components/locative/.translations/cs.json create mode 100644 homeassistant/components/owntracks/.translations/cs.json create mode 100644 homeassistant/components/ps4/.translations/nl.json create mode 100644 homeassistant/components/ps4/.translations/zh-Hans.json create mode 100644 homeassistant/components/toon/.translations/de.json create mode 100644 homeassistant/components/toon/.translations/lt.json create mode 100644 homeassistant/components/toon/.translations/nl.json create mode 100644 homeassistant/components/tplink/.translations/nl.json diff --git a/homeassistant/components/daikin/.translations/zh-Hans.json b/homeassistant/components/daikin/.translations/zh-Hans.json index 1330e3a932d..5123dc2366b 100644 --- a/homeassistant/components/daikin/.translations/zh-Hans.json +++ b/homeassistant/components/daikin/.translations/zh-Hans.json @@ -10,7 +10,7 @@ "data": { "host": "\u4e3b\u673a" }, - "description": "\u8f93\u5165\u60a8\u7684 Daikin \u7a7a\u8c03\u7684IP\u5730\u5740\u3002", + "description": "\u8f93\u5165\u60a8\u7684 Daikin \u7a7a\u8c03\u7684 IP \u5730\u5740\u3002", "title": "\u914d\u7f6e Daikin \u7a7a\u8c03" } }, diff --git a/homeassistant/components/deconz/.translations/de.json b/homeassistant/components/deconz/.translations/de.json index dce2c7a6704..39975eaa39e 100644 --- a/homeassistant/components/deconz/.translations/de.json +++ b/homeassistant/components/deconz/.translations/de.json @@ -17,7 +17,7 @@ "title": "Definiere das deCONZ-Gateway" }, "link": { - "description": "Entsperre dein deCONZ-Gateway, um dich bei Home Assistant zu registrieren. \n\n 1. Gehe zu den deCONZ-Systemeinstellungen \n 2. Dr\u00fccke die Taste \"Gateway entsperren\"", + "description": "Entsperre dein deCONZ-Gateway, um es bei Home Assistant zu registrieren. \n\n 1. Gehe in die deCONZ-Systemeinstellungen \n 2. Dr\u00fccke die Taste \"Gateway entsperren\"", "title": "Mit deCONZ verbinden" }, "options": { diff --git a/homeassistant/components/emulated_roku/.translations/zh-Hans.json b/homeassistant/components/emulated_roku/.translations/zh-Hans.json index 9cb4cc33431..5ff0466c9bc 100644 --- a/homeassistant/components/emulated_roku/.translations/zh-Hans.json +++ b/homeassistant/components/emulated_roku/.translations/zh-Hans.json @@ -6,7 +6,9 @@ "step": { "user": { "data": { - "host_ip": "\u4e3b\u673aIP", + "advertise_ip": "\u5e7f\u64ad IP", + "advertise_port": "\u5e7f\u64ad\u7aef\u53e3", + "host_ip": "\u4e3b\u673a IP", "listen_port": "\u76d1\u542c\u7aef\u53e3", "name": "\u59d3\u540d" }, diff --git a/homeassistant/components/esphome/.translations/de.json b/homeassistant/components/esphome/.translations/de.json index 191d930eb96..30cbf09525f 100644 --- a/homeassistant/components/esphome/.translations/de.json +++ b/homeassistant/components/esphome/.translations/de.json @@ -13,9 +13,13 @@ "data": { "password": "Passwort" }, - "description": "Bitte geben Sie das Passwort der ESPhome-Konfiguration ein:", + "description": "Bitte geben Sie das Passwort der ESPHome-Konfiguration f\u00fcr {name} ein:", "title": "Passwort eingeben" }, + "discovery_confirm": { + "description": "Willst du den ESPHome-Knoten `{name}` zu Home Assistant hinzuf\u00fcgen?", + "title": "Gefundener ESPHome-Knoten" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/esphome/.translations/nl.json b/homeassistant/components/esphome/.translations/nl.json index 89831979d89..56d77377808 100644 --- a/homeassistant/components/esphome/.translations/nl.json +++ b/homeassistant/components/esphome/.translations/nl.json @@ -16,6 +16,10 @@ "description": "Voer het wachtwoord in dat u in uw configuratie hebt ingesteld.", "title": "Voer wachtwoord in" }, + "discovery_confirm": { + "description": "Wil je de ESPHome-node `{name}` toevoegen aan Home Assistant?", + "title": "Ontdekte ESPHome node" + }, "user": { "data": { "host": "Host", diff --git a/homeassistant/components/esphome/.translations/sv.json b/homeassistant/components/esphome/.translations/sv.json index f727e8a54df..da977af601a 100644 --- a/homeassistant/components/esphome/.translations/sv.json +++ b/homeassistant/components/esphome/.translations/sv.json @@ -13,7 +13,7 @@ "data": { "password": "L\u00f6senord" }, - "description": "Ange det l\u00f6senord du angav i din konfiguration.", + "description": "Ange det l\u00f6senord du angett i din konfiguration f\u00f6r {name}.", "title": "Ange l\u00f6senord" }, "discovery_confirm": { diff --git a/homeassistant/components/esphome/.translations/zh-Hans.json b/homeassistant/components/esphome/.translations/zh-Hans.json index 8e5ca59fcef..0a8211be449 100644 --- a/homeassistant/components/esphome/.translations/zh-Hans.json +++ b/homeassistant/components/esphome/.translations/zh-Hans.json @@ -1,16 +1,16 @@ { "config": { "error": { - "connection_error": "\u65e0\u6cd5\u8fde\u63a5\u5230ESP\u3002\u8bf7\u786e\u4fdd\u60a8\u7684YAML\u6587\u4ef6\u5305\u542b'api:'\u884c\u3002", + "connection_error": "\u65e0\u6cd5\u8fde\u63a5\u5230 ESP\u3002\u8bf7\u786e\u8ba4\u60a8\u7684 YAML \u6587\u4ef6\u4e2d\u5305\u542b 'api:' \u884c\u3002", "invalid_password": "\u65e0\u6548\u7684\u5bc6\u7801\uff01", - "resolve_error": "\u65e0\u6cd5\u89e3\u6790ESP\u7684\u5730\u5740\u3002\u5982\u679c\u6b64\u9519\u8bef\u4ecd\u7136\u5b58\u5728\uff0c\u8bf7\u8bbe\u7f6e\u9759\u6001IP\u5730\u5740\uff1ahttps://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" + "resolve_error": "\u65e0\u6cd5\u89e3\u6790 ESP \u7684\u5730\u5740\u3002\u5982\u679c\u6b64\u9519\u8bef\u6301\u7eed\u5b58\u5728\uff0c\u8bf7\u8bbe\u7f6e\u9759\u6001IP\u5730\u5740\uff1ahttps://esphomelib.com/esphomeyaml/components/wifi.html#manual-ips" }, "step": { "authenticate": { "data": { "password": "\u5bc6\u7801" }, - "description": "\u8bf7\u8f93\u5165\u60a8\u5728\u914d\u7f6e\u4e2d\u8bbe\u7f6e\u7684\u5bc6\u7801\u3002", + "description": "\u8bf7\u8f93\u5165\u60a8\u5728\u914d\u7f6e\u4e2d\u4e3a\u201c{name}\u201d\u8bbe\u7f6e\u7684\u5bc6\u7801\u3002", "title": "\u8f93\u5165\u5bc6\u7801" }, "user": { diff --git a/homeassistant/components/geofency/.translations/zh-Hans.json b/homeassistant/components/geofency/.translations/zh-Hans.json index 7ab8a128980..d18d8bc8280 100644 --- a/homeassistant/components/geofency/.translations/zh-Hans.json +++ b/homeassistant/components/geofency/.translations/zh-Hans.json @@ -2,7 +2,10 @@ "config": { "abort": { "not_internet_accessible": "\u60a8\u7684 Home Assistant \u5b9e\u4f8b\u9700\u8981\u63a5\u5165\u4e92\u8054\u7f51\u4ee5\u63a5\u6536 Geofency \u6d88\u606f\u3002", - "one_instance_allowed": "\u4ec5\u9700\u4e00\u4e2a\u5b9e\u4f8b" + "one_instance_allowed": "\u53ea\u6709\u4e00\u4e2a\u5b9e\u4f8b\u662f\u5fc5\u9700\u7684\u3002" + }, + "create_entry": { + "default": "\u8981\u5411 Home Assistant \u53d1\u9001\u4e8b\u4ef6\uff0c\u60a8\u9700\u8981\u914d\u7f6e Geofency \u7684 Webhook \u529f\u80fd\u3002\n\n\u586b\u5199\u4ee5\u4e0b\u4fe1\u606f\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n\n\u8bf7\u53c2\u9605[\u6587\u6863]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u591a\u4fe1\u606f\u3002" }, "step": { "user": { diff --git a/homeassistant/components/gpslogger/.translations/cs.json b/homeassistant/components/gpslogger/.translations/cs.json new file mode 100644 index 00000000000..f79a9f5d739 --- /dev/null +++ b/homeassistant/components/gpslogger/.translations/cs.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Instalace dom\u00e1c\u00edho asistenta mus\u00ed b\u00fdt p\u0159\u00edstupn\u00e1 z internetu, aby p\u0159ij\u00edmala zpr\u00e1vy od spole\u010dnosti GPSLogger.", + "one_instance_allowed": "Povolena je pouze jedna instance." + }, + "create_entry": { + "default": "Chcete-li odeslat ud\u00e1losti do aplikace Home Assistant, budete muset nastavit funkci Webhook v n\u00e1stroji GPSLogger. \n\n Vypl\u0148te n\u00e1sleduj\u00edc\u00ed informace: \n\n - URL: ` {webhook_url} ' \n - Metoda: POST \n\n Dal\u0161\u00ed podrobnosti naleznete v [dokumentaci] ( {docs_url} )." + }, + "step": { + "user": { + "description": "Opravdu chcete nastavit GPSLogger Webhook?", + "title": "Nastavit GPSLogger Webhook" + } + }, + "title": "GPSLogger Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/nl.json b/homeassistant/components/gpslogger/.translations/nl.json index d0dece65a0f..4956cf52f26 100644 --- a/homeassistant/components/gpslogger/.translations/nl.json +++ b/homeassistant/components/gpslogger/.translations/nl.json @@ -1,5 +1,11 @@ { "config": { + "step": { + "user": { + "description": "Weet je zeker dat je de GPSLogger Webhook wilt instellen?", + "title": "Configureer de GPSLogger Webhook" + } + }, "title": "GPSLogger Webhook" } } \ No newline at end of file diff --git a/homeassistant/components/gpslogger/.translations/zh-Hans.json b/homeassistant/components/gpslogger/.translations/zh-Hans.json index dd5db73f582..91d3ac74994 100644 --- a/homeassistant/components/gpslogger/.translations/zh-Hans.json +++ b/homeassistant/components/gpslogger/.translations/zh-Hans.json @@ -2,6 +2,9 @@ "config": { "abort": { "one_instance_allowed": "\u53ea\u6709\u4e00\u4e2a\u5b9e\u4f8b\u662f\u5fc5\u9700\u7684\u3002" + }, + "create_entry": { + "default": "\u8981\u5411 Home Assistant \u53d1\u9001\u4e8b\u4ef6\uff0c\u60a8\u9700\u8981\u914d\u7f6e GPSLogger \u7684 Webhook \u529f\u80fd\u3002\n\n\u586b\u5199\u4ee5\u4e0b\u4fe1\u606f\uff1a\n\n- URL: `{webhook_url}`\n- Method: POST\n\n\u8bf7\u53c2\u9605[\u6587\u6863]({docs_url})\u4ee5\u4e86\u89e3\u66f4\u591a\u4fe1\u606f\u3002" } } } \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/ca.json b/homeassistant/components/homekit_controller/.translations/ca.json new file mode 100644 index 00000000000..e53fb5f9def --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/ca.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Accessori ja configurat amb aquest controlador.", + "already_paired": "Aquest accessori ja est\u00e0 vinculat amb un altre dispositiu. Reinicia l'accessori i torna-ho a provar.", + "ignored_model": "La disponibilitat de HomeKit per aquest model est\u00e0 bloquejada ja que, de moment, no hi ha una integraci\u00f3 nativa completa.", + "invalid_config_entry": "Aquest dispositiu s'est\u00e0 mostrant com a llest per a ser vinculat per\u00f2, hi ha una entrada de configuraci\u00f3 conflictiva a Home Assistant que s'ha d'eliminar primer.", + "no_devices": "No s'han trobat dispositius desvinculats." + }, + "error": { + "authentication_error": "Codi HomeKit incorrecte. Verifica'l i torna-ho a provar.", + "unable_to_pair": "No s'ha pogut vincular, torna-ho a provar.", + "unknown_error": "El dispositiu ha em\u00e8s un error desconegut. Vinculaci\u00f3 fallida." + }, + "step": { + "pair": { + "data": { + "pairing_code": "Codi de vinculaci\u00f3" + }, + "description": "Introdueix el codi de vinculaci\u00f3 HomeKit per utilitzar aquest accessori", + "title": "Vinculaci\u00f3 amb {{ model }}" + }, + "user": { + "data": { + "device": "Dispositiu" + }, + "description": "Selecciona el dispositiu amb el qual et vols vincular", + "title": "Vinculaci\u00f3 amb un accessori HomeKit" + } + }, + "title": "Acessori HomeKit" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/de.json b/homeassistant/components/homekit_controller/.translations/de.json new file mode 100644 index 00000000000..1f2dfe66dd2 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/de.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Das Zubeh\u00f6r ist mit diesem Controller bereits konfiguriert.", + "already_paired": "Dieses Zubeh\u00f6r ist bereits mit einem anderen Ger\u00e4t gekoppelt. Setzen Sie das Zubeh\u00f6r zur\u00fcck und versuchen Sie es erneut.", + "ignored_model": "Die Unterst\u00fctzung von HomeKit f\u00fcr dieses Modell ist blockiert, da eine vollst\u00e4ndige native Integration verf\u00fcgbar ist.", + "invalid_config_entry": "Dieses Ger\u00e4t wird als bereit zum Koppeln angezeigt, es gibt jedoch bereits einen widerspr\u00fcchlichen Konfigurationseintrag in Home Assistant, der zuerst entfernt werden muss.", + "no_devices": "Keine ungekoppelten Ger\u00e4te gefunden" + }, + "error": { + "authentication_error": "Ung\u00fcltiger HomeKit Code, \u00fcberpr\u00fcfe bitte den Code und versuche es erneut.", + "unable_to_pair": "Koppeln fehltgeschlagen, bitte versuche es erneut", + "unknown_error": "Das Ger\u00e4t meldete einen unbekannten Fehler. Die Kopplung ist fehlgeschlagen." + }, + "step": { + "pair": { + "data": { + "pairing_code": "Kopplungscode" + }, + "description": "Geben Sie Ihren HomeKit-Kopplungscode ein, um dieses Zubeh\u00f6r zu verwenden", + "title": "Kopplung mit {{ model }}" + }, + "user": { + "data": { + "device": "Ger\u00e4t" + }, + "description": "W\u00e4hle das Ger\u00e4t aus, mit dem du die Kopplung herstellen m\u00f6chtest", + "title": "Mit HomeKit Zubeh\u00f6r koppeln" + } + }, + "title": "HomeKit Zubeh\u00f6r" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/en.json b/homeassistant/components/homekit_controller/.translations/en.json index 6cbd172085e..857fca58e43 100644 --- a/homeassistant/components/homekit_controller/.translations/en.json +++ b/homeassistant/components/homekit_controller/.translations/en.json @@ -1,33 +1,33 @@ { "config": { - "title": "HomeKit Accessory", - "step": { - "user": { - "title": "Pair with HomeKit Accessory", - "description": "Select the device you want to pair with", - "data": { - "device": "Device" - } - }, - "pair": { - "title": "Pair with {{ model }}", - "description": "Enter your HomeKit pairing code to use this accessory", - "data": { - "pairing_code": "Pairing Code" - } - } - }, - "error": { - "unable_to_pair": "Unable to pair, please try again.", - "unknown_error": "Device reported an unknown error. Pairing failed.", - "authentication_error": "Incorrect HomeKit code. Please check it and try again." - }, "abort": { - "no_devices": "No unpaired devices could be found", + "already_configured": "Accessory is already configured with this controller.", "already_paired": "This accessory is already paired to another device. Please reset the accessory and try again.", "ignored_model": "HomeKit support for this model is blocked as a more feature complete native integration is available.", - "already_configured": "Accessory is already configured with this controller.", - "invalid_config_entry": "This device is showing as ready to pair but there is already a conflicting config entry for it in Home Assistant that must first be removed." - } + "invalid_config_entry": "This device is showing as ready to pair but there is already a conflicting config entry for it in Home Assistant that must first be removed.", + "no_devices": "No unpaired devices could be found" + }, + "error": { + "authentication_error": "Incorrect HomeKit code. Please check it and try again.", + "unable_to_pair": "Unable to pair, please try again.", + "unknown_error": "Device reported an unknown error. Pairing failed." + }, + "step": { + "pair": { + "data": { + "pairing_code": "Pairing Code" + }, + "description": "Enter your HomeKit pairing code to use this accessory", + "title": "Pair with {{ model }}" + }, + "user": { + "data": { + "device": "Device" + }, + "description": "Select the device you want to pair with", + "title": "Pair with HomeKit Accessory" + } + }, + "title": "HomeKit Accessory" } -} +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/ko.json b/homeassistant/components/homekit_controller/.translations/ko.json new file mode 100644 index 00000000000..5617fc85746 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/ko.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "\uc561\uc138\uc11c\ub9ac\uac00 \ucee8\ud2b8\ub864\ub7ec\uc5d0 \uc774\ubbf8 \uad6c\uc131\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "already_paired": "\uc774 \uc561\uc138\uc11c\ub9ac\ub294 \uc774\ubbf8 \ub2e4\ub978 \uae30\uae30\uc640 \ud398\uc5b4\ub9c1\ub418\uc5b4 \uc788\uc2b5\ub2c8\ub2e4. \uc561\uc138\uc11c\ub9ac\ub97c \uc7ac\uc124\uc815\ud558\uace0 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "ignored_model": "\uc774 \ubaa8\ub378\uc5d0 \ub300\ud55c HomeKit \uc9c0\uc6d0\uc740 \ub354 \ub9ce\uc740 \uae30\ub2a5\uc744 \uc81c\uacf5\ud558\ub294 \uae30\ubcf8 \uad6c\uc131\uc694\uc18c\ub85c \uc778\ud574 \ucc28\ub2e8\ub418\uc5c8\uc2b5\ub2c8\ub2e4.", + "invalid_config_entry": "\uc774 \uc7a5\uce58\ub294 \ud398\uc5b4\ub9c1 \ud560 \uc900\ube44\uac00 \ub418\uc5c8\uc9c0\ub9cc Home Assistant \uc5d0 \uc774\ubbf8 \uad6c\uc131\ub418\uc5b4 \ucda9\ub3cc\ud558\ub294 \uad6c\uc131\uc694\uc18c\uac00 \uc788\uc2b5\ub2c8\ub2e4. \uba3c\uc800 \ud574\ub2f9 \uad6c\uc131\uc694\uc18c\ub97c \uc81c\uac70\ud574\uc8fc\uc138\uc694.", + "no_devices": "\ud398\uc5b4\ub9c1\ub418\uc9c0 \uc54a\uc740 \uc7a5\uce58\ub97c \ucc3e\uc744 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4" + }, + "error": { + "authentication_error": "HomeKit \ucf54\ub4dc\uac00 \uc798\ubabb\ub418\uc5c8\uc2b5\ub2c8\ub2e4. \ud655\uc778 \ud6c4 \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "unable_to_pair": "\ud398\uc5b4\ub9c1 \ud560 \uc218 \uc5c6\uc2b5\ub2c8\ub2e4. \ub2e4\uc2dc \uc2dc\ub3c4\ud574\uc8fc\uc138\uc694.", + "unknown_error": "\uae30\uae30\uc5d0\uc11c \uc54c \uc218\uc5c6\ub294 \uc624\ub958\ub97c \ubcf4\uace0\ud588\uc2b5\ub2c8\ub2e4. \ud398\uc5b4\ub9c1\uc5d0 \uc2e4\ud328\ud588\uc2b5\ub2c8\ub2e4." + }, + "step": { + "pair": { + "data": { + "pairing_code": "\ud398\uc5b4\ub9c1 \ucf54\ub4dc" + }, + "description": "\uc774 \uc561\uc138\uc11c\ub9ac\ub97c \uc0ac\uc6a9\ud558\ub824\uba74 HomeKit \ud398\uc5b4\ub9c1 \ucf54\ub4dc\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694", + "title": "{{ model }} \ud398\uc5b4\ub9c1" + }, + "user": { + "data": { + "device": "\uc7a5\uce58" + }, + "description": "\ud398\uc5b4\ub9c1 \ud560 \uc7a5\uce58\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694", + "title": "HomeKit \uc561\uc138\uc11c\ub9ac\uc640 \ud398\uc5b4\ub9c1" + } + }, + "title": "HomeKit \uc561\uc138\uc11c\ub9ac" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/lb.json b/homeassistant/components/homekit_controller/.translations/lb.json new file mode 100644 index 00000000000..56680552161 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/lb.json @@ -0,0 +1,30 @@ +{ + "config": { + "abort": { + "already_configured": "Accessoire ass schon mat d\u00ebsem Kontroller konfigur\u00e9iert.", + "no_devices": "Keng net verbonnen Apparater fonnt" + }, + "error": { + "authentication_error": "Ong\u00ebltege HomeKit Code. Iwwerpr\u00e9ift d\u00ebsen an prob\u00e9iert w.e.g. nach emol.", + "unable_to_pair": "Feeler beim verbannen, prob\u00e9iert w.e.g. nach emol.", + "unknown_error": "Apparat mellt een onbekannte Feeler. Verbindung net m\u00e9iglech." + }, + "step": { + "pair": { + "data": { + "pairing_code": "Pairing Code" + }, + "description": "Gitt \u00e4ren HomeKit pairing Code an fir d\u00ebsen Accessoire ze benotzen", + "title": "Mat {{ model }} verbannen" + }, + "user": { + "data": { + "device": "Apparat" + }, + "description": "Wielt den Apparat aus dee soll verbonne ginn", + "title": "Mam HomeKit Accessoire verbannen" + } + }, + "title": "HomeKit Accessoire" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/no.json b/homeassistant/components/homekit_controller/.translations/no.json new file mode 100644 index 00000000000..53250833755 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/no.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "Tilbeh\u00f8r er allerede konfigurert med denne kontrolleren.", + "already_paired": "Dette tilbeh\u00f8ret er allerede sammenkoblet med en annen enhet. Vennligst tilbakestill tilbeh\u00f8ret og pr\u00f8v igjen.", + "ignored_model": "HomeKit st\u00f8tte for denne modellen er blokkert da en mer funksjonsrik standard integrering er tilgjengelig.", + "invalid_config_entry": "Denne enheten vises som klar til \u00e5 sammenkoble, men det er allerede en motstridende konfigurasjonsoppf\u00f8ring for den i Home Assistant som m\u00e5 fjernes f\u00f8rst.", + "no_devices": "Ingen ukoblede enheter ble funnet" + }, + "error": { + "authentication_error": "Ugyldig HomeKit kode. Vennligst sjekk den og pr\u00f8v igjen.", + "unable_to_pair": "Kunne ikke koble til, vennligst pr\u00f8v igjen.", + "unknown_error": "Enheten rapporterte en ukjent feil. Sammenkobling mislyktes." + }, + "step": { + "pair": { + "data": { + "pairing_code": "Sammenkoblingskode" + }, + "description": "Skriv inn HomeKit sammenkoblingskoden for \u00e5 bruke dette tilbeh\u00f8ret", + "title": "Sammenkoble {{ model }}" + }, + "user": { + "data": { + "device": "Enhet" + }, + "description": "Velg enheten du vil koble til", + "title": "Koble til HomeKit tilbeh\u00f8r" + } + }, + "title": "HomeKit tilbeh\u00f8r" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/pl.json b/homeassistant/components/homekit_controller/.translations/pl.json new file mode 100644 index 00000000000..62aff22fee7 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/pl.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "description": "Wybierz urz\u0105dzenie, kt\u00f3re chcesz sparowa\u0107", + "title": "Sparuj z akcesorium HomeKit" + } + }, + "title": "Akcesorium HomeKit" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/ru.json b/homeassistant/components/homekit_controller/.translations/ru.json new file mode 100644 index 00000000000..21617f0f70d --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/ru.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "\u0410\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440 \u0443\u0436\u0435 \u0441\u0432\u044f\u0437\u0430\u043d \u0441 \u044d\u0442\u0438\u043c \u043a\u043e\u043d\u0442\u0440\u043e\u043b\u043b\u0435\u0440\u043e\u043c.", + "already_paired": "\u042d\u0442\u043e\u0442 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440 \u0443\u0436\u0435 \u0441\u0432\u044f\u0437\u0430\u043d \u0441 \u0434\u0440\u0443\u0433\u0438\u043c \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e\u043c. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u0435 \u0441\u0431\u0440\u043e\u0441 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u0430 \u0438 \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0441\u043d\u043e\u0432\u0430.", + "ignored_model": "\u041f\u043e\u0434\u0434\u0435\u0440\u0436\u043a\u0430 HomeKit \u0434\u043b\u044f \u044d\u0442\u043e\u0439 \u043c\u043e\u0434\u0435\u043b\u0438 \u0437\u0430\u0431\u043b\u043e\u043a\u0438\u0440\u043e\u0432\u0430\u043d\u0430, \u0442\u0430\u043a \u043a\u0430\u043a \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u0430 \u0431\u043e\u043b\u0435\u0435 \u043f\u043e\u043b\u043d\u0430\u044f \u043d\u0430\u0442\u0438\u0432\u043d\u0430\u044f \u0438\u043d\u0442\u0435\u0433\u0440\u0430\u0446\u0438\u044f.", + "invalid_config_entry": "\u042d\u0442\u043e \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u043e\u0442\u043e\u0431\u0440\u0430\u0436\u0430\u0435\u0442\u0441\u044f \u043a\u0430\u043a \u0433\u043e\u0442\u043e\u0432\u043e\u0435 \u043a \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044e, \u043d\u043e \u0432 Home Assistant \u0443\u0436\u0435 \u0435\u0441\u0442\u044c \u043a\u043e\u043d\u0444\u043b\u0438\u043a\u0442\u0443\u044e\u0449\u0430\u044f \u0437\u0430\u043f\u0438\u0441\u044c \u043a\u043e\u043d\u0444\u0438\u0433\u0443\u0440\u0430\u0446\u0438\u0438 \u0434\u043b\u044f \u043d\u0435\u0433\u043e, \u043a\u043e\u0442\u043e\u0440\u0443\u044e \u043d\u0435\u043e\u0431\u0445\u043e\u0434\u0438\u043c\u043e \u0443\u0434\u0430\u043b\u0438\u0442\u044c.", + "no_devices": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u0430, \u0434\u043e\u0441\u0442\u0443\u043f\u043d\u044b\u0435 \u0434\u043b\u044f \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f, \u043d\u0435 \u043d\u0430\u0439\u0434\u0435\u043d\u044b." + }, + "error": { + "authentication_error": "\u041d\u0435\u0432\u0435\u0440\u043d\u044b\u0439 \u043a\u043e\u0434 HomeKit. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u0440\u043e\u0432\u0435\u0440\u044c\u0442\u0435 \u043a\u043e\u0434 \u0438 \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.", + "unable_to_pair": "\u041d\u0435\u0432\u043e\u0437\u043c\u043e\u0436\u043d\u043e \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435. \u041f\u043e\u0436\u0430\u043b\u0443\u0439\u0441\u0442\u0430, \u043f\u043e\u043f\u0440\u043e\u0431\u0443\u0439\u0442\u0435 \u0435\u0449\u0435 \u0440\u0430\u0437.", + "unknown_error": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e \u0441\u043e\u043e\u0431\u0449\u0438\u043b\u043e \u043e \u043d\u0435\u0438\u0437\u0432\u0435\u0441\u0442\u043d\u043e\u0439 \u043e\u0448\u0438\u0431\u043a\u0435. \u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u043d\u0435 \u0443\u0434\u0430\u043b\u043e\u0441\u044c." + }, + "step": { + "pair": { + "data": { + "pairing_code": "\u041a\u043e\u0434 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f" + }, + "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u0434 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f HomeKit, \u0447\u0442\u043e\u0431\u044b \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u044d\u0442\u043e\u0442 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440", + "title": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0441 {{model}}" + }, + "user": { + "data": { + "device": "\u0423\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e" + }, + "description": "\u0412\u044b\u0431\u0435\u0440\u0438\u0442\u0435 \u0443\u0441\u0442\u0440\u043e\u0439\u0441\u0442\u0432\u043e, \u0441 \u043a\u043e\u0442\u043e\u0440\u044b\u043c \u043d\u0443\u0436\u043d\u043e \u0432\u044b\u043f\u043e\u043b\u043d\u0438\u0442\u044c \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435", + "title": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0441 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u043e\u043c HomeKit" + } + }, + "title": "HomeKit Accessory" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/zh-Hans.json b/homeassistant/components/homekit_controller/.translations/zh-Hans.json new file mode 100644 index 00000000000..a83b5be1f0a --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/zh-Hans.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "\u914d\u4ef6\u5df2\u901a\u8fc7\u6b64\u63a7\u5236\u5668\u914d\u7f6e\u5b8c\u6210\u3002", + "already_paired": "\u6b64\u914d\u4ef6\u5df2\u4e0e\u53e6\u4e00\u53f0\u8bbe\u5907\u914d\u5bf9\u3002\u8bf7\u91cd\u7f6e\u914d\u4ef6\uff0c\u7136\u540e\u91cd\u8bd5\u3002", + "ignored_model": "HomeKit \u5bf9\u6b64\u8bbe\u5907\u7684\u652f\u6301\u5df2\u88ab\u963b\u6b62\uff0c\u56e0\u4e3a\u6709\u529f\u80fd\u66f4\u5b8c\u6574\u7684\u539f\u751f\u96c6\u6210\u53ef\u4ee5\u4f7f\u7528\u3002", + "invalid_config_entry": "\u6b64\u8bbe\u5907\u5df2\u51c6\u5907\u597d\u914d\u5bf9\uff0c\u4f46\u662f Home Assistant \u4e2d\u5b58\u5728\u4e0e\u4e4b\u51b2\u7a81\u7684\u914d\u7f6e\uff0c\u5fc5\u987b\u5148\u5c06\u5176\u5220\u9664\u3002", + "no_devices": "\u6ca1\u6709\u627e\u5230\u672a\u914d\u5bf9\u7684\u8bbe\u5907" + }, + "error": { + "authentication_error": "HomeKit \u4ee3\u7801\u4e0d\u6b63\u786e\u3002\u8bf7\u68c0\u67e5\u540e\u91cd\u8bd5\u3002", + "unable_to_pair": "\u65e0\u6cd5\u914d\u5bf9\uff0c\u8bf7\u518d\u8bd5\u4e00\u6b21\u3002", + "unknown_error": "\u8bbe\u5907\u62a5\u544a\u4e86\u672a\u77e5\u9519\u8bef\u3002\u914d\u5bf9\u5931\u8d25\u3002" + }, + "step": { + "pair": { + "data": { + "pairing_code": "\u914d\u5bf9\u4ee3\u7801" + }, + "description": "\u8f93\u5165\u60a8\u7684 HomeKit \u914d\u5bf9\u4ee3\u7801\u4ee5\u4f7f\u7528\u6b64\u914d\u4ef6", + "title": "\u4e0e {{model}} \u914d\u5bf9" + }, + "user": { + "data": { + "device": "\u8bbe\u5907" + }, + "description": "\u9009\u62e9\u60a8\u8981\u914d\u5bf9\u7684\u8bbe\u5907", + "title": "\u4e0e HomeKit \u914d\u4ef6\u914d\u5bf9" + } + }, + "title": "HomeKit \u914d\u4ef6" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/zh-Hant.json b/homeassistant/components/homekit_controller/.translations/zh-Hant.json new file mode 100644 index 00000000000..cbe819fdaeb --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/zh-Hant.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "\u914d\u4ef6\u5df2\u7d93\u7531\u6b64\u63a7\u5236\u5668\u8a2d\u5b9a\u5b8c\u6210", + "already_paired": "\u914d\u4ef6\u5df2\u7d93\u8207\u5176\u4ed6\u88dd\u7f6e\u914d\u5c0d\uff0c\u8acb\u91cd\u7f6e\u914d\u4ef6\u5f8c\u518d\u8a66\u4e00\u6b21\u3002", + "ignored_model": "\u7531\u65bc\u6b64\u578b\u865f\u53ef\u539f\u751f\u652f\u63f4\u66f4\u5b8c\u6574\u529f\u80fd\uff0c\u56e0\u6b64 Homekit \u652f\u63f4\u5df2\u88ab\u7981\u6b62\u3002", + "invalid_config_entry": "\u88dd\u7f6e\u986f\u793a\u7b49\u5f85\u9032\u884c\u914d\u5c0d\uff0c\u4f46 Home Assistant \u986f\u793a\u6709\u76f8\u885d\u7a81\u8a2d\u5b9a\u7269\u4ef6\u5fc5\u9808\u5148\u884c\u79fb\u9664\u3002", + "no_devices": "\u627e\u4e0d\u5230\u4efb\u4f55\u672a\u914d\u5c0d\u88dd\u7f6e" + }, + "error": { + "authentication_error": "Homekit \u4ee3\u78bc\u932f\u8aa4\uff0c\u8acb\u78ba\u5b9a\u5f8c\u518d\u8a66\u4e00\u6b21\u3002", + "unable_to_pair": "\u7121\u6cd5\u914d\u5c0d\uff0c\u8acb\u518d\u8a66\u4e00\u6b21\u3002", + "unknown_error": "\u88dd\u7f6e\u56de\u5831\u672a\u77e5\u932f\u8aa4\u3002\u914d\u5c0d\u5931\u6557\u3002" + }, + "step": { + "pair": { + "data": { + "pairing_code": "\u8a2d\u5b9a\u4ee3\u78bc" + }, + "description": "\u8f38\u5165\u914d\u4ef6 Homekit \u8a2d\u5b9a\u4ee3\u78bc", + "title": "{{ model }} \u914d\u5c0d" + }, + "user": { + "data": { + "device": "\u88dd\u7f6e" + }, + "description": "\u9078\u64c7\u6240\u8981\u65b0\u589e\u7684\u88dd\u7f6e", + "title": "HomeKit \u914d\u4ef6\u914d\u5c0d" + } + }, + "title": "HomeKit \u914d\u4ef6" + } +} \ No newline at end of file diff --git a/homeassistant/components/homematicip_cloud/.translations/de.json b/homeassistant/components/homematicip_cloud/.translations/de.json index bd600f7d2ef..c2a7579e4fc 100644 --- a/homeassistant/components/homematicip_cloud/.translations/de.json +++ b/homeassistant/components/homematicip_cloud/.translations/de.json @@ -18,7 +18,7 @@ "name": "Name (optional, wird als Pr\u00e4fix f\u00fcr alle Ger\u00e4te verwendet)", "pin": "PIN Code (optional)" }, - "title": "HometicIP Accesspoint ausw\u00e4hlen" + "title": "HomematicIP Accesspoint ausw\u00e4hlen" }, "link": { "description": "Dr\u00fccke 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)", diff --git a/homeassistant/components/ios/.translations/cs.json b/homeassistant/components/ios/.translations/cs.json index a311daa6f9e..3f6c634f38f 100644 --- a/homeassistant/components/ios/.translations/cs.json +++ b/homeassistant/components/ios/.translations/cs.json @@ -1,5 +1,8 @@ { "config": { + "abort": { + "single_instance_allowed": "Povolena je pouze jedna instance Home Assistant iOS." + }, "step": { "confirm": { "description": "Chcete nastavit komponenty Home Assistant iOS?", diff --git a/homeassistant/components/locative/.translations/cs.json b/homeassistant/components/locative/.translations/cs.json new file mode 100644 index 00000000000..d48b6ff13d9 --- /dev/null +++ b/homeassistant/components/locative/.translations/cs.json @@ -0,0 +1,18 @@ +{ + "config": { + "abort": { + "not_internet_accessible": "Va\u0161e instanci dom\u00e1c\u00edho asistenta mus\u00ed b\u00fdt p\u0159\u00edstupn\u00e1 z internetu a p\u0159ij\u00edmat zpr\u00e1vy od spole\u010dnosti Geofency.", + "one_instance_allowed": "Povolena je pouze jedna instance." + }, + "create_entry": { + "default": "Chcete-li odes\u00edlat um\u00edst\u011bn\u00ed do aplikace Home Assistant, budete muset nastavit funkci Webhook v aplikaci Locative. \n\n Vypl\u0148te n\u00e1sleduj\u00edc\u00ed informace: \n\n - URL: ` {webhook_url} ' \n - Metoda: POST \n\n Dal\u0161\u00ed podrobnosti naleznete v [dokumentaci] ( {docs_url} )." + }, + "step": { + "user": { + "description": "Opravdu chcete nastavit Locative Webhook?", + "title": "Nastavit Locative Webhook" + } + }, + "title": "Locative Webhook" + } +} \ No newline at end of file diff --git a/homeassistant/components/locative/.translations/nl.json b/homeassistant/components/locative/.translations/nl.json index 237d21c46ee..26ec0951d88 100644 --- a/homeassistant/components/locative/.translations/nl.json +++ b/homeassistant/components/locative/.translations/nl.json @@ -1,6 +1,7 @@ { "config": { "abort": { + "not_internet_accessible": "Je Home Assistant instance moet bereikbaar zijn vanuit het internet om berichten van Geofency te ontvangen.", "one_instance_allowed": "Slechts \u00e9\u00e9n instantie is nodig." }, "create_entry": { diff --git a/homeassistant/components/locative/.translations/zh-Hans.json b/homeassistant/components/locative/.translations/zh-Hans.json index 96626a57c5b..967671de535 100644 --- a/homeassistant/components/locative/.translations/zh-Hans.json +++ b/homeassistant/components/locative/.translations/zh-Hans.json @@ -1,7 +1,7 @@ { "config": { "abort": { - "not_internet_accessible": "\u60a8\u7684Home Assistant\u5b9e\u4f8b\u9700\u8981\u53ef\u4ee5\u4eceInternet\u8bbf\u95ee\u4ee5\u63a5\u6536\u6765\u81eaGeofency\u7684\u6d88\u606f\u3002", + "not_internet_accessible": "\u60a8\u7684 Home Assistant \u5b9e\u4f8b\u9700\u8981\u53ef\u4ece\u4e92\u8054\u7f51\u8bbf\u95ee\u4ee5\u63a5\u6536 Geofency \u6d88\u606f\u3002", "one_instance_allowed": "\u53ea\u6709\u4e00\u4e2a\u5b9e\u4f8b\u662f\u5fc5\u9700\u7684\u3002" }, "step": { diff --git a/homeassistant/components/owntracks/.translations/cs.json b/homeassistant/components/owntracks/.translations/cs.json new file mode 100644 index 00000000000..25738b7618e --- /dev/null +++ b/homeassistant/components/owntracks/.translations/cs.json @@ -0,0 +1,17 @@ +{ + "config": { + "abort": { + "one_instance_allowed": "Povolena je pouze jedna instance." + }, + "create_entry": { + "default": "\n\n V syst\u00e9mu Android otev\u0159ete aplikaci [OwnTracks]({android_url}) a p\u0159ejd\u011bte na p\u0159edvolby - > p\u0159ipojen\u00ed. Zm\u011b\u0148te n\u00e1sleduj\u00edc\u00ed nastaven\u00ed: \n - Re\u017eim: Private HTTP \n - Hostitel: {webhook_url} \n - Identifikace: \n - U\u017eivatelsk\u00e9 jm\u00e9no: ` ' \n - ID za\u0159\u00edzen\u00ed: ` ' \n\n V aplikaci iOS otev\u0159ete [aplikaci OwnTracks]({ios_url}), klepn\u011bte na ikonu (i) vlevo naho\u0159e - > nastaven\u00ed. Zm\u011b\u0148te n\u00e1sleduj\u00edc\u00ed nastaven\u00ed: \n - Re\u017eim: HTTP \n - URL: {webhook_url} \n - Zapn\u011bte ov\u011b\u0159ov\u00e1n\u00ed \n - ID u\u017eivatele: ` ' \n\n {secret} \n \n V\u00edce informac\u00ed naleznete v [dokumentaci]({docs_url})." + }, + "step": { + "user": { + "description": "Opravdu chcete nastavit slu\u017ebu OwnTracks?", + "title": "Nastavit OwnTracks" + } + }, + "title": "OwnTracks" + } +} \ No newline at end of file diff --git a/homeassistant/components/point/.translations/zh-Hans.json b/homeassistant/components/point/.translations/zh-Hans.json index ebd2b88b10e..16d1bddbaf7 100644 --- a/homeassistant/components/point/.translations/zh-Hans.json +++ b/homeassistant/components/point/.translations/zh-Hans.json @@ -1,12 +1,12 @@ { "config": { "abort": { - "authorize_url_fail": "\u751f\u6210\u6388\u6743URL\u65f6\u53d1\u751f\u672a\u77e5\u9519\u8bef\u3002", - "authorize_url_timeout": "\u751f\u6210\u6388\u6743URL\u8d85\u65f6" + "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" }, "error": { "follow_link": "\u8bf7\u5728\u70b9\u51fb\u63d0\u4ea4\u524d\u6309\u7167\u94fe\u63a5\u8fdb\u884c\u8eab\u4efd\u9a8c\u8bc1", - "no_token": "\u672a\u7ecfMinut\u9a8c\u8bc1" + "no_token": "\u672a\u7ecf Minut \u9a8c\u8bc1" }, "step": { "auth": { diff --git a/homeassistant/components/ps4/.translations/nl.json b/homeassistant/components/ps4/.translations/nl.json new file mode 100644 index 00000000000..5023cd87191 --- /dev/null +++ b/homeassistant/components/ps4/.translations/nl.json @@ -0,0 +1,17 @@ +{ + "config": { + "step": { + "link": { + "data": { + "code": "PIN", + "ip_address": "IP-adres", + "name": "Naam", + "region": "Regio" + }, + "description": "Voer je PlayStation 4 informatie in. Voor 'PIN', blader naar 'Instellingen' op je PlayStation 4. Blader dan naar 'Mobiele App verbindingsinstellingen' en kies 'Apparaat toevoegen'. Voer de weergegeven PIN-code in.", + "title": "PlayStation 4" + } + }, + "title": "PlayStation 4" + } +} \ No newline at end of file diff --git a/homeassistant/components/ps4/.translations/zh-Hans.json b/homeassistant/components/ps4/.translations/zh-Hans.json new file mode 100644 index 00000000000..8c975e8170c --- /dev/null +++ b/homeassistant/components/ps4/.translations/zh-Hans.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "credential_error": "\u83b7\u53d6\u51ed\u636e\u65f6\u51fa\u9519\u3002", + "devices_configured": "\u6240\u6709\u53d1\u73b0\u7684\u8bbe\u5907\u90fd\u5df2\u914d\u7f6e\u5b8c\u6210\u3002", + "no_devices_found": "\u6ca1\u6709\u5728\u7f51\u7edc\u4e0a\u627e\u5230 PlayStation 4 \u8bbe\u5907\u3002", + "port_987_bind_error": "\u65e0\u6cd5\u7ed1\u5b9a\u7aef\u53e3 987\u3002", + "port_997_bind_error": "\u65e0\u6cd5\u7ed1\u5b9a\u7aef\u53e3 997\u3002" + }, + "error": { + "login_failed": "\u65e0\u6cd5\u4e0e PlayStation 4 \u914d\u5bf9\u3002\u8bf7\u786e\u8ba4 PIN \u662f\u5426\u6b63\u786e\u3002", + "not_ready": "PlayStation 4 \u672a\u5f00\u673a\u6216\u672a\u8fde\u63a5\u5230\u7f51\u7edc\u3002" + }, + "step": { + "creds": { + "description": "\u9700\u8981\u51ed\u636e\u3002\u8bf7\u70b9\u51fb\u201c\u63d0\u4ea4\u201d\u7136\u540e\u5728 PS4 \u7b2c\u4e8c\u5c4f\u5e55\u5e94\u7528\u7a0b\u5e8f\u4e2d\u5237\u65b0\u8bbe\u5907\u5e76\u9009\u62e9\u201cHome-Assistant\u201d\u8bbe\u5907\u4ee5\u7ee7\u7eed\u3002", + "title": "PlayStation 4" + }, + "link": { + "data": { + "code": "PIN", + "ip_address": "IP \u5730\u5740", + "name": "\u540d\u79f0", + "region": "\u5730\u533a" + }, + "title": "PlayStation 4" + } + }, + "title": "PlayStation 4" + } +} \ No newline at end of file diff --git a/homeassistant/components/tellduslive/.translations/de.json b/homeassistant/components/tellduslive/.translations/de.json index a9f91f16b11..6c094ed6a8c 100644 --- a/homeassistant/components/tellduslive/.translations/de.json +++ b/homeassistant/components/tellduslive/.translations/de.json @@ -19,6 +19,7 @@ "data": { "host": "Host" }, + "description": "Leer", "title": "Endpunkt ausw\u00e4hlen." } }, diff --git a/homeassistant/components/tellduslive/.translations/zh-Hans.json b/homeassistant/components/tellduslive/.translations/zh-Hans.json index f707b1f15f8..4b1afd548e8 100644 --- a/homeassistant/components/tellduslive/.translations/zh-Hans.json +++ b/homeassistant/components/tellduslive/.translations/zh-Hans.json @@ -2,8 +2,8 @@ "config": { "abort": { "all_configured": "Tellduslive \u5df2\u914d\u7f6e\u5b8c\u6210", - "authorize_url_fail": "\u751f\u6210\u6388\u6743URL\u65f6\u53d1\u751f\u672a\u77e5\u9519\u8bef\u3002", - "authorize_url_timeout": "\u751f\u6210\u6388\u6743URL\u8d85\u65f6", + "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", "unknown": "\u53d1\u751f\u672a\u77e5\u7684\u9519\u8bef" }, "error": { @@ -11,13 +11,12 @@ }, "step": { "auth": { - "description": "\u8981\u94fe\u63a5\u60a8\u7684TelldusLive\u8d26\u6237\uff1a \n 1.\u5355\u51fb\u4e0b\u9762\u7684\u94fe\u63a5\n 2.\u767b\u5f55Telldus Live \n 3.\u6388\u6743 **{app_name}** (\u70b9\u51fb **\u662f**)\u3002 \n 4.\u56de\u5230\u8fd9\u91cc\uff0c\u7136\u540e\u70b9\u51fb**\u63d0\u4ea4**\u3002 \n\n [TelldusLive\u8d26\u6237\u94fe\u63a5]({auth_url})" + "description": "\u8981\u94fe\u63a5\u60a8\u7684TelldusLive\u8d26\u6237\uff1a \n 1. \u70b9\u51fb\u4e0b\u9762\u7684\u94fe\u63a5\n 2. \u767b\u5f55 Telldus Live \n 3. \u6388\u6743 **{app_name}** (\u70b9\u51fb **\u662f**)\u3002 \n 4. \u8fd4\u56de\u6b64\u9875\uff0c\u7136\u540e\u70b9\u51fb**\u63d0\u4ea4**\u3002 \n\n [\u94fe\u63a5 TelldusLive \u8d26\u6237]({auth_url})" }, "user": { "data": { "host": "\u4e3b\u673a" - }, - "description": "\u7a7a\u767d" + } } } } diff --git a/homeassistant/components/toon/.translations/de.json b/homeassistant/components/toon/.translations/de.json new file mode 100644 index 00000000000..cbcfd5d4adc --- /dev/null +++ b/homeassistant/components/toon/.translations/de.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "client_id": "Die Client-ID aus der Konfiguration ist ung\u00fcltig.", + "client_secret": "Das Client-Secret aus der Konfiguration ist ung\u00fcltig.", + "no_agreements": "Dieses Konto hat keine Toon-Anzeigen.", + "no_app": "Toon muss konfiguriert werden, bevor die Authentifizierung durchgef\u00fchrt werden kann. [Lies bitte die Anleitung](https://www.home-assistant.io/components/toon/).", + "unknown_auth_fail": "Beim Authentifizieren ist ein unerwarteter Fehler aufgetreten." + }, + "error": { + "credentials": "Die angegebenen Anmeldeinformationen sind ung\u00fcltig.", + "display_exists": "Die ausgew\u00e4hlte Anzeige ist bereits konfiguriert." + }, + "step": { + "authenticate": { + "data": { + "password": "Passwort", + "tenant": "Tenant", + "username": "Benutzername" + }, + "description": "Authentifiziere dich mit deinem Eneco Toon-Konto (nicht dem Entwicklerkonto).", + "title": "Verkn\u00fcpfe dein Toon-Konto" + }, + "display": { + "data": { + "display": "Anzeige w\u00e4hlen" + }, + "description": "W\u00e4hle die Toon-Anzeige aus, die verbunden werden soll.", + "title": "Anzeige ausw\u00e4hlen" + } + }, + "title": "Toon" + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/.translations/lt.json b/homeassistant/components/toon/.translations/lt.json new file mode 100644 index 00000000000..4c2802218f2 --- /dev/null +++ b/homeassistant/components/toon/.translations/lt.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "authenticate": { + "data": { + "password": "Slapta\u017eodis" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/.translations/nl.json b/homeassistant/components/toon/.translations/nl.json new file mode 100644 index 00000000000..131a7b6cf7c --- /dev/null +++ b/homeassistant/components/toon/.translations/nl.json @@ -0,0 +1,34 @@ +{ + "config": { + "abort": { + "client_id": "De client ID uit de configuratie is ongeldig.", + "client_secret": "De client secret uit de configuratie is ongeldig.", + "no_agreements": "Dit account heeft geen Toon-schermen.", + "no_app": "Je moet Toon configureren voordat je ermee kunt aanmelden. [Lees de instructies](https://www.home-assistant.io/components/toon/).", + "unknown_auth_fail": "Onverwachte fout tijdens het verifi\u00ebren." + }, + "error": { + "credentials": "De opgegeven inloggegevens zijn ongeldig.", + "display_exists": "Het gekozen scherm is al geconfigureerd." + }, + "step": { + "authenticate": { + "data": { + "password": "Wachtwoord", + "tenant": "Huurder", + "username": "Gebruikersnaam" + }, + "description": "Verifieer met je Eneco Toon-account (niet het developer-account).", + "title": "Link je Toon-account" + }, + "display": { + "data": { + "display": "Kies scherm" + }, + "description": "Kies het Toon-scherm om mee te verbinden.", + "title": "Kies scherm" + } + }, + "title": "Toon" + } +} \ No newline at end of file diff --git a/homeassistant/components/tplink/.translations/nl.json b/homeassistant/components/tplink/.translations/nl.json new file mode 100644 index 00000000000..622315fd84c --- /dev/null +++ b/homeassistant/components/tplink/.translations/nl.json @@ -0,0 +1,15 @@ +{ + "config": { + "abort": { + "no_devices_found": "Geen TP-Link apparaten gevonden op het netwerk.", + "single_instance_allowed": "Slechts \u00e9\u00e9n configuratie is nodig." + }, + "step": { + "confirm": { + "description": "Wil je TP-Link slimme apparaten instellen?", + "title": "TP-Link Smart Home" + } + }, + "title": "TP-Link Smart Home" + } +} \ No newline at end of file diff --git a/homeassistant/components/upnp/.translations/cs.json b/homeassistant/components/upnp/.translations/cs.json index 58de4963000..17d9949453c 100644 --- a/homeassistant/components/upnp/.translations/cs.json +++ b/homeassistant/components/upnp/.translations/cs.json @@ -4,9 +4,15 @@ "already_configured": "UPnP/IGD je ji\u017e nakonfigurov\u00e1no", "incomplete_device": "Ignorov\u00e1n\u00ed ne\u00fapln\u00e9ho za\u0159\u00edzen\u00ed UPnP", "no_devices_discovered": "Nebyly zji\u0161t\u011bny \u017e\u00e1dn\u00e9 UPnP/IGD", - "no_sensors_or_port_mapping": "Povolte senzory nebo mapov\u00e1n\u00ed port\u016f" + "no_devices_found": "V s\u00edti nejsou nalezena \u017e\u00e1dn\u00e1 za\u0159\u00edzen\u00ed UPnP/IGD.", + "no_sensors_or_port_mapping": "Povolte senzory nebo mapov\u00e1n\u00ed port\u016f", + "single_instance_allowed": "Povolena je pouze jedna instance UPnP/IGD." }, "step": { + "confirm": { + "description": "Chcete nastavit UPnP/IGD?", + "title": "UPnP/IGD" + }, "init": { "title": "UPnP/IGD" }, From ad73b6eee9efdbe4976d156d2eee53b4ae39c7f0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 9 Mar 2019 21:26:35 -0800 Subject: [PATCH 168/291] Updated frontend to 20190309.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index d7c1aabdb49..8f1e563b782 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -21,7 +21,7 @@ from homeassistant.loader import bind_hass from .storage import async_setup_frontend_storage -REQUIREMENTS = ['home-assistant-frontend==20190305.0'] +REQUIREMENTS = ['home-assistant-frontend==20190309.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 8476bc1f1ab..ab9c260158f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -548,7 +548,7 @@ hole==0.3.0 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190305.0 +home-assistant-frontend==20190309.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index bca0bd4365f..af4a21e9366 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -120,7 +120,7 @@ hdate==0.8.7 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190305.0 +home-assistant-frontend==20190309.0 # homeassistant.components.homekit_controller homekit==0.12.2 From 7102e82113912521546072990ff4b94331c6aa52 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Sun, 10 Mar 2019 10:08:13 +0100 Subject: [PATCH 169/291] Change lib for whois sensor (#21878) * Change lib for whois sensor * Change requirements.txt --- homeassistant/components/sensor/whois.py | 25 ++++++++++++------------ requirements_all.txt | 6 +++--- requirements_test_all.txt | 3 --- 3 files changed, 15 insertions(+), 19 deletions(-) diff --git a/homeassistant/components/sensor/whois.py b/homeassistant/components/sensor/whois.py index b589caddc79..a189c0e858e 100644 --- a/homeassistant/components/sensor/whois.py +++ b/homeassistant/components/sensor/whois.py @@ -14,7 +14,7 @@ from homeassistant.const import CONF_NAME import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pythonwhois==2.4.3'] +REQUIREMENTS = ['python-whois==0.7.1'] _LOGGER = logging.getLogger(__name__) @@ -37,21 +37,20 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the WHOIS sensor.""" - from pythonwhois import get_whois - from pythonwhois.shared import WhoisException + import whois domain = config.get(CONF_DOMAIN) name = config.get(CONF_NAME) try: - if 'expiration_date' in get_whois(domain, normalized=True): + if 'expiration_date' in whois.whois(domain): add_entities([WhoisSensor(name, domain)], True) else: _LOGGER.error( "WHOIS lookup for %s didn't contain expiration_date", domain) return - except WhoisException as ex: + except whois.BaseException as ex: _LOGGER.error( "Exception %s occurred during WHOIS lookup for %s", ex, domain) return @@ -62,9 +61,9 @@ class WhoisSensor(Entity): def __init__(self, name, domain): """Initialize the sensor.""" - from pythonwhois import get_whois + import whois - self.whois = get_whois + self.whois = whois.whois self._name = name self._domain = domain @@ -104,11 +103,11 @@ class WhoisSensor(Entity): def update(self): """Get the current WHOIS data for the domain.""" - from pythonwhois.shared import WhoisException + import whois try: - response = self.whois(self._domain, normalized=True) - except WhoisException as ex: + response = self.whois(self._domain) + except whois.BaseException as ex: _LOGGER.error("Exception %s occurred during WHOIS lookup", ex) self._empty_state_and_attributes() return @@ -128,17 +127,17 @@ class WhoisSensor(Entity): attrs = {} - expiration_date = response['expiration_date'][0] + expiration_date = response['expiration_date'] attrs[ATTR_EXPIRES] = expiration_date.isoformat() if 'nameservers' in response: attrs[ATTR_NAME_SERVERS] = ' '.join(response['nameservers']) if 'updated_date' in response: - attrs[ATTR_UPDATED] = response['updated_date'][0].isoformat() + attrs[ATTR_UPDATED] = response['updated_date'].isoformat() if 'registrar' in response: - attrs[ATTR_REGISTRAR] = response['registrar'][0] + attrs[ATTR_REGISTRAR] = response['registrar'] time_delta = (expiration_date - expiration_date.now()) diff --git a/requirements_all.txt b/requirements_all.txt index ab9c260158f..cf60d0cc3a7 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1402,6 +1402,9 @@ python-velbus==2.0.22 # homeassistant.components.media_player.vlc python-vlc==1.1.2 +# homeassistant.components.sensor.whois +python-whois==0.7.1 + # homeassistant.components.wink python-wink==1.10.3 @@ -1414,9 +1417,6 @@ python_opendata_transport==0.1.4 # homeassistant.components.egardia pythonegardia==1.0.39 -# homeassistant.components.sensor.whois -pythonwhois==2.4.3 - # homeassistant.components.device_tracker.tile pytile==2.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index af4a21e9366..b6d11228b93 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -245,9 +245,6 @@ python-nest==4.1.0 # homeassistant.components.sensor.awair python_awair==0.0.3 -# homeassistant.components.sensor.whois -pythonwhois==2.4.3 - # homeassistant.components.tradfri pytradfri[async]==6.0.1 From 6f77d9bc34501510abe657a0b3aa54afa18e8fa5 Mon Sep 17 00:00:00 2001 From: Julius Mittenzwei Date: Sun, 10 Mar 2019 11:47:22 +0100 Subject: [PATCH 170/291] Don't wait until final position of Velux cover is reached (#21558) ## Description: * Bump version to latest version of pyvlx: 0.2.10. Library more failure tolerant, when detecting an unsupported device. * When calling API (e.g. run scene, change position) don't wait until device has reached target position (This caused HASS to be flaky while the device was moving) * Support for vertical and horizontal awnings. --- homeassistant/components/velux/__init__.py | 2 +- homeassistant/components/velux/cover.py | 9 +++++---- homeassistant/components/velux/scene.py | 2 +- requirements_all.txt | 2 +- 4 files changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/velux/__init__.py b/homeassistant/components/velux/__init__.py index 6ea50ae6c0d..a46f62dbd5f 100644 --- a/homeassistant/components/velux/__init__.py +++ b/homeassistant/components/velux/__init__.py @@ -12,7 +12,7 @@ DATA_VELUX = "data_velux" SUPPORTED_DOMAINS = ['cover', 'scene'] _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['pyvlx==0.2.9'] +REQUIREMENTS = ['pyvlx==0.2.10'] CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ diff --git a/homeassistant/components/velux/cover.py b/homeassistant/components/velux/cover.py index 1c3192961af..6abaa42bb9d 100644 --- a/homeassistant/components/velux/cover.py +++ b/homeassistant/components/velux/cover.py @@ -71,11 +71,11 @@ class VeluxCover(CoverDevice): async def async_close_cover(self, **kwargs): """Close the cover.""" - await self.node.close() + await self.node.close(wait_for_completion=False) async def async_open_cover(self, **kwargs): """Open the cover.""" - await self.node.open() + await self.node.open(wait_for_completion=False) async def async_set_cover_position(self, **kwargs): """Move the cover to a specific position.""" @@ -83,8 +83,9 @@ class VeluxCover(CoverDevice): position_percent = 100 - kwargs[ATTR_POSITION] from pyvlx import Position await self.node.set_position( - Position(position_percent=position_percent)) + Position(position_percent=position_percent), + wait_for_completion=False) async def async_stop_cover(self, **kwargs): """Stop the cover.""" - await self.node.stop() + await self.node.stop(wait_for_completion=False) diff --git a/homeassistant/components/velux/scene.py b/homeassistant/components/velux/scene.py index db1e9450daf..b0716dc2cb8 100644 --- a/homeassistant/components/velux/scene.py +++ b/homeassistant/components/velux/scene.py @@ -29,4 +29,4 @@ class VeluxScene(Scene): async def async_activate(self): """Activate the scene.""" - await self.scene.run() + await self.scene.run(wait_for_completion=False) diff --git a/requirements_all.txt b/requirements_all.txt index cf60d0cc3a7..1888bd72ae5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1457,7 +1457,7 @@ pyvesync_v2==0.9.6 pyvizio==0.0.4 # homeassistant.components.velux -pyvlx==0.2.9 +pyvlx==0.2.10 # homeassistant.components.notify.html5 pywebpush==1.6.0 From 05333f60d701a50b21f294739d9daa9dfa937da2 Mon Sep 17 00:00:00 2001 From: Marco M Date: Sun, 10 Mar 2019 14:25:01 +0100 Subject: [PATCH 171/291] Fix missing code_required check in async_alarm_arm_night (#21858) * Fix missing code_required check in async_alarm_arm_night * Remove double code validation Test added --- .../components/mqtt/alarm_control_panel.py | 3 +- .../mqtt/test_alarm_control_panel.py | 42 +++++++++++++++++++ 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 3602defd02a..4d96e73fb23 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -227,7 +227,8 @@ class MqttAlarm(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, This method is a coroutine. """ - if not self._validate_code(code, 'arming night'): + code_required = self._config.get(CONF_CODE_ARM_REQUIRED) + if code_required and not self._validate_code(code, 'arming night'): return mqtt.async_publish( self.hass, self._config.get(CONF_COMMAND_TOPIC), diff --git a/tests/components/mqtt/test_alarm_control_panel.py b/tests/components/mqtt/test_alarm_control_panel.py index 4db66774b6e..742aafba8dc 100644 --- a/tests/components/mqtt/test_alarm_control_panel.py +++ b/tests/components/mqtt/test_alarm_control_panel.py @@ -135,6 +135,27 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): self.hass.block_till_done() assert call_count == self.mock_publish.call_count + def test_arm_home_publishes_mqtt_when_code_not_req(self): + """Test publishing of MQTT messages. + + When code_arm_required = False + """ + assert setup_component(self.hass, alarm_control_panel.DOMAIN, { + alarm_control_panel.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'alarm/state', + 'command_topic': 'alarm/command', + 'code': '1234', + 'code_arm_required': False + } + }) + + common.alarm_arm_home(self.hass) + self.hass.block_till_done() + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/command', 'ARM_HOME', 0, False) + def test_arm_away_publishes_mqtt(self): """Test publishing of MQTT messages while armed.""" assert setup_component(self.hass, alarm_control_panel.DOMAIN, { @@ -230,6 +251,27 @@ class TestAlarmControlPanelMQTT(unittest.TestCase): self.hass.block_till_done() assert call_count == self.mock_publish.call_count + def test_arm_night_publishes_mqtt_when_code_not_req(self): + """Test publishing of MQTT messages. + + When code_arm_required = False + """ + assert setup_component(self.hass, alarm_control_panel.DOMAIN, { + alarm_control_panel.DOMAIN: { + 'platform': 'mqtt', + 'name': 'test', + 'state_topic': 'alarm/state', + 'command_topic': 'alarm/command', + 'code': '1234', + 'code_arm_required': False + } + }) + + common.alarm_arm_night(self.hass) + self.hass.block_till_done() + self.mock_publish.async_publish.assert_called_once_with( + 'alarm/command', 'ARM_NIGHT', 0, False) + def test_disarm_publishes_mqtt(self): """Test publishing of MQTT messages while disarmed.""" assert setup_component(self.hass, alarm_control_panel.DOMAIN, { From 6456f71a46569f2015cc19ec9fcd211893f80975 Mon Sep 17 00:00:00 2001 From: Thibault Maekelbergh Date: Sun, 10 Mar 2019 14:49:31 +0100 Subject: [PATCH 172/291] Fix icon for sensor.discogs_random_record (#21891) * Fix icon for sensor.discogs_random_record * Add myself to CODEOWNERS for Discogs code --- CODEOWNERS | 1 + homeassistant/components/sensor/discogs.py | 10 +++++----- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index ed55211a9cf..2125ca8a626 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -97,6 +97,7 @@ homeassistant/components/sensor/bitcoin.py @fabaff homeassistant/components/sensor/cpuspeed.py @fabaff homeassistant/components/sensor/cups.py @fabaff homeassistant/components/sensor/darksky.py @fabaff +homeassistant/components/sensor/discogs.py @thibmaek homeassistant/components/sensor/file.py @fabaff homeassistant/components/sensor/filter.py @dgomes homeassistant/components/sensor/fixer.py @fabaff diff --git a/homeassistant/components/sensor/discogs.py b/homeassistant/components/sensor/discogs.py index ecbd6d9cab1..8cdc89a540e 100644 --- a/homeassistant/components/sensor/discogs.py +++ b/homeassistant/components/sensor/discogs.py @@ -40,17 +40,17 @@ SENSOR_RANDOM_RECORD_TYPE = 'random_record' SENSORS = { SENSOR_COLLECTION_TYPE: { 'name': 'Collection', - 'icon': 'mdi:album', - 'unit_of_measurement': 'records' + 'icon': ICON_RECORD, + 'unit_of_measurement': UNIT_RECORDS }, SENSOR_WANTLIST_TYPE: { 'name': 'Wantlist', - 'icon': 'mdi:album', - 'unit_of_measurement': 'records' + 'icon': ICON_RECORD, + 'unit_of_measurement': UNIT_RECORDS }, SENSOR_RANDOM_RECORD_TYPE: { 'name': 'Random Record', - 'icon': 'mdi:record_player', + 'icon': ICON_PLAYER, 'unit_of_measurement': None }, } From 65ff8b727a135c155e911620d5a57fd556a18f6c Mon Sep 17 00:00:00 2001 From: Thibault Maekelbergh Date: Sun, 10 Mar 2019 15:47:17 +0100 Subject: [PATCH 173/291] Add myself to CODEOWNERS for NMBS code (#21892) * Add myself to CODEOWNERS for NMBS code * Fix --- CODEOWNERS | 1 + 1 file changed, 1 insertion(+) diff --git a/CODEOWNERS b/CODEOWNERS index 2125ca8a626..a795c4c3151 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -115,6 +115,7 @@ homeassistant/components/sensor/miflora.py @danielhiversen @ChristianKuehnel homeassistant/components/sensor/min_max.py @fabaff homeassistant/components/sensor/moon.py @fabaff homeassistant/components/sensor/netdata.py @fabaff +homeassistant/components/sensor/nmbs.py @thibmaek homeassistant/components/sensor/nsw_fuel_station.py @nickw444 homeassistant/components/sensor/pi_hole.py @fabaff homeassistant/components/sensor/pollen.py @bachya From bab53a1c9445da5729bbebd5a030fda6be3258b3 Mon Sep 17 00:00:00 2001 From: gertdb <42268196+gertdb@users.noreply.github.com> Date: Sun, 10 Mar 2019 16:08:29 +0100 Subject: [PATCH 174/291] Modbus write_register accept single value and array (#21621) * Update __init__.py * Update services.yaml --- homeassistant/components/modbus/__init__.py | 4 +++- homeassistant/components/modbus/services.yaml | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/modbus/__init__.py b/homeassistant/components/modbus/__init__.py index 182e3dc28fa..0500a904cb9 100644 --- a/homeassistant/components/modbus/__init__.py +++ b/homeassistant/components/modbus/__init__.py @@ -60,7 +60,9 @@ SERVICE_WRITE_REGISTER_SCHEMA = vol.Schema({ vol.Optional(ATTR_HUB, default=DEFAULT_HUB): cv.string, vol.Required(ATTR_UNIT): cv.positive_int, vol.Required(ATTR_ADDRESS): cv.positive_int, - vol.Required(ATTR_VALUE): vol.All(cv.ensure_list, [cv.positive_int]) + vol.Required(ATTR_VALUE): vol.Any( + cv.positive_int, + vol.All(cv.ensure_list, [cv.positive_int])) }) SERVICE_WRITE_COIL_SCHEMA = vol.Schema({ diff --git a/homeassistant/components/modbus/services.yaml b/homeassistant/components/modbus/services.yaml index 0fd9e5a49e7..48e9e815aaa 100644 --- a/homeassistant/components/modbus/services.yaml +++ b/homeassistant/components/modbus/services.yaml @@ -9,4 +9,4 @@ write_register: fields: address: {description: Address of the holding register to write to., example: 0} unit: {description: Address of the modbus unit., example: 21} - value: {description: Value to write., example: 0} + value: {description: Value (single value or array) to write., example: 0 or [4,0]} From c888e65f11cbe9aff35afb73c84b6a7200df3892 Mon Sep 17 00:00:00 2001 From: Peter Epley Date: Sun, 10 Mar 2019 13:14:45 -0400 Subject: [PATCH 175/291] Add custom holidays to workday sensor (#21718) * Add custom holidays to workday sensor * correcting copy/paste errors * resolve block comment and add default * resolve line too long * fixed handling of no custom holidays * hound fixes * rerun Travis * @fabaff requested changes * spaces v tabs * Fix validation --- homeassistant/components/binary_sensor/workday.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/homeassistant/components/binary_sensor/workday.py b/homeassistant/components/binary_sensor/workday.py index 551ca835e78..6b547927af4 100644 --- a/homeassistant/components/binary_sensor/workday.py +++ b/homeassistant/components/binary_sensor/workday.py @@ -42,6 +42,7 @@ CONF_PROVINCE = 'province' CONF_WORKDAYS = 'workdays' CONF_EXCLUDES = 'excludes' CONF_OFFSET = 'days_offset' +CONF_ADD_HOLIDAYS = 'add_holidays' # By default, Monday - Friday are workdays DEFAULT_WORKDAYS = ['mon', 'tue', 'wed', 'thu', 'fri'] @@ -59,6 +60,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_PROVINCE): cv.string, vol.Optional(CONF_WORKDAYS, default=DEFAULT_WORKDAYS): vol.All(cv.ensure_list, [vol.In(ALLOWED_DAYS)]), + vol.Optional(CONF_ADD_HOLIDAYS): vol.All(cv.ensure_list, [cv.string]), }) @@ -72,6 +74,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): workdays = config.get(CONF_WORKDAYS) excludes = config.get(CONF_EXCLUDES) days_offset = config.get(CONF_OFFSET) + add_holidays = config.get(CONF_ADD_HOLIDAYS) year = (get_date(datetime.today()) + timedelta(days=days_offset)).year obj_holidays = getattr(holidays, country)(years=year) @@ -92,6 +95,12 @@ def setup_platform(hass, config, add_entities, discovery_info=None): province, country) return + # Add custom holidays + try: + obj_holidays.append(add_holidays) + except TypeError: + _LOGGER.debug("No custom holidays or invalid holidays") + _LOGGER.debug("Found the following holidays for your configuration:") for date, name in sorted(obj_holidays.items()): _LOGGER.debug("%s %s", date, name) From 5debc8828a4ea40b8e2425368139395c83421b22 Mon Sep 17 00:00:00 2001 From: Niall Donegan Date: Sun, 10 Mar 2019 18:22:28 +0000 Subject: [PATCH 176/291] Return time based attributes as datetime in Unifi module (#21146) * Return time based data as datetime in Unifi module * Fix missing space and pylint complaints about import order. --- .../components/device_tracker/unifi.py | 9 +++++++- tests/components/device_tracker/test_unifi.py | 22 ++++++++++++------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/device_tracker/unifi.py b/homeassistant/components/device_tracker/unifi.py index 0c0c908d1e0..2dc5f7a4df3 100644 --- a/homeassistant/components/device_tracker/unifi.py +++ b/homeassistant/components/device_tracker/unifi.py @@ -43,6 +43,8 @@ AVAILABLE_ATTRS = [ 'uptime', 'user_id', 'usergroup_id', 'vlan' ] +TIMESTAMP_ATTRS = ['first_seen', 'last_seen', 'latest_assoc_time'] + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, vol.Optional(CONF_SITE_ID, default='default'): cv.string, @@ -149,7 +151,12 @@ class UnifiScanner(DeviceScanner): attributes = {} for variable in self._monitored_conditions: if variable in client: - attributes[variable] = client[variable] + if variable in TIMESTAMP_ATTRS: + attributes[variable] = dt_util.utc_from_timestamp( + float(client[variable]) + ) + else: + attributes[variable] = client[variable] _LOGGER.debug("Device mac %s attributes %s", device, attributes) return attributes diff --git a/tests/components/device_tracker/test_unifi.py b/tests/components/device_tracker/test_unifi.py index b3ce1e93e4c..7a791b334aa 100644 --- a/tests/components/device_tracker/test_unifi.py +++ b/tests/components/device_tracker/test_unifi.py @@ -1,13 +1,13 @@ """The tests for the Unifi WAP device tracker platform.""" from unittest import mock +from datetime import datetime, timedelta from pyunifi.controller import APIError -import homeassistant.util.dt as dt_util -from datetime import timedelta import pytest import voluptuous as vol +import homeassistant.util.dt as dt_util from homeassistant.components.device_tracker import DOMAIN, unifi as unifi from homeassistant.const import (CONF_HOST, CONF_USERNAME, CONF_PASSWORD, CONF_PLATFORM, CONF_VERIFY_SSL, @@ -241,7 +241,8 @@ def test_monitored_conditions(): 'hostname': 'foobar', 'essid': 'barnet', 'signal': -60, - 'last_seen': dt_util.as_timestamp(dt_util.utcnow())}, + 'last_seen': dt_util.as_timestamp(dt_util.utcnow()), + 'latest_assoc_time': 946684800.0}, {'mac': '234', 'name': 'Nice Name', 'essid': 'barnet', @@ -254,9 +255,14 @@ def test_monitored_conditions(): ] ctrl.get_clients.return_value = fake_clients scanner = unifi.UnifiScanner(ctrl, DEFAULT_DETECTION_TIME, None, - ['essid', 'signal']) - assert scanner.get_extra_attributes('123') == {'essid': 'barnet', - 'signal': -60} - assert scanner.get_extra_attributes('234') == {'essid': 'barnet', - 'signal': -42} + ['essid', 'signal', 'latest_assoc_time']) + assert scanner.get_extra_attributes('123') == { + 'essid': 'barnet', + 'signal': -60, + 'latest_assoc_time': datetime(2000, 1, 1, 0, 0, tzinfo=dt_util.UTC) + } + assert scanner.get_extra_attributes('234') == { + 'essid': 'barnet', + 'signal': -42 + } assert scanner.get_extra_attributes('456') == {'essid': 'barnet'} From 77dc7595ee371e713a1abf7481098dec02b3e5ef Mon Sep 17 00:00:00 2001 From: Thiago Oliveira Date: Sun, 10 Mar 2019 16:04:21 -0700 Subject: [PATCH 177/291] Allow emulated hue to set climate component temperature (#19034) --- homeassistant/components/climate/const.py | 2 +- .../components/emulated_hue/hue_api.py | 34 ++++-- tests/components/emulated_hue/test_hue_api.py | 108 +++++++++++++++++- 3 files changed, 135 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/climate/const.py b/homeassistant/components/climate/const.py index e213ae09de6..364c452bf4d 100644 --- a/homeassistant/components/climate/const.py +++ b/homeassistant/components/climate/const.py @@ -1,4 +1,4 @@ -"""Proides the constants needed for component.""" +"""Provides the constants needed for component.""" ATTR_AUX_HEAT = 'aux_heat' ATTR_AWAY_MODE = 'away_mode' diff --git a/homeassistant/components/emulated_hue/hue_api.py b/homeassistant/components/emulated_hue/hue_api.py index 95b3c470d9e..4c329cac28f 100644 --- a/homeassistant/components/emulated_hue/hue_api.py +++ b/homeassistant/components/emulated_hue/hue_api.py @@ -5,13 +5,16 @@ from aiohttp import web from homeassistant import core from homeassistant.const import ( - ATTR_ENTITY_ID, SERVICE_TURN_OFF, SERVICE_TURN_ON, SERVICE_VOLUME_SET, - SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, STATE_ON, STATE_OFF, - HTTP_BAD_REQUEST, HTTP_NOT_FOUND, ATTR_SUPPORTED_FEATURES, + ATTR_ENTITY_ID, ATTR_TEMPERATURE, SERVICE_TURN_OFF, SERVICE_TURN_ON, + SERVICE_VOLUME_SET, SERVICE_OPEN_COVER, SERVICE_CLOSE_COVER, STATE_ON, + STATE_OFF, HTTP_BAD_REQUEST, HTTP_NOT_FOUND, ATTR_SUPPORTED_FEATURES ) from homeassistant.components.light import ( ATTR_BRIGHTNESS, SUPPORT_BRIGHTNESS ) +from homeassistant.components.climate.const import ( + SERVICE_SET_TEMPERATURE, SUPPORT_TARGET_TEMPERATURE +) from homeassistant.components.media_player.const import ( ATTR_MEDIA_VOLUME_LEVEL, SUPPORT_VOLUME_SET, ) @@ -26,7 +29,7 @@ from homeassistant.components.cover import ( ) from homeassistant.components import ( - cover, fan, media_player, light, script, scene + climate, cover, fan, media_player, light, script, scene ) from homeassistant.components.http import HomeAssistantView @@ -262,6 +265,18 @@ class HueOneLightChangeView(HomeAssistantView): if brightness is not None: data['variables']['requested_level'] = brightness + # If the requested entity is a climate, set the temperature + elif entity.domain == climate.DOMAIN: + # We don't support turning climate devices on or off, + # only setting the temperature + service = None + + if entity_features & SUPPORT_TARGET_TEMPERATURE: + if brightness is not None: + domain = entity.domain + service = SERVICE_SET_TEMPERATURE + data[ATTR_TEMPERATURE] = brightness + # If the requested entity is a media player, convert to volume elif entity.domain == media_player.DOMAIN: if entity_features & SUPPORT_VOLUME_SET: @@ -318,8 +333,9 @@ class HueOneLightChangeView(HomeAssistantView): core.DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True)) - hass.async_create_task(hass.services.async_call( - domain, service, data, blocking=True)) + if service is not None: + hass.async_create_task(hass.services.async_call( + domain, service, data, blocking=True)) json_response = \ [create_hue_success_response(entity_id, HUE_API_STATE_ON, result)] @@ -371,7 +387,7 @@ def parse_hue_api_put_light_body(request_json, entity): elif entity.domain in [ script.DOMAIN, media_player.DOMAIN, - fan.DOMAIN, cover.DOMAIN]: + fan.DOMAIN, cover.DOMAIN, climate.DOMAIN]: # Convert 0-255 to 0-100 level = brightness / 255 * 100 brightness = round(level) @@ -397,6 +413,10 @@ def get_entity_state(config, entity): if entity_features & SUPPORT_BRIGHTNESS: pass + elif entity.domain == climate.DOMAIN: + temperature = entity.attributes.get(ATTR_TEMPERATURE, 0) + # Convert 0-100 to 0-255 + final_brightness = round(temperature * 255 / 100) elif entity.domain == media_player.DOMAIN: level = entity.attributes.get( ATTR_MEDIA_VOLUME_LEVEL, 1.0 if final_state else 0.0) diff --git a/tests/components/emulated_hue/test_hue_api.py b/tests/components/emulated_hue/test_hue_api.py index 5e3d6d1019c..8be99a02148 100644 --- a/tests/components/emulated_hue/test_hue_api.py +++ b/tests/components/emulated_hue/test_hue_api.py @@ -11,7 +11,7 @@ from tests.common import get_test_instance_port from homeassistant import core, const, setup import homeassistant.components as core_components from homeassistant.components import ( - fan, http, light, script, emulated_hue, media_player, cover) + fan, http, light, script, emulated_hue, media_player, cover, climate) from homeassistant.components.emulated_hue import Config from homeassistant.components.emulated_hue.hue_api import ( HUE_API_STATE_ON, HUE_API_STATE_BRI, HueUsernameView, HueOneLightStateView, @@ -77,6 +77,15 @@ def hass_hue(loop, hass): } })) + loop.run_until_complete( + setup.async_setup_component(hass, climate.DOMAIN, { + 'climate': [ + { + 'platform': 'demo', + } + ] + })) + loop.run_until_complete( setup.async_setup_component(hass, media_player.DOMAIN, { 'media_player': [ @@ -136,6 +145,22 @@ def hass_hue(loop, hass): cover_entity.entity_id, cover_entity.state, attributes=attrs ) + # Expose Hvac + hvac_entity = hass.states.get('climate.hvac') + attrs = dict(hvac_entity.attributes) + attrs[emulated_hue.ATTR_EMULATED_HUE_HIDDEN] = False + hass.states.async_set( + hvac_entity.entity_id, hvac_entity.state, attributes=attrs + ) + + # Expose HeatPump + hp_entity = hass.states.get('climate.heatpump') + attrs = dict(hp_entity.attributes) + attrs[emulated_hue.ATTR_EMULATED_HUE_HIDDEN] = False + hass.states.async_set( + hp_entity.entity_id, hp_entity.state, attributes=attrs + ) + return hass @@ -189,6 +214,9 @@ def test_discover_lights(hue_client): assert 'fan.living_room_fan' in devices assert 'fan.ceiling_fan' not in devices assert 'cover.living_room_window' in devices + assert 'climate.hvac' in devices + assert 'climate.heatpump' in devices + assert 'climate.ecobee' not in devices @asyncio.coroutine @@ -316,6 +344,84 @@ def test_put_light_state_script(hass_hue, hue_client): assert kitchen_light.attributes[light.ATTR_BRIGHTNESS] == level +@asyncio.coroutine +def test_put_light_state_climate_set_temperature(hass_hue, hue_client): + """Test setting climate temperature.""" + brightness = 19 + temperature = round(brightness / 255 * 100) + + hvac_result = yield from perform_put_light_state( + hass_hue, hue_client, + 'climate.hvac', True, brightness) + + hvac_result_json = yield from hvac_result.json() + + assert hvac_result.status == 200 + assert len(hvac_result_json) == 2 + + hvac = hass_hue.states.get('climate.hvac') + assert hvac.state == climate.const.STATE_COOL + assert hvac.attributes[climate.ATTR_TEMPERATURE] == temperature + assert hvac.attributes[climate.ATTR_OPERATION_MODE] == \ + climate.const.STATE_COOL + + # Make sure we can't change the ecobee temperature since it's not exposed + ecobee_result = yield from perform_put_light_state( + hass_hue, hue_client, + 'climate.ecobee', True) + assert ecobee_result.status == 404 + + +@asyncio.coroutine +def test_put_light_state_climate_turn_on(hass_hue, hue_client): + """Test inability to turn climate on.""" + yield from hass_hue.services.async_call( + climate.DOMAIN, const.SERVICE_TURN_OFF, + {const.ATTR_ENTITY_ID: 'climate.heatpump'}, + blocking=True) + + # Somehow after calling the above service the device gets unexposed, + # so we need to expose it again + hp_entity = hass_hue.states.get('climate.heatpump') + attrs = dict(hp_entity.attributes) + attrs[emulated_hue.ATTR_EMULATED_HUE_HIDDEN] = False + hass_hue.states.async_set( + hp_entity.entity_id, hp_entity.state, attributes=attrs + ) + + hp_result = yield from perform_put_light_state( + hass_hue, hue_client, + 'climate.heatpump', True) + + hp_result_json = yield from hp_result.json() + + assert hp_result.status == 200 + assert len(hp_result_json) == 1 + + hp = hass_hue.states.get('climate.heatpump') + assert hp.state == STATE_OFF + assert hp.attributes[climate.ATTR_OPERATION_MODE] == \ + climate.const.STATE_HEAT + + +@asyncio.coroutine +def test_put_light_state_climate_turn_off(hass_hue, hue_client): + """Test inability to turn climate off.""" + hp_result = yield from perform_put_light_state( + hass_hue, hue_client, + 'climate.heatpump', False) + + hp_result_json = yield from hp_result.json() + + assert hp_result.status == 200 + assert len(hp_result_json) == 1 + + hp = hass_hue.states.get('climate.heatpump') + assert hp.state == climate.const.STATE_HEAT + assert hp.attributes[climate.ATTR_OPERATION_MODE] == \ + climate.const.STATE_HEAT + + @asyncio.coroutine def test_put_light_state_media_player(hass_hue, hue_client): """Test turning on media player and setting volume.""" From 7ec7e51f703af44fb70d4ce7a3774fe865439585 Mon Sep 17 00:00:00 2001 From: Finbarr Brady Date: Mon, 11 Mar 2019 01:49:40 +0000 Subject: [PATCH 178/291] bump netdisco to 2.4.0 (#21914) --- homeassistant/components/discovery/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 16d49d3d5bb..ccc0b8387c2 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -21,7 +21,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.discovery import async_load_platform, async_discover import homeassistant.util.dt as dt_util -REQUIREMENTS = ['netdisco==2.3.0'] +REQUIREMENTS = ['netdisco==2.4.0'] DOMAIN = 'discovery' diff --git a/requirements_all.txt b/requirements_all.txt index 1888bd72ae5..fc43608cda8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -738,7 +738,7 @@ nessclient==0.9.14 netdata==0.1.2 # homeassistant.components.discovery -netdisco==2.3.0 +netdisco==2.4.0 # homeassistant.components.sensor.neurio_energy neurio==0.3.1 From fe1840f9016c87288840f307620484e8683885cc Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Sun, 10 Mar 2019 19:55:36 -0700 Subject: [PATCH 179/291] Deprecate http.api_password (#21884) * Deprecated http.api_password * Deprecated ApiConfig.api_password GitHub Drafted PR would trigger CI after changed it to normal PR. I have to commit a comment change to trigger it * Trigger CI * Adjust if- elif chain in auth middleware --- homeassistant/auth/__init__.py | 19 +- .../auth/providers/legacy_api_password.py | 61 ++-- homeassistant/bootstrap.py | 4 +- homeassistant/components/__init__.py | 1 + homeassistant/components/api/__init__.py | 4 +- homeassistant/components/camera/proxy.py | 9 +- homeassistant/components/frontend/__init__.py | 2 +- homeassistant/components/hassio/auth.py | 6 +- homeassistant/components/hassio/handler.py | 7 +- homeassistant/components/http/__init__.py | 58 ++-- homeassistant/components/http/auth.py | 261 +++++++++--------- homeassistant/components/http/const.py | 2 + homeassistant/components/http/view.py | 4 +- homeassistant/components/mqtt/__init__.py | 8 - .../components/websocket_api/auth.py | 21 +- homeassistant/components/zeroconf/__init__.py | 4 +- homeassistant/config.py | 9 +- homeassistant/core.py | 2 +- .../providers/test_legacy_api_password.py | 24 +- tests/components/api/test_init.py | 8 +- tests/components/hassio/test_init.py | 6 - tests/components/http/test_auth.py | 72 ++--- tests/components/http/test_init.py | 6 +- tests/components/mqtt/test_server.py | 18 -- tests/components/websocket_api/__init__.py | 2 +- tests/conftest.py | 5 +- tests/test_config.py | 5 +- 27 files changed, 304 insertions(+), 324 deletions(-) diff --git a/homeassistant/auth/__init__.py b/homeassistant/auth/__init__.py index bb90f296468..9e4b9d09d78 100644 --- a/homeassistant/auth/__init__.py +++ b/homeassistant/auth/__init__.py @@ -100,9 +100,21 @@ class AuthManager: """Return a list of available auth modules.""" return list(self._mfa_modules.values()) + def get_auth_provider(self, provider_type: str, provider_id: str) \ + -> Optional[AuthProvider]: + """Return an auth provider, None if not found.""" + return self._providers.get((provider_type, provider_id)) + + def get_auth_providers(self, provider_type: str) \ + -> List[AuthProvider]: + """Return a List of auth provider of one type, Empty if not found.""" + return [provider + for (p_type, _), provider in self._providers.items() + if p_type == provider_type] + def get_auth_mfa_module(self, module_id: str) \ -> Optional[MultiFactorAuthModule]: - """Return an multi-factor auth module, None if not found.""" + """Return a multi-factor auth module, None if not found.""" return self._mfa_modules.get(module_id) async def async_get_users(self) -> List[models.User]: @@ -113,6 +125,11 @@ class AuthManager: """Retrieve a user.""" return await self._store.async_get_user(user_id) + async def async_get_owner(self) -> Optional[models.User]: + """Retrieve the owner.""" + users = await self.async_get_users() + return next((user for user in users if user.is_owner), None) + async def async_get_group(self, group_id: str) -> Optional[models.Group]: """Retrieve all groups.""" return await self._store.async_get_group(group_id) diff --git a/homeassistant/auth/providers/legacy_api_password.py b/homeassistant/auth/providers/legacy_api_password.py index 6cdb12b7157..e85d831a325 100644 --- a/homeassistant/auth/providers/legacy_api_password.py +++ b/homeassistant/auth/providers/legacy_api_password.py @@ -4,27 +4,23 @@ Support Legacy API password auth provider. It will be removed when auth system production ready """ import hmac -from typing import Any, Dict, Optional, cast, TYPE_CHECKING +from typing import Any, Dict, Optional, cast import voluptuous as vol from homeassistant.core import HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError +import homeassistant.helpers.config_validation as cv from . import AuthProvider, AUTH_PROVIDER_SCHEMA, AUTH_PROVIDERS, LoginFlow from .. import AuthManager from ..models import Credentials, UserMeta, User -if TYPE_CHECKING: - from homeassistant.components.http import HomeAssistantHTTP # noqa: F401 - - -USER_SCHEMA = vol.Schema({ - vol.Required('username'): str, -}) - +AUTH_PROVIDER_TYPE = 'legacy_api_password' +CONF_API_PASSWORD = 'api_password' CONFIG_SCHEMA = AUTH_PROVIDER_SCHEMA.extend({ + vol.Required(CONF_API_PASSWORD): cv.string, }, extra=vol.PREVENT_EXTRA) LEGACY_USER_NAME = 'Legacy API password user' @@ -34,40 +30,45 @@ class InvalidAuthError(HomeAssistantError): """Raised when submitting invalid authentication.""" -async def async_get_user(hass: HomeAssistant) -> User: - """Return the legacy API password user.""" +async def async_validate_password(hass: HomeAssistant, password: str)\ + -> Optional[User]: + """Return a user if password is valid. None if not.""" auth = cast(AuthManager, hass.auth) # type: ignore - found = None - - for prv in auth.auth_providers: - if prv.type == 'legacy_api_password': - found = prv - break - - if found is None: + providers = auth.get_auth_providers(AUTH_PROVIDER_TYPE) + if not providers: raise ValueError('Legacy API password provider not found') - return await auth.async_get_or_create_user( - await found.async_get_or_create_credentials({}) - ) + try: + provider = cast(LegacyApiPasswordAuthProvider, providers[0]) + provider.async_validate_login(password) + return await auth.async_get_or_create_user( + await provider.async_get_or_create_credentials({}) + ) + except InvalidAuthError: + return None -@AUTH_PROVIDERS.register('legacy_api_password') +@AUTH_PROVIDERS.register(AUTH_PROVIDER_TYPE) class LegacyApiPasswordAuthProvider(AuthProvider): - """Example auth provider based on hardcoded usernames and passwords.""" + """An auth provider support legacy api_password.""" DEFAULT_TITLE = 'Legacy API Password' + @property + def api_password(self) -> str: + """Return api_password.""" + return str(self.config[CONF_API_PASSWORD]) + async def async_login_flow(self, context: Optional[Dict]) -> LoginFlow: """Return a flow to login.""" return LegacyLoginFlow(self) @callback def async_validate_login(self, password: str) -> None: - """Validate a username and password.""" - hass_http = getattr(self.hass, 'http', None) # type: HomeAssistantHTTP + """Validate password.""" + api_password = str(self.config[CONF_API_PASSWORD]) - if not hmac.compare_digest(hass_http.api_password.encode('utf-8'), + if not hmac.compare_digest(api_password.encode('utf-8'), password.encode('utf-8')): raise InvalidAuthError @@ -99,12 +100,6 @@ class LegacyLoginFlow(LoginFlow): """Handle the step of the form.""" errors = {} - hass_http = getattr(self.hass, 'http', None) - if hass_http is None or not hass_http.api_password: - return self.async_abort( - reason='no_api_password_set' - ) - if user_input is not None: try: cast(LegacyApiPasswordAuthProvider, self._auth_provider)\ diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 06f4fdd8788..444b4a9f855 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -99,12 +99,12 @@ async def async_from_config_dict(config: Dict[str, Any], "This may cause issues") core_config = config.get(core.DOMAIN, {}) - has_api_password = bool(config.get('http', {}).get('api_password')) + api_password = config.get('http', {}).get('api_password') trusted_networks = config.get('http', {}).get('trusted_networks') try: await conf_util.async_process_ha_core_config( - hass, core_config, has_api_password, trusted_networks) + hass, core_config, api_password, trusted_networks) except vol.Invalid as config_err: conf_util.async_log_exception( config_err, 'homeassistant', core_config, hass) diff --git a/homeassistant/components/__init__.py b/homeassistant/components/__init__.py index f3045df6a12..533811e275d 100644 --- a/homeassistant/components/__init__.py +++ b/homeassistant/components/__init__.py @@ -166,6 +166,7 @@ async def async_setup(hass: ha.HomeAssistant, config: dict) -> Awaitable[bool]: _LOGGER.error(err) return + # auth only processed during startup await conf_util.async_process_ha_core_config( hass, conf.get(ha.DOMAIN) or {}) diff --git a/homeassistant/components/api/__init__.py b/homeassistant/components/api/__init__.py index 7639ac621fe..beba17ee2ea 100644 --- a/homeassistant/components/api/__init__.py +++ b/homeassistant/components/api/__init__.py @@ -168,11 +168,11 @@ class APIDiscoveryView(HomeAssistantView): def get(self, request): """Get discovery information.""" hass = request.app['hass'] - needs_auth = hass.config.api.api_password is not None return self.json({ ATTR_BASE_URL: hass.config.api.base_url, ATTR_LOCATION_NAME: hass.config.location_name, - ATTR_REQUIRES_API_PASSWORD: needs_auth, + # always needs authentication + ATTR_REQUIRES_API_PASSWORD: True, ATTR_VERSION: __version__, }) diff --git a/homeassistant/components/camera/proxy.py b/homeassistant/components/camera/proxy.py index 8afd71abc26..3e6e4911d27 100644 --- a/homeassistant/components/camera/proxy.py +++ b/homeassistant/components/camera/proxy.py @@ -11,13 +11,11 @@ from datetime import timedelta import voluptuous as vol from homeassistant.components.camera import PLATFORM_SCHEMA, Camera -from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_MODE, \ - HTTP_HEADER_HA_AUTH +from homeassistant.const import CONF_ENTITY_ID, CONF_NAME, CONF_MODE from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.util.async_ import run_coroutine_threadsafe import homeassistant.util.dt as dt_util -from homeassistant.components.camera import async_get_still_stream REQUIREMENTS = ['pillow==5.4.1'] @@ -209,9 +207,6 @@ class ProxyCamera(Camera): or config.get(CONF_CACHE_IMAGES)) self._last_image_time = dt_util.utc_from_timestamp(0) 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) self._mode = config.get(CONF_MODE) def camera_image(self): @@ -252,7 +247,7 @@ class ProxyCamera(Camera): return await self.hass.components.camera.async_get_mjpeg_stream( request, self._proxied_camera) - return await async_get_still_stream( + return await self.hass.components.camera.async_get_still_stream( request, self._async_stream_image, self.content_type, self.frame_interval) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 8f1e563b782..3fa5791b7bc 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -407,7 +407,7 @@ class IndexView(HomeAssistantView): }) no_auth = '1' - if hass.config.api.api_password and not request[KEY_AUTHENTICATED]: + if not request[KEY_AUTHENTICATED]: # do not try to auto connect on load no_auth = '0' diff --git a/homeassistant/components/hassio/auth.py b/homeassistant/components/hassio/auth.py index b104d53aff9..05c183ccd60 100644 --- a/homeassistant/components/hassio/auth.py +++ b/homeassistant/components/hassio/auth.py @@ -57,9 +57,9 @@ class HassIOAuth(HomeAssistantView): def _get_provider(self): """Return Homeassistant auth provider.""" - for prv in self.hass.auth.auth_providers: - if prv.type == 'homeassistant': - return prv + prv = self.hass.auth.get_auth_provider('homeassistant', None) + if prv is not None: + return prv _LOGGER.error("Can't find Home Assistant auth.") raise HTTPNotFound() diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 640ed29e578..46e32c9f7c3 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -7,8 +7,10 @@ import aiohttp import async_timeout from homeassistant.components.http import ( - CONF_API_PASSWORD, CONF_SERVER_HOST, CONF_SERVER_PORT, - CONF_SSL_CERTIFICATE) + CONF_SERVER_HOST, + CONF_SERVER_PORT, + CONF_SSL_CERTIFICATE, +) from homeassistant.const import CONF_TIME_ZONE, SERVER_PORT from .const import X_HASSIO @@ -125,7 +127,6 @@ class HassIO: options = { 'ssl': CONF_SSL_CERTIFICATE in http_config, 'port': port, - 'password': http_config.get(CONF_API_PASSWORD), 'watchdog': True, 'refresh_token': refresh_token, } diff --git a/homeassistant/components/http/__init__.py b/homeassistant/components/http/__init__.py index 93afbc04396..0bcf3f85ff7 100644 --- a/homeassistant/components/http/__init__.py +++ b/homeassistant/components/http/__init__.py @@ -18,7 +18,12 @@ from homeassistant.util.logging import HideSensitiveDataFilter from .auth import setup_auth from .ban import setup_bans -from .const import KEY_AUTHENTICATED, KEY_REAL_IP # noqa +from .const import ( # noqa + KEY_AUTHENTICATED, + KEY_HASS, + KEY_HASS_USER, + KEY_REAL_IP, +) from .cors import setup_cors from .real_ip import setup_real_ip from .static import CACHE_HEADERS, CachingStaticResource @@ -66,8 +71,22 @@ def trusted_networks_deprecated(value): return value +def api_password_deprecated(value): + """Warn user api_password config is deprecated.""" + if not value: + return value + + _LOGGER.warning( + "Configuring api_password via the http component has been" + " deprecated. Use the legacy api password auth provider instead." + " For instructions, see https://www.home-assistant.io/docs/" + "authentication/providers/#legacy-api-password") + return value + + HTTP_SCHEMA = vol.Schema({ - vol.Optional(CONF_API_PASSWORD): cv.string, + vol.Optional(CONF_API_PASSWORD): + vol.All(cv.string, api_password_deprecated), vol.Optional(CONF_SERVER_HOST, default=DEFAULT_SERVER_HOST): cv.string, vol.Optional(CONF_SERVER_PORT, default=SERVER_PORT): cv.port, vol.Optional(CONF_BASE_URL): cv.string, @@ -98,12 +117,10 @@ 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: + use_ssl: bool = False) -> None: """Initialize a new API config object.""" self.host = host self.port = port - self.api_password = api_password host = host.rstrip('/') if host.startswith(("http://", "https://")): @@ -133,7 +150,6 @@ async def async_setup(hass, config): cors_origins = conf[CONF_CORS_ORIGINS] 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] @@ -146,14 +162,12 @@ async def async_setup(hass, config): hass, server_host=server_host, 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, ssl_profile=ssl_profile, @@ -183,8 +197,7 @@ async def async_setup(hass, config): host = hass_util.get_local_ip() port = server_port - hass.config.api = ApiConfig(host, port, ssl_certificate is not None, - api_password) + hass.config.api = ApiConfig(host, port, ssl_certificate is not None) return True @@ -192,13 +205,14 @@ async def async_setup(hass, config): class HomeAssistantHTTP: """HTTP server for Home Assistant.""" - def __init__(self, hass, api_password, + def __init__(self, hass, ssl_certificate, ssl_peer_certificate, ssl_key, server_host, server_port, cors_origins, - use_x_forwarded_for, trusted_proxies, trusted_networks, + use_x_forwarded_for, trusted_proxies, login_threshold, is_ban_enabled, ssl_profile): """Initialize the HTTP Home Assistant server.""" app = self.app = web.Application(middlewares=[]) + app[KEY_HASS] = hass # This order matters setup_real_ip(app, use_x_forwarded_for, trusted_proxies) @@ -206,34 +220,16 @@ class HomeAssistantHTTP: if is_ban_enabled: setup_bans(hass, app, login_threshold) - if hass.auth.support_legacy: - _LOGGER.warning( - "legacy_api_password support has been enabled. If you don't " - "require it, remove the 'api_password' from your http config.") - - for prv in hass.auth.auth_providers: - if prv.type == 'trusted_networks': - # auth_provider.trusted_networks will override - # http.trusted_networks, http.trusted_networks will be - # removed from future release - trusted_networks = prv.trusted_networks - break - - setup_auth(app, trusted_networks, - api_password if hass.auth.support_legacy else None) + setup_auth(hass, app) 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.trusted_networks = trusted_networks self.is_ban_enabled = is_ban_enabled self.ssl_profile = ssl_profile self._handler = None diff --git a/homeassistant/components/http/auth.py b/homeassistant/components/http/auth.py index 312fc2164c3..4736ef12391 100644 --- a/homeassistant/components/http/auth.py +++ b/homeassistant/components/http/auth.py @@ -1,6 +1,5 @@ """Authentication for HTTP component.""" import base64 -import hmac import logging from aiohttp import hdrs @@ -13,7 +12,11 @@ from homeassistant.const import HTTP_HEADER_HA_AUTH from homeassistant.core import callback from homeassistant.util import dt as dt_util -from .const import KEY_AUTHENTICATED, KEY_REAL_IP +from .const import ( + KEY_AUTHENTICATED, + KEY_HASS_USER, + KEY_REAL_IP, +) _LOGGER = logging.getLogger(__name__) @@ -40,10 +43,125 @@ def async_sign_path(hass, refresh_token_id, path, expiration): @callback -def setup_auth(app, trusted_networks, api_password): +def setup_auth(hass, app): """Create auth middleware for the app.""" old_auth_warning = set() + support_legacy = hass.auth.support_legacy + if support_legacy: + _LOGGER.warning("legacy_api_password support has been enabled.") + + trusted_networks = [] + for prv in hass.auth.auth_providers: + if prv.type == 'trusted_networks': + trusted_networks += prv.trusted_networks + + async def async_validate_auth_header(request): + """ + Test authorization header against access token. + + Basic auth_type is legacy code, should be removed with api_password. + """ + 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': + refresh_token = await hass.auth.async_validate_access_token( + auth_val) + if refresh_token is None: + return False + + request[KEY_HASS_USER] = refresh_token.user + return True + + if auth_type == 'Basic' and support_legacy: + decoded = base64.b64decode(auth_val).decode('utf-8') + try: + username, password = decoded.split(':', 1) + except ValueError: + # If no ':' in decoded + return False + + if username != 'homeassistant': + return False + + user = await legacy_api_password.async_validate_password( + hass, password) + if user is None: + return False + + request[KEY_HASS_USER] = user + _LOGGER.info( + 'Basic auth with api_password is going to deprecate,' + ' please use a bearer token to access %s from %s', + request.path, request[KEY_REAL_IP]) + old_auth_warning.add(request.path) + return True + + return False + + async def async_validate_signed_request(request): + """Validate a signed request.""" + secret = hass.data.get(DATA_SIGN_SECRET) + + if secret is None: + return False + + signature = request.query.get(SIGN_QUERY_PARAM) + + if signature is None: + return False + + try: + claims = jwt.decode( + signature, + secret, + algorithms=['HS256'], + options={'verify_iss': False} + ) + except jwt.InvalidTokenError: + return False + + if claims['path'] != request.path: + return False + + refresh_token = await hass.auth.async_get_refresh_token(claims['iss']) + + if refresh_token is None: + return False + + request[KEY_HASS_USER] = refresh_token.user + return True + + async def async_validate_trusted_networks(request): + """Test if request is from a trusted ip.""" + ip_addr = request[KEY_REAL_IP] + + if not any(ip_addr in trusted_network + for trusted_network in trusted_networks): + return False + + user = await hass.auth.async_get_owner() + if user is None: + return False + + request[KEY_HASS_USER] = user + return True + + async def async_validate_legacy_api_password(request, password): + """Validate api_password.""" + user = await legacy_api_password.async_validate_password( + hass, password) + if user is None: + return False + + request[KEY_HASS_USER] = user + return True + @middleware async def auth_middleware(request, handler): """Authenticate as middleware.""" @@ -53,13 +171,14 @@ def setup_auth(app, trusted_networks, api_password): DATA_API_PASSWORD in request.query): if request.path not in old_auth_warning: _LOGGER.log( - logging.INFO if api_password else logging.WARNING, - 'You need to use a bearer token to access %s from %s', + logging.INFO if support_legacy else logging.WARNING, + 'api_password is going to deprecate. You need to use a' + ' bearer token to access %s from %s', request.path, request[KEY_REAL_IP]) old_auth_warning.add(request.path) if (hdrs.AUTHORIZATION in request.headers and - await async_validate_auth_header(request, api_password)): + await async_validate_auth_header(request)): # it included both use_auth and api_password Basic auth authenticated = True @@ -69,133 +188,21 @@ def setup_auth(app, trusted_networks, api_password): await async_validate_signed_request(request)): authenticated = True - elif (api_password 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 + elif (trusted_networks and + await async_validate_trusted_networks(request)): authenticated = True - request['hass_user'] = await legacy_api_password.async_get_user( - app['hass']) - elif (api_password and DATA_API_PASSWORD in request.query and - hmac.compare_digest( - api_password.encode('utf-8'), - request.query[DATA_API_PASSWORD].encode('utf-8'))): + elif (support_legacy and HTTP_HEADER_HA_AUTH in request.headers and + await async_validate_legacy_api_password( + request, request.headers[HTTP_HEADER_HA_AUTH])): authenticated = True - request['hass_user'] = await legacy_api_password.async_get_user( - app['hass']) - elif _is_trusted_ip(request, trusted_networks): - users = await app['hass'].auth.async_get_users() - for user in users: - if user.is_owner: - request['hass_user'] = user - authenticated = True - break + elif (support_legacy and DATA_API_PASSWORD in request.query and + await async_validate_legacy_api_password( + request, request.query[DATA_API_PASSWORD])): + authenticated = True request[KEY_AUTHENTICATED] = authenticated return await handler(request) app.middlewares.append(auth_middleware) - - -def _is_trusted_ip(request, trusted_networks): - """Test if request is from a trusted ip.""" - ip_addr = request[KEY_REAL_IP] - - return any( - ip_addr in trusted_network for trusted_network - in trusted_networks) - - -def validate_password(request, api_password): - """Test if password is valid.""" - return hmac.compare_digest( - api_password.encode('utf-8'), - request.app['hass'].http.api_password.encode('utf-8')) - - -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 - - try: - auth_type, auth_val = \ - request.headers.get(hdrs.AUTHORIZATION).split(' ', 1) - except ValueError: - # If no space in authorization header - return False - - hass = request.app['hass'] - - if auth_type == 'Bearer': - refresh_token = await hass.auth.async_validate_access_token(auth_val) - if refresh_token is None: - return False - - request['hass_refresh_token'] = refresh_token - request['hass_user'] = refresh_token.user - return True - - if auth_type == 'Basic' and api_password is not None: - decoded = base64.b64decode(auth_val).decode('utf-8') - try: - username, password = decoded.split(':', 1) - except ValueError: - # If no ':' in decoded - return False - - if username != 'homeassistant': - return False - - if not hmac.compare_digest(api_password.encode('utf-8'), - password.encode('utf-8')): - return False - - request['hass_user'] = await legacy_api_password.async_get_user(hass) - return True - - return False - - -async def async_validate_signed_request(request): - """Validate a signed request.""" - hass = request.app['hass'] - secret = hass.data.get(DATA_SIGN_SECRET) - - if secret is None: - return False - - signature = request.query.get(SIGN_QUERY_PARAM) - - if signature is None: - return False - - try: - claims = jwt.decode( - signature, - secret, - algorithms=['HS256'], - options={'verify_iss': False} - ) - except jwt.InvalidTokenError: - return False - - if claims['path'] != request.path: - return False - - refresh_token = await hass.auth.async_get_refresh_token(claims['iss']) - - if refresh_token is None: - return False - - request['hass_refresh_token'] = refresh_token - request['hass_user'] = refresh_token.user - - return True diff --git a/homeassistant/components/http/const.py b/homeassistant/components/http/const.py index e5494e945c4..f26220e63d1 100644 --- a/homeassistant/components/http/const.py +++ b/homeassistant/components/http/const.py @@ -1,3 +1,5 @@ """HTTP specific constants.""" KEY_AUTHENTICATED = 'ha_authenticated' +KEY_HASS = 'hass' +KEY_HASS_USER = 'hass_user' KEY_REAL_IP = 'ha_real_ip' diff --git a/homeassistant/components/http/view.py b/homeassistant/components/http/view.py index 9662f3e6c23..bb7f2c2fee2 100644 --- a/homeassistant/components/http/view.py +++ b/homeassistant/components/http/view.py @@ -14,7 +14,7 @@ from homeassistant.const import CONTENT_TYPE_JSON from homeassistant.core import Context, is_callback from homeassistant.helpers.json import JSONEncoder -from .const import KEY_AUTHENTICATED, KEY_REAL_IP +from .const import KEY_AUTHENTICATED, KEY_REAL_IP, KEY_HASS _LOGGER = logging.getLogger(__name__) @@ -91,7 +91,7 @@ def request_handler_factory(view, handler): async def handle(request): """Handle incoming request.""" - if not request.app['hass'].is_running: + if not request.app[KEY_HASS].is_running: return web.Response(status=503) authenticated = request.get(KEY_AUTHENTICATED, False) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index e430b1fbc9f..18ebb004209 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -399,14 +399,6 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: conf = dict(conf) if CONF_EMBEDDED in conf or CONF_BROKER not in conf: - if (conf.get(CONF_PASSWORD) is None and - config.get('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) diff --git a/homeassistant/components/websocket_api/auth.py b/homeassistant/components/websocket_api/auth.py index f175327bf28..dbb43e08780 100644 --- a/homeassistant/components/websocket_api/auth.py +++ b/homeassistant/components/websocket_api/auth.py @@ -2,11 +2,12 @@ import voluptuous as vol from voluptuous.humanize import humanize_error -from homeassistant.const import __version__ -from homeassistant.components.http.auth import validate_password -from homeassistant.components.http.ban import process_wrong_login, \ - process_success_login from homeassistant.auth.providers import legacy_api_password +from homeassistant.components.http.ban import ( + process_wrong_login, + process_success_login, +) +from homeassistant.const import __version__ from .connection import ActiveConnection from .error import Disconnect @@ -80,9 +81,15 @@ class AuthPhase: refresh_token.user, refresh_token) elif self._hass.auth.support_legacy and 'api_password' in msg: - self._logger.debug("Received api_password") - if validate_password(self._request, msg['api_password']): - user = await legacy_api_password.async_get_user(self._hass) + self._logger.info( + "Received api_password, it is going to deprecate, please use" + " access_token instead. For instructions, see https://" + "developers.home-assistant.io/docs/en/external_api_websocket" + ".html#authentication-phase" + ) + user = await legacy_api_password.async_validate_password( + self._hass, msg['api_password']) + if user is not None: return await self._async_finish_auth(user, None) self._send_message(auth_invalid_message( diff --git a/homeassistant/components/zeroconf/__init__.py b/homeassistant/components/zeroconf/__init__.py index bf743eaf370..844246528a6 100644 --- a/homeassistant/components/zeroconf/__init__.py +++ b/homeassistant/components/zeroconf/__init__.py @@ -30,11 +30,11 @@ def setup(hass, config): zeroconf_name = '{}.{}'.format(hass.config.location_name, ZEROCONF_TYPE) - requires_api_password = hass.config.api.api_password is not None params = { 'version': __version__, 'base_url': hass.config.api.base_url, - 'requires_api_password': requires_api_password, + # always needs authentication + 'requires_api_password': True, } host_ip = util.get_local_ip() diff --git a/homeassistant/config.py b/homeassistant/config.py index 492db240eee..db59e2c2744 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -428,7 +428,7 @@ def _format_config_error(ex: vol.Invalid, domain: str, config: Dict) -> str: async def async_process_ha_core_config( hass: HomeAssistant, config: Dict, - has_api_password: bool = False, + api_password: Optional[str] = None, trusted_networks: Optional[Any] = None) -> None: """Process the [homeassistant] section from the configuration. @@ -444,8 +444,11 @@ async def async_process_ha_core_config( auth_conf = [ {'type': 'homeassistant'} ] - if has_api_password: - auth_conf.append({'type': 'legacy_api_password'}) + if api_password: + auth_conf.append({ + 'type': 'legacy_api_password', + 'api_password': api_password, + }) if trusted_networks: auth_conf.append({ 'type': 'trusted_networks', diff --git a/homeassistant/core.py b/homeassistant/core.py index 253900a39ef..df315ad63c0 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -1180,7 +1180,7 @@ class Config: # List of loaded components self.components = set() # type: set - # API (HTTP) server configuration + # API (HTTP) server configuration, see components.http.ApiConfig self.api = None # type: Optional[Any] # Directory that holds the configuration diff --git a/tests/auth/providers/test_legacy_api_password.py b/tests/auth/providers/test_legacy_api_password.py index 96da624161a..3f4c257f000 100644 --- a/tests/auth/providers/test_legacy_api_password.py +++ b/tests/auth/providers/test_legacy_api_password.py @@ -1,6 +1,4 @@ """Tests for the legacy_api_password auth provider.""" -from unittest.mock import Mock - import pytest from homeassistant import auth, data_entry_flow @@ -19,6 +17,7 @@ def provider(hass, store): """Mock provider.""" return legacy_api_password.LegacyApiPasswordAuthProvider(hass, store, { 'type': 'legacy_api_password', + 'api_password': 'test-password', }) @@ -51,32 +50,13 @@ async def test_only_one_credentials(manager, provider): async def test_verify_login(hass, provider): """Test login using legacy api password auth provider.""" - 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_login_flow_abort(hass, manager): - """Test wrong config.""" - for http in ( - None, - Mock(api_password=None), - Mock(api_password=''), - ): - hass.http = http - - result = await manager.login_flow.async_init( - handler=('legacy_api_password', None) - ) - assert result['type'] == data_entry_flow.RESULT_TYPE_ABORT - assert result['reason'] == 'no_api_password_set' - - async def test_login_flow_works(hass, manager): """Test wrong config.""" - hass.http = Mock(api_password='hello') result = await manager.login_flow.async_init( handler=('legacy_api_password', None) ) @@ -94,7 +74,7 @@ async def test_login_flow_works(hass, manager): result = await manager.login_flow.async_configure( flow_id=result['flow_id'], user_input={ - 'password': 'hello' + 'password': 'test-password' } ) assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY diff --git a/tests/components/api/test_init.py b/tests/components/api/test_init.py index a88c828efe8..c4f227e488b 100644 --- a/tests/components/api/test_init.py +++ b/tests/components/api/test_init.py @@ -407,14 +407,10 @@ def _listen_count(hass): async def test_api_error_log(hass, aiohttp_client, hass_access_token, - hass_admin_user, legacy_auth): + hass_admin_user): """Test if we can fetch the error log.""" hass.data[DATA_LOGGING] = '/some/path' - await async_setup_component(hass, 'api', { - 'http': { - 'api_password': 'yolo' - } - }) + await async_setup_component(hass, 'api', {}) client = await aiohttp_client(hass.http.app) resp = await client.get(const.URL_API_ERROR_LOG) diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 62e7278ba1f..435e03a1755 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -51,7 +51,6 @@ def test_setup_api_push_api_data(hass, aioclient_mock): with patch.dict(os.environ, MOCK_ENVIRON): result = yield from async_setup_component(hass, 'hassio', { 'http': { - 'api_password': "123456", 'server_port': 9999 }, 'hassio': {} @@ -60,7 +59,6 @@ def test_setup_api_push_api_data(hass, aioclient_mock): assert aioclient_mock.call_count == 3 assert not aioclient_mock.mock_calls[1][2]['ssl'] - assert aioclient_mock.mock_calls[1][2]['password'] == "123456" assert aioclient_mock.mock_calls[1][2]['port'] == 9999 assert aioclient_mock.mock_calls[1][2]['watchdog'] @@ -71,7 +69,6 @@ def test_setup_api_push_api_data_server_host(hass, aioclient_mock): with patch.dict(os.environ, MOCK_ENVIRON): result = yield from async_setup_component(hass, 'hassio', { 'http': { - 'api_password': "123456", 'server_port': 9999, 'server_host': "127.0.0.1" }, @@ -81,7 +78,6 @@ def test_setup_api_push_api_data_server_host(hass, aioclient_mock): assert aioclient_mock.call_count == 3 assert not aioclient_mock.mock_calls[1][2]['ssl'] - assert aioclient_mock.mock_calls[1][2]['password'] == "123456" assert aioclient_mock.mock_calls[1][2]['port'] == 9999 assert not aioclient_mock.mock_calls[1][2]['watchdog'] @@ -98,7 +94,6 @@ async def test_setup_api_push_api_data_default(hass, aioclient_mock, 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( @@ -159,7 +154,6 @@ async def test_setup_api_existing_hassio_user(hass, aioclient_mock, 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'] == token.token diff --git a/tests/components/http/test_auth.py b/tests/components/http/test_auth.py index 304bb4de997..a16b40213b8 100644 --- a/tests/components/http/test_auth.py +++ b/tests/components/http/test_auth.py @@ -7,7 +7,7 @@ import pytest from aiohttp import BasicAuth, web from aiohttp.web_exceptions import HTTPUnauthorized -from homeassistant.auth.providers import legacy_api_password +from homeassistant.auth.providers import trusted_networks from homeassistant.components.http.auth import setup_auth, async_sign_path from homeassistant.components.http.const import KEY_AUTHENTICATED from homeassistant.components.http.real_ip import setup_real_ip @@ -16,7 +16,7 @@ from homeassistant.setup import async_setup_component from . import mock_real_ip -API_PASSWORD = 'test1234' +API_PASSWORD = 'test-password' # Don't add 127.0.0.1/::1 as trusted, as it may interfere with other test cases TRUSTED_NETWORKS = [ @@ -35,17 +35,22 @@ async def mock_handler(request): if not request[KEY_AUTHENTICATED]: raise HTTPUnauthorized - token = request.get('hass_refresh_token') - token_id = token.id if token else None user = request.get('hass_user') user_id = user.id if user else None return web.json_response(status=200, data={ - 'refresh_token_id': token_id, 'user_id': user_id, }) +async def get_legacy_user(auth): + """Get the user in legacy_api_password auth provider.""" + provider = auth.get_auth_provider('legacy_api_password', None) + return await auth.async_get_or_create_user( + await provider.async_get_or_create_credentials({}) + ) + + @pytest.fixture def app(hass): """Fixture to set up a web.Application.""" @@ -65,6 +70,19 @@ def app2(hass): return app +@pytest.fixture +def trusted_networks_auth(hass): + """Load trusted networks auth provider.""" + prv = trusted_networks.TrustedNetworksAuthProvider( + hass, hass.auth._store, { + 'type': 'trusted_networks', + 'trusted_networks': TRUSTED_NETWORKS, + } + ) + hass.auth._providers[(prv.type, prv.id)] = prv + return prv + + async def test_auth_middleware_loaded_by_default(hass): """Test accessing to server from banned IP when feature is off.""" with patch('homeassistant.components.http.setup_auth') as mock_setup: @@ -78,15 +96,14 @@ async def test_auth_middleware_loaded_by_default(hass): async def test_access_with_password_in_header(app, aiohttp_client, legacy_auth, hass): """Test access with password in header.""" - setup_auth(app, [], api_password=API_PASSWORD) + setup_auth(hass, app) client = await aiohttp_client(app) - user = await legacy_api_password.async_get_user(hass) + user = await get_legacy_user(hass.auth) req = await client.get( '/', headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) assert req.status == 200 assert await req.json() == { - 'refresh_token_id': None, 'user_id': user.id, } @@ -98,16 +115,15 @@ async def test_access_with_password_in_header(app, aiohttp_client, async def test_access_with_password_in_query(app, aiohttp_client, legacy_auth, hass): """Test access with password in URL.""" - setup_auth(app, [], api_password=API_PASSWORD) + setup_auth(hass, app) client = await aiohttp_client(app) - user = await legacy_api_password.async_get_user(hass) + user = await get_legacy_user(hass.auth) resp = await client.get('/', params={ 'api_password': API_PASSWORD }) assert resp.status == 200 assert await resp.json() == { - 'refresh_token_id': None, 'user_id': user.id, } @@ -122,16 +138,15 @@ async def test_access_with_password_in_query(app, aiohttp_client, legacy_auth, async def test_basic_auth_works(app, aiohttp_client, hass, legacy_auth): """Test access with basic authentication.""" - setup_auth(app, [], api_password=API_PASSWORD) + setup_auth(hass, app) client = await aiohttp_client(app) - user = await legacy_api_password.async_get_user(hass) + user = await get_legacy_user(hass.auth) req = await client.get( '/', auth=BasicAuth('homeassistant', API_PASSWORD)) assert req.status == 200 assert await req.json() == { - 'refresh_token_id': None, 'user_id': user.id, } @@ -153,9 +168,11 @@ async def test_basic_auth_works(app, aiohttp_client, hass, legacy_auth): assert req.status == 401 -async def test_access_with_trusted_ip(app2, aiohttp_client, hass_owner_user): +async def test_access_with_trusted_ip(hass, app2, trusted_networks_auth, + aiohttp_client, + hass_owner_user): """Test access with an untrusted ip address.""" - setup_auth(app2, TRUSTED_NETWORKS, api_password='some-pass') + setup_auth(hass, app2) set_mock_ip = mock_real_ip(app2) client = await aiohttp_client(app2) @@ -172,7 +189,6 @@ async def test_access_with_trusted_ip(app2, aiohttp_client, hass_owner_user): assert resp.status == 200, \ "{} should be trusted".format(remote_addr) assert await resp.json() == { - 'refresh_token_id': None, 'user_id': hass_owner_user.id, } @@ -181,7 +197,7 @@ 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, [], api_password=None) + setup_auth(hass, app) client = await aiohttp_client(app) refresh_token = await hass.auth.async_validate_access_token( hass_access_token) @@ -190,7 +206,6 @@ async def test_auth_active_access_with_access_token_in_header( '/', headers={'Authorization': 'Bearer {}'.format(token)}) assert req.status == 200 assert await req.json() == { - 'refresh_token_id': refresh_token.id, 'user_id': refresh_token.user.id, } @@ -198,7 +213,6 @@ async def test_auth_active_access_with_access_token_in_header( '/', headers={'AUTHORIZATION': 'Bearer {}'.format(token)}) assert req.status == 200 assert await req.json() == { - 'refresh_token_id': refresh_token.id, 'user_id': refresh_token.user.id, } @@ -206,7 +220,6 @@ async def test_auth_active_access_with_access_token_in_header( '/', headers={'authorization': 'Bearer {}'.format(token)}) assert req.status == 200 assert await req.json() == { - 'refresh_token_id': refresh_token.id, 'user_id': refresh_token.user.id, } @@ -226,10 +239,12 @@ async def test_auth_active_access_with_access_token_in_header( assert req.status == 401 -async def test_auth_active_access_with_trusted_ip(app2, aiohttp_client, +async def test_auth_active_access_with_trusted_ip(hass, app2, + trusted_networks_auth, + aiohttp_client, hass_owner_user): """Test access with an untrusted ip address.""" - setup_auth(app2, TRUSTED_NETWORKS, None) + setup_auth(hass, app2) set_mock_ip = mock_real_ip(app2) client = await aiohttp_client(app2) @@ -246,7 +261,6 @@ async def test_auth_active_access_with_trusted_ip(app2, aiohttp_client, assert resp.status == 200, \ "{} should be trusted".format(remote_addr) assert await resp.json() == { - 'refresh_token_id': None, 'user_id': hass_owner_user.id, } @@ -254,15 +268,14 @@ async def test_auth_active_access_with_trusted_ip(app2, aiohttp_client, async def test_auth_legacy_support_api_password_access( app, aiohttp_client, legacy_auth, hass): """Test access using api_password if auth.support_legacy.""" - setup_auth(app, [], API_PASSWORD) + setup_auth(hass, app) client = await aiohttp_client(app) - user = await legacy_api_password.async_get_user(hass) + user = await get_legacy_user(hass.auth) req = await client.get( '/', headers={HTTP_HEADER_HA_AUTH: API_PASSWORD}) assert req.status == 200 assert await req.json() == { - 'refresh_token_id': None, 'user_id': user.id, } @@ -271,7 +284,6 @@ async def test_auth_legacy_support_api_password_access( }) assert resp.status == 200 assert await resp.json() == { - 'refresh_token_id': None, 'user_id': user.id, } @@ -280,7 +292,6 @@ async def test_auth_legacy_support_api_password_access( auth=BasicAuth('homeassistant', API_PASSWORD)) assert req.status == 200 assert await req.json() == { - 'refresh_token_id': None, 'user_id': user.id, } @@ -290,7 +301,7 @@ async def test_auth_access_signed_path( """Test access with signed url.""" app.router.add_post('/', mock_handler) app.router.add_get('/another_path', mock_handler) - setup_auth(app, [], None) + setup_auth(hass, app) client = await aiohttp_client(app) refresh_token = await hass.auth.async_validate_access_token( @@ -303,7 +314,6 @@ async def test_auth_access_signed_path( req = await client.get(signed_path) assert req.status == 200 data = await req.json() - assert data['refresh_token_id'] == refresh_token.id assert data['user_id'] == refresh_token.user.id # Use signature on other path diff --git a/tests/components/http/test_init.py b/tests/components/http/test_init.py index fadb91a3e03..a753dd275fe 100644 --- a/tests/components/http/test_init.py +++ b/tests/components/http/test_init.py @@ -143,15 +143,13 @@ async def test_api_base_url_removes_trailing_slash(hass): async def test_not_log_password(hass, aiohttp_client, caplog, legacy_auth): """Test access with password doesn't get logged.""" assert await async_setup_component(hass, 'api', { - 'http': { - http.CONF_API_PASSWORD: 'some-pass' - } + 'http': {} }) client = await aiohttp_client(hass.http.app) logging.getLogger('aiohttp.access').setLevel(logging.INFO) resp = await client.get('/api/', params={ - 'api_password': 'some-pass' + 'api_password': 'test-password' }) assert resp.status == 200 diff --git a/tests/components/mqtt/test_server.py b/tests/components/mqtt/test_server.py index 2589adf2f9c..71ef1dc1e43 100644 --- a/tests/components/mqtt/test_server.py +++ b/tests/components/mqtt/test_server.py @@ -19,24 +19,6 @@ class TestMQTT: """Stop everything that was started.""" self.hass.stop() - @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_http_pass_only(self, mock_mqtt): - """Test if the MQTT server failed starts. - - Since 0.77, MQTT server has to set up 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() - assert not setup_component(self.hass, mqtt.DOMAIN, { - 'http': {'api_password': 'http_secret'} - }) - @patch('passlib.apps.custom_app_context', Mock(return_value='')) @patch('tempfile.NamedTemporaryFile', Mock(return_value=MagicMock())) @patch('hbmqtt.broker.Broker', Mock(return_value=MagicMock())) diff --git a/tests/components/websocket_api/__init__.py b/tests/components/websocket_api/__init__.py index c218c6165d4..e58197e60be 100644 --- a/tests/components/websocket_api/__init__.py +++ b/tests/components/websocket_api/__init__.py @@ -1,2 +1,2 @@ """Tests for the websocket API.""" -API_PASSWORD = 'test1234' +API_PASSWORD = 'test-password' diff --git a/tests/conftest.py b/tests/conftest.py index 1dc5733cf40..efe24c51533 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -153,10 +153,12 @@ def legacy_auth(hass): """Load legacy API password provider.""" prv = legacy_api_password.LegacyApiPasswordAuthProvider( hass, hass.auth._store, { - 'type': 'legacy_api_password' + 'type': 'legacy_api_password', + 'api_password': 'test-password', } ) hass.auth._providers[(prv.type, prv.id)] = prv + return prv @pytest.fixture @@ -168,6 +170,7 @@ def local_auth(hass): } ) hass.auth._providers[(prv.type, prv.id)] = prv + return prv @pytest.fixture diff --git a/tests/test_config.py b/tests/test_config.py index e860ff53b3d..8afad09c946 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -822,7 +822,7 @@ async def test_auth_provider_config(hass): 'time_zone': 'GMT', CONF_AUTH_PROVIDERS: [ {'type': 'homeassistant'}, - {'type': 'legacy_api_password'}, + {'type': 'legacy_api_password', 'api_password': 'some-pass'}, ], CONF_AUTH_MFA_MODULES: [ {'type': 'totp'}, @@ -873,11 +873,12 @@ async def test_auth_provider_config_default_api_password(hass): } if hasattr(hass, 'auth'): del hass.auth - await config_util.async_process_ha_core_config(hass, core_config, True) + await config_util.async_process_ha_core_config(hass, core_config, 'pass') assert len(hass.auth.auth_providers) == 2 assert hass.auth.auth_providers[0].type == 'homeassistant' assert hass.auth.auth_providers[1].type == 'legacy_api_password' + assert hass.auth.auth_providers[1].api_password == 'pass' async def test_auth_provider_config_default_trusted_networks(hass): From fc85b3fc5f11fcd57dfb4403c62ec360c75b1ae1 Mon Sep 17 00:00:00 2001 From: emontnemery Date: Mon, 11 Mar 2019 03:57:30 +0100 Subject: [PATCH 180/291] Don't hang forever if manually added cast is down (#21565) * Don't hang forever if manually added cast is down * Adapt to pychromecast * Do not set available until connected * Update __init__.py * Update requirements * Lint, tests * Fix tests --- homeassistant/components/cast/__init__.py | 2 +- homeassistant/components/cast/media_player.py | 21 +++++++++---------- requirements_all.txt | 2 +- tests/components/cast/test_media_player.py | 11 ++++------ 4 files changed, 16 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/cast/__init__.py b/homeassistant/components/cast/__init__.py index 1b3da200540..bc32b36c455 100644 --- a/homeassistant/components/cast/__init__.py +++ b/homeassistant/components/cast/__init__.py @@ -2,7 +2,7 @@ from homeassistant import config_entries from homeassistant.helpers import config_entry_flow -REQUIREMENTS = ['pychromecast==2.5.2'] +REQUIREMENTS = ['pychromecast==3.0.0'] DOMAIN = 'cast' diff --git a/homeassistant/components/cast/media_player.py b/homeassistant/components/cast/media_player.py index 432290482f1..28373cc6c14 100644 --- a/homeassistant/components/cast/media_player.py +++ b/homeassistant/components/cast/media_player.py @@ -257,6 +257,9 @@ async def async_setup_entry(hass, config_entry, async_add_entities): _async_setup_platform(hass, cfg, async_add_entities, None) for cfg in config]) if any([task.exception() for task in done]): + exceptions = [task.exception() for task in done] + for exception in exceptions: + _LOGGER.debug("Failed to setup chromecast", exc_info=exception) raise PlatformNotReady @@ -289,7 +292,7 @@ async def _async_setup_platform(hass: HomeAssistantType, config: ConfigType, if cast_device is not None: async_add_entities([cast_device]) - remove_handler = async_dispatcher_connect( + async_dispatcher_connect( hass, SIGNAL_CAST_DISCOVERED, async_cast_discovered) # Re-play the callback for all past chromecasts, store the objects in # a list to avoid concurrent modification resulting in exception. @@ -306,8 +309,6 @@ async def _async_setup_platform(hass: HomeAssistantType, config: ConfigType, if info.friendly_name is None: _LOGGER.debug("Cannot retrieve detail information for chromecast" " %s, the device may not be online", info) - remove_handler() - raise PlatformNotReady hass.async_add_job(_discover_chromecast, hass, info) @@ -477,16 +478,10 @@ class CastDevice(MediaPlayerDevice): )) self._chromecast = chromecast self._status_listener = CastStatusListener(self, chromecast) - # Initialise connection status as connected because we can only - # register the connection listener *after* the initial connection - # attempt. If the initial connection failed, we would never reach - # this code anyway. - self._available = True + self._available = False self.cast_status = chromecast.status self.media_status = chromecast.media_controller.status - _LOGGER.debug("[%s %s (%s:%s)] Connection successful!", - self.entity_id, self._cast_info.friendly_name, - self._cast_info.host, self._cast_info.port) + self._chromecast.start() self.async_schedule_update_ha_state() async def async_del_cast_info(self, cast_info): @@ -562,6 +557,10 @@ class CastDevice(MediaPlayerDevice): self.entity_id, self._cast_info.friendly_name, self._cast_info.host, self._cast_info.port, connection_status.status) + info = self._cast_info + if info.friendly_name is None and not info.is_audio_group: + # We couldn't find friendly_name when the cast was added, retry + self._cast_info = _fill_out_missing_chromecast_info(info) self._available = new_available self.schedule_update_ha_state() diff --git a/requirements_all.txt b/requirements_all.txt index fc43608cda8..2d8b747fcc6 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -977,7 +977,7 @@ pycfdns==0.0.1 pychannels==1.0.0 # homeassistant.components.cast -pychromecast==2.5.2 +pychromecast==3.0.0 # homeassistant.components.media_player.cmus pycmus==0.1.1 diff --git a/tests/components/cast/test_media_player.py b/tests/components/cast/test_media_player.py index 66a975a226e..ff81c056420 100644 --- a/tests/components/cast/test_media_player.py +++ b/tests/components/cast/test_media_player.py @@ -222,13 +222,6 @@ async def test_normal_chromecast_not_starting_discovery(hass): assert setup_discovery.call_count == 1 -async def test_normal_raises_platform_not_ready(hass): - """Test cast platform raises PlatformNotReady if HTTP dial fails.""" - with patch('pychromecast.dial.get_device_status', return_value=None): - with pytest.raises(PlatformNotReady): - await async_setup_cast(hass, {'host': 'host1'}) - - async def test_replay_past_chromecasts(hass): """Test cast platform re-playing past chromecasts when adding new one.""" cast_group1 = get_fake_chromecast_info(host='host1', port=42) @@ -262,6 +255,10 @@ async def test_entity_media_states(hass: HomeAssistantType): return_value=full_info): chromecast, entity = await async_setup_media_player_cast(hass, info) + entity._available = True + entity.schedule_update_ha_state() + await hass.async_block_till_done() + state = hass.states.get('media_player.speaker') assert state is not None assert state.name == 'Speaker' From 429bbc05dcbe28c9a289197c5b035854ff1220dd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 10 Mar 2019 20:07:09 -0700 Subject: [PATCH 181/291] Add WS subscription command for MQTT (#21696) * Add WS subscription command for MQTT * Add test * Add check for connected * Rename event_listeners to subscriptions --- homeassistant/components/mqtt/__init__.py | 40 ++++++++++++++++++- .../components/websocket_api/__init__.py | 1 + .../components/websocket_api/commands.py | 19 +++------ .../components/websocket_api/connection.py | 4 +- .../components/websocket_api/messages.py | 9 +++++ tests/components/mqtt/test_init.py | 34 ++++++++++++++++ 6 files changed, 90 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 18ebb004209..d603b6637b0 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -25,7 +25,7 @@ from homeassistant.const import ( CONF_PROTOCOL, CONF_USERNAME, CONF_VALUE_TEMPLATE, EVENT_HOMEASSISTANT_STOP) from homeassistant.core import Event, ServiceCall, callback -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import HomeAssistantError, Unauthorized from homeassistant.helpers import config_validation as cv, template from homeassistant.helpers.entity import Entity from homeassistant.helpers.typing import ( @@ -35,6 +35,7 @@ from homeassistant.setup import async_prepare_setup_platform from homeassistant.util.async_ import ( run_callback_threadsafe, run_coroutine_threadsafe) from homeassistant.util.logging import catch_log_exception +from homeassistant.components import websocket_api # Loading the config flow file will register the flow from . import config_flow # noqa pylint: disable=unused-import @@ -391,6 +392,8 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType) -> bool: # This needs a better solution. hass.data[DATA_MQTT_HASS_CONFIG] = config + websocket_api.async_register_command(hass, websocket_subscribe) + if conf is None: # If we have a config entry, setup is done by that config entry. # If there is no config entry, this should fail. @@ -602,6 +605,7 @@ class MQTT: self.keepalive = keepalive self.subscriptions = [] # type: List[Subscription] self.birth_message = birth_message + self.connected = False self._mqttc = None # type: mqtt.Client self._paho_lock = asyncio.Lock(loop=hass.loop) @@ -703,7 +707,10 @@ class MQTT: if any(other.topic == topic for other in self.subscriptions): # Other subscriptions on topic remaining - don't unsubscribe. return - self.hass.async_create_task(self._async_unsubscribe(topic)) + + # Only unsubscribe if currently connected. + if self.connected: + self.hass.async_create_task(self._async_unsubscribe(topic)) return async_remove @@ -743,6 +750,8 @@ class MQTT: self._mqttc.disconnect() return + self.connected = True + # Group subscriptions to only re-subscribe once for each topic. keyfunc = attrgetter('topic') for topic, subs in groupby(sorted(self.subscriptions, key=keyfunc), @@ -782,6 +791,8 @@ class MQTT: def _mqtt_on_disconnect(self, _mqttc, _userdata, result_code: int) -> None: """Disconnected callback.""" + self.connected = False + # When disconnected because of calling disconnect() if result_code == 0: return @@ -791,6 +802,7 @@ class MQTT: while True: try: if self._mqttc.reconnect() == 0: + self.connected = True _LOGGER.info("Successfully reconnected to the MQTT server") break except socket.error: @@ -1040,3 +1052,27 @@ class MqttEntityDeviceInfo(Entity): info['via_hub'] = (DOMAIN, self._device_config[CONF_VIA_HUB]) return info + + +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required('type'): 'mqtt/subscribe', + vol.Required('topic'): valid_subscribe_topic, +}) +async def websocket_subscribe(hass, connection, msg): + """Subscribe to a MQTT topic.""" + if not connection.user.is_admin: + raise Unauthorized + + async def forward_messages(topic: str, payload: str, qos: int): + """Forward events to websocket.""" + connection.send_message(websocket_api.event_message(msg['id'], { + 'topic': topic, + 'payload': payload, + 'qos': qos, + })) + + connection.subscriptions[msg['id']] = await async_subscribe( + hass, msg['topic'], forward_messages) + + connection.send_message(websocket_api.result_message(msg['id'])) diff --git a/homeassistant/components/websocket_api/__init__.py b/homeassistant/components/websocket_api/__init__.py index 3734f46abb7..6c4935b9d95 100644 --- a/homeassistant/components/websocket_api/__init__.py +++ b/homeassistant/components/websocket_api/__init__.py @@ -14,6 +14,7 @@ ActiveConnection = connection.ActiveConnection BASE_COMMAND_MESSAGE_SCHEMA = messages.BASE_COMMAND_MESSAGE_SCHEMA error_message = messages.error_message result_message = messages.result_message +event_message = messages.event_message async_response = decorators.async_response require_admin = decorators.require_admin ws_require_user = decorators.ws_require_user diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index b367e3392ed..b64fac0ed51 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -24,15 +24,6 @@ def async_register_commands(hass): async_reg(handle_ping) -def event_message(iden, event): - """Return an event message.""" - return { - 'id': iden, - 'type': 'event', - 'event': event.as_dict(), - } - - def pong_message(iden): """Return a pong message.""" return { @@ -59,9 +50,11 @@ def handle_subscribe_events(hass, connection, msg): if event.event_type == EVENT_TIME_CHANGED: return - connection.send_message(event_message(msg['id'], event)) + connection.send_message(messages.event_message( + msg['id'], event.as_dict() + )) - connection.event_listeners[msg['id']] = hass.bus.async_listen( + connection.subscriptions[msg['id']] = hass.bus.async_listen( msg['event_type'], forward_events) connection.send_message(messages.result_message(msg['id'])) @@ -79,8 +72,8 @@ def handle_unsubscribe_events(hass, connection, msg): """ subscription = msg['subscription'] - if subscription in connection.event_listeners: - connection.event_listeners.pop(subscription)() + if subscription in connection.subscriptions: + connection.subscriptions.pop(subscription)() connection.send_message(messages.result_message(msg['id'])) else: connection.send_message(messages.error_message( diff --git a/homeassistant/components/websocket_api/connection.py b/homeassistant/components/websocket_api/connection.py index 041aad3969e..d65ba4c54d8 100644 --- a/homeassistant/components/websocket_api/connection.py +++ b/homeassistant/components/websocket_api/connection.py @@ -21,7 +21,7 @@ class ActiveConnection: else: self.refresh_token_id = None - self.event_listeners = {} + self.subscriptions = {} self.last_id = 0 def context(self, msg): @@ -82,7 +82,7 @@ class ActiveConnection: @callback def async_close(self): """Close down connection.""" - for unsub in self.event_listeners.values(): + for unsub in self.subscriptions.values(): unsub() @callback diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index d616b6ad670..c0f899d279e 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -40,3 +40,12 @@ def error_message(iden, code, message): 'message': message, }, } + + +def event_message(iden, event): + """Return an event message.""" + return { + 'id': iden, + 'type': 'event', + 'event': event, + } diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 94506efa909..81941173d68 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -767,3 +767,37 @@ async def test_message_callback_exception_gets_logged(hass, caplog): assert \ "Exception in bad_handler when handling msg on 'test-topic':" \ " 'test'" in caplog.text + + +async def test_mqtt_ws_subscription(hass, hass_ws_client): + """Test MQTT websocket subscription.""" + await async_mock_mqtt_component(hass) + + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 5, + 'type': 'mqtt/subscribe', + 'topic': 'test-topic', + }) + response = await client.receive_json() + assert response['success'] + + async_fire_mqtt_message(hass, 'test-topic', 'test1') + async_fire_mqtt_message(hass, 'test-topic', 'test2') + + response = await client.receive_json() + assert response['event']['topic'] == 'test-topic' + assert response['event']['payload'] == 'test1' + + response = await client.receive_json() + assert response['event']['topic'] == 'test-topic' + assert response['event']['payload'] == 'test2' + + # Unsubscribe + await client.send_json({ + 'id': 8, + 'type': 'unsubscribe_events', + 'subscription': 5, + }) + response = await client.receive_json() + assert response['success'] From 29f01fb14e5acd2d0457a2e032feaedae59ed5da Mon Sep 17 00:00:00 2001 From: Colby Rome Date: Mon, 11 Mar 2019 00:26:19 -0400 Subject: [PATCH 182/291] Add 'ssl' parameter for FiOS Quantum Gateway and upgrade Pypi (#21669) * bump pypi version and take 'use_https' parameter * changed to use CONF_SSL --- .../components/device_tracker/quantum_gateway.py | 9 ++++++--- requirements_all.txt | 2 +- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/device_tracker/quantum_gateway.py b/homeassistant/components/device_tracker/quantum_gateway.py index a06794f9179..90ba3575cfa 100644 --- a/homeassistant/components/device_tracker/quantum_gateway.py +++ b/homeassistant/components/device_tracker/quantum_gateway.py @@ -11,10 +11,10 @@ import voluptuous as vol from homeassistant.components.device_tracker import (DOMAIN, PLATFORM_SCHEMA, DeviceScanner) -from homeassistant.const import (CONF_HOST, CONF_PASSWORD) +from homeassistant.const import (CONF_HOST, CONF_PASSWORD, CONF_SSL) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['quantum-gateway==0.0.3'] +REQUIREMENTS = ['quantum-gateway==0.0.5'] _LOGGER = logging.getLogger(__name__) @@ -22,6 +22,7 @@ DEFAULT_HOST = 'myfiosgateway.com' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Optional(CONF_HOST, default=DEFAULT_HOST): cv.string, + vol.Optional(CONF_SSL, default=True): cv.boolean, vol.Required(CONF_PASSWORD): cv.string }) @@ -42,10 +43,12 @@ class QuantumGatewayDeviceScanner(DeviceScanner): self.host = config[CONF_HOST] self.password = config[CONF_PASSWORD] + self.use_https = config[CONF_SSL] _LOGGER.debug('Initializing') try: - self.quantum = QuantumGatewayScanner(self.host, self.password) + self.quantum = QuantumGatewayScanner(self.host, self.password, + self.use_https) self.success_init = self.quantum.success_init except RequestException: self.success_init = False diff --git a/requirements_all.txt b/requirements_all.txt index 2d8b747fcc6..d249ae12d4c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1478,7 +1478,7 @@ pyzbar==0.1.7 qnapstats==0.2.7 # homeassistant.components.device_tracker.quantum_gateway -quantum-gateway==0.0.3 +quantum-gateway==0.0.5 # homeassistant.components.rachio rachiopy==0.1.3 From 9ab543ab3dfe05ad2899a05c6c3154b1762e3769 Mon Sep 17 00:00:00 2001 From: Brad Dixon Date: Mon, 11 Mar 2019 03:51:22 -0400 Subject: [PATCH 183/291] Add as_timestamp() to Jinja filters. (#21910) Add as_timestamp() to Jinja filters --- homeassistant/helpers/template.py | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 03ae37843d8..d79c68ffd5e 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -657,6 +657,7 @@ ENV.filters['sin'] = sine ENV.filters['cos'] = cosine ENV.filters['tan'] = tangent ENV.filters['sqrt'] = square_root +ENV.filters['as_timestamp'] = forgiving_as_timestamp ENV.filters['timestamp_custom'] = timestamp_custom ENV.filters['timestamp_local'] = timestamp_local ENV.filters['timestamp_utc'] = timestamp_utc From b0d55d1946c7fcc66a7d4022ff6953d5c074ac94 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Mon, 11 Mar 2019 09:07:12 +0000 Subject: [PATCH 184/291] HomeKit controller config flow fixes (#21898) * HomeKit controller config flow fixes * This does work if you have latest vol-serialize --- homeassistant/components/homekit_controller/config_flow.py | 3 --- homeassistant/components/homekit_controller/strings.json | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 5b4d5e81e29..52768097aec 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -237,9 +237,6 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): return self.async_show_form( step_id='pair', - description_placeholders={ - 'model': self.model, - }, errors=errors, data_schema=vol.Schema({ vol.Required('pairing_code'): vol.All(str, vol.Strip), diff --git a/homeassistant/components/homekit_controller/strings.json b/homeassistant/components/homekit_controller/strings.json index 6cbd172085e..b1601a1f33e 100644 --- a/homeassistant/components/homekit_controller/strings.json +++ b/homeassistant/components/homekit_controller/strings.json @@ -10,7 +10,7 @@ } }, "pair": { - "title": "Pair with {{ model }}", + "title": "Pair with HomeKit Accessory", "description": "Enter your HomeKit pairing code to use this accessory", "data": { "pairing_code": "Pairing Code" From 85dc5fe4d555eb1036a8b0f9b1e222a958ad0dd7 Mon Sep 17 00:00:00 2001 From: Finbarr Brady Date: Mon, 11 Mar 2019 10:42:56 +0000 Subject: [PATCH 185/291] Update enigma2 based on review comments (#21890) * Updates based on review comments * bump netdisco and remove enigma2 init code * revert netdisco bump --- homeassistant/components/discovery/__init__.py | 2 +- homeassistant/components/enigma2/__init__.py | 17 ----------------- .../components/enigma2/media_player.py | 12 ++++++++---- 3 files changed, 9 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index ccc0b8387c2..1243804ecec 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -69,7 +69,7 @@ SERVICE_HANDLERS = { SERVICE_HASSIO: ('hassio', None), SERVICE_AXIS: ('axis', None), SERVICE_APPLE_TV: ('apple_tv', None), - SERVICE_ENIGMA2: ('enigma2', None), + SERVICE_ENIGMA2: ('media_player', 'enigma2'), SERVICE_ROKU: ('roku', None), SERVICE_WINK: ('wink', None), SERVICE_XIAOMI_GW: ('xiaomi_aqara', None), diff --git a/homeassistant/components/enigma2/__init__.py b/homeassistant/components/enigma2/__init__.py index 3f7f3d2241c..11cd4d9a804 100644 --- a/homeassistant/components/enigma2/__init__.py +++ b/homeassistant/components/enigma2/__init__.py @@ -1,18 +1 @@ """Support for Enigma2 devices.""" -from homeassistant.components.discovery import SERVICE_ENIGMA2 -from homeassistant.helpers.discovery import load_platform -from homeassistant.helpers import discovery - -DOMAIN = 'enigma2' - - -def setup(hass, config): - """Set up the Enigma2 platform.""" - def device_discovered(service, info): - """Handle when an Enigma2 device has been discovered.""" - load_platform(hass, 'media_player', DOMAIN, info, config) - - discovery.listen( - hass, SERVICE_ENIGMA2, device_discovered) - - return True diff --git a/homeassistant/components/enigma2/media_player.py b/homeassistant/components/enigma2/media_player.py index 10b39b7c5a9..40101120f12 100644 --- a/homeassistant/components/enigma2/media_player.py +++ b/homeassistant/components/enigma2/media_player.py @@ -1,6 +1,5 @@ """Support for Enigma2 media players.""" import logging -import asyncio import voluptuous as vol @@ -195,8 +194,7 @@ class Enigma2Device(MediaPlayerDevice): """List of available input sources.""" return self.e2_box.source_list - @asyncio.coroutine - def async_select_source(self, source): + def select_source(self, source): """Select input source.""" self.e2_box.select_source(self.e2_box.sources[source]) @@ -206,7 +204,13 @@ class Enigma2Device(MediaPlayerDevice): @property def device_state_attributes(self): - """Return device specific state attributes.""" + """Return device specific state attributes. + + isRecording: Is the box currently recording. + currservice_fulldescription: Full program description. + currservice_begin: is in the format '21:00'. + currservice_end: is in the format '21:00'. + """ attributes = {} if not self.e2_box.in_standby: attributes[ATTR_MEDIA_CURRENTLY_RECORDING] = \ From 49014ac13f87de5b3fcf491db651bef991414a85 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Mon, 11 Mar 2019 12:31:36 +0100 Subject: [PATCH 186/291] Remove confusing warning for TTS without entity_id (#21927) --- homeassistant/components/tts/__init__.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/tts/__init__.py b/homeassistant/components/tts/__init__.py index 475ed2c6892..0cd4a1bb6c6 100644 --- a/homeassistant/components/tts/__init__.py +++ b/homeassistant/components/tts/__init__.py @@ -22,7 +22,7 @@ from homeassistant.components.media_player.const import ( ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, MEDIA_TYPE_MUSIC, SERVICE_PLAY_MEDIA) from homeassistant.components.media_player.const import DOMAIN as DOMAIN_MP -from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.const import ATTR_ENTITY_ID, ENTITY_MATCH_ALL from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_per_platform @@ -126,7 +126,7 @@ async def async_setup(hass, config): async def async_say_handle(service): """Service handle for say.""" - entity_ids = service.data.get(ATTR_ENTITY_ID) + entity_ids = service.data.get(ATTR_ENTITY_ID, ENTITY_MATCH_ALL) message = service.data.get(ATTR_MESSAGE) cache = service.data.get(ATTR_CACHE) language = service.data.get(ATTR_LANGUAGE) @@ -144,11 +144,9 @@ async def async_setup(hass, config): data = { ATTR_MEDIA_CONTENT_ID: url, ATTR_MEDIA_CONTENT_TYPE: MEDIA_TYPE_MUSIC, + ATTR_ENTITY_ID: entity_ids, } - if entity_ids: - data[ATTR_ENTITY_ID] = entity_ids - await hass.services.async_call( DOMAIN_MP, SERVICE_PLAY_MEDIA, data, blocking=True) From 3fd6aa0ba94eb6cf6969ee6f47b6b5cc7e39b324 Mon Sep 17 00:00:00 2001 From: gertdb <42268196+gertdb@users.noreply.github.com> Date: Mon, 11 Mar 2019 13:17:31 +0100 Subject: [PATCH 187/291] Fixes Modbus service.yaml validity (#21923) * Update __init__.py * Update services.yaml * Update services.yaml --- homeassistant/components/modbus/services.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/modbus/services.yaml b/homeassistant/components/modbus/services.yaml index 48e9e815aaa..8713257b47c 100644 --- a/homeassistant/components/modbus/services.yaml +++ b/homeassistant/components/modbus/services.yaml @@ -9,4 +9,4 @@ write_register: fields: address: {description: Address of the holding register to write to., example: 0} unit: {description: Address of the modbus unit., example: 21} - value: {description: Value (single value or array) to write., example: 0 or [4,0]} + value: {description: Value (single value or array) to write., example: "0 or [4,0]"} From c401f35a43f25fbaf41b47521eaf732d5087be65 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Mon, 11 Mar 2019 07:33:25 -0500 Subject: [PATCH 188/291] Add cloudhook support to SmartThings component (#21905) * Add support for Nabu Casa cloudhooks * Added tests to cover cloudhook creation and removal * Remove cloud dependency --- .../components/smartthings/__init__.py | 13 +- .../components/smartthings/config_flow.py | 11 +- homeassistant/components/smartthings/const.py | 1 + .../components/smartthings/smartapp.py | 111 +++++++++++++++--- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/smartthings/conftest.py | 3 +- .../smartthings/test_config_flow.py | 36 +++++- tests/components/smartthings/test_init.py | 30 ++++- 9 files changed, 176 insertions(+), 33 deletions(-) diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 3734baae6f4..e5226076f46 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -25,9 +25,10 @@ from .const import ( TOKEN_REFRESH_INTERVAL) from .smartapp import ( setup_smartapp, setup_smartapp_endpoint, smartapp_sync_subscriptions, - validate_installed_app) + unload_smartapp_endpoint, validate_installed_app, + validate_webhook_requirements) -REQUIREMENTS = ['pysmartapp==0.3.1', 'pysmartthings==0.6.7'] +REQUIREMENTS = ['pysmartapp==0.3.2', 'pysmartthings==0.6.7'] DEPENDENCIES = ['webhook'] _LOGGER = logging.getLogger(__name__) @@ -64,7 +65,7 @@ async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry): """Initialize config entry which represents an installed SmartApp.""" from pysmartthings import SmartThings - if not hass.config.api.base_url.lower().startswith('https://'): + if not validate_webhook_requirements(hass): _LOGGER.warning("The 'base_url' of the 'http' component must be " "configured and start with 'https://'") return False @@ -200,8 +201,9 @@ async def async_remove_entry( # Remove the app if not referenced by other entries, which if already # removed raises a 403 error. + all_entries = hass.config_entries.async_entries(DOMAIN) app_id = entry.data[CONF_APP_ID] - app_count = sum(1 for entry in hass.config_entries.async_entries(DOMAIN) + app_count = sum(1 for entry in all_entries if entry.data[CONF_APP_ID] == app_id) if app_count > 1: _LOGGER.debug("App %s was not removed because it is in use by other" @@ -218,6 +220,9 @@ async def async_remove_entry( raise _LOGGER.debug("Removed app %s", app_id) + if len(all_entries) == 1: + await unload_smartapp_endpoint(hass) + class DeviceBroker: """Manages an individual SmartThings config entry.""" diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index c290f0f8e55..da9b7c8854e 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -13,7 +13,8 @@ from .const import ( CONF_LOCATION_ID, CONF_OAUTH_CLIENT_ID, CONF_OAUTH_CLIENT_SECRET, DOMAIN, VAL_UID_MATCHER) from .smartapp import ( - create_app, find_app, setup_smartapp, setup_smartapp_endpoint, update_app) + create_app, find_app, setup_smartapp, setup_smartapp_endpoint, update_app, + validate_webhook_requirements) _LOGGER = logging.getLogger(__name__) @@ -56,10 +57,6 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow): from pysmartthings import APIResponseError, AppOAuth, SmartThings errors = {} - if not self.hass.config.api.base_url.lower().startswith('https://'): - errors['base'] = "base_url_not_https" - return self._show_step_user(errors) - if user_input is None or CONF_ACCESS_TOKEN not in user_input: return self._show_step_user(errors) @@ -81,6 +78,10 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow): # Setup end-point await setup_smartapp_endpoint(self.hass) + if not validate_webhook_requirements(self.hass): + errors['base'] = "base_url_not_https" + return self._show_step_user(errors) + try: app = await find_app(self.hass, self.api) if app: diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index 105c9760e12..aa7cba8e74c 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -8,6 +8,7 @@ APP_OAUTH_SCOPES = [ ] APP_NAME_PREFIX = 'homeassistant.' CONF_APP_ID = 'app_id' +CONF_CLOUDHOOK_URL = 'cloudhook_url' CONF_INSTALLED_APP_ID = 'installed_app_id' CONF_INSTALLED_APPS = 'installed_apps' CONF_INSTANCE_ID = 'instance_id' diff --git a/homeassistant/components/smartthings/smartapp.py b/homeassistant/components/smartthings/smartapp.py index 5527fda54f4..0b64bac5956 100644 --- a/homeassistant/components/smartthings/smartapp.py +++ b/homeassistant/components/smartthings/smartapp.py @@ -8,11 +8,12 @@ callbacks when device states change. import asyncio import functools import logging +from urllib.parse import urlparse from uuid import uuid4 from aiohttp import web -from homeassistant.components import webhook +from homeassistant.components import cloud, webhook from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.dispatcher import ( @@ -21,9 +22,10 @@ from homeassistant.helpers.typing import HomeAssistantType from .const import ( APP_NAME_PREFIX, APP_OAUTH_CLIENT_NAME, APP_OAUTH_SCOPES, CONF_APP_ID, - CONF_INSTALLED_APP_ID, CONF_INSTALLED_APPS, CONF_INSTANCE_ID, - CONF_LOCATION_ID, CONF_REFRESH_TOKEN, DATA_BROKERS, DATA_MANAGER, DOMAIN, - SETTINGS_INSTANCE_ID, SIGNAL_SMARTAPP_PREFIX, STORAGE_KEY, STORAGE_VERSION) + CONF_CLOUDHOOK_URL, CONF_INSTALLED_APP_ID, CONF_INSTALLED_APPS, + CONF_INSTANCE_ID, CONF_LOCATION_ID, CONF_REFRESH_TOKEN, DATA_BROKERS, + DATA_MANAGER, DOMAIN, SETTINGS_INSTANCE_ID, SIGNAL_SMARTAPP_PREFIX, + STORAGE_KEY, STORAGE_VERSION) _LOGGER = logging.getLogger(__name__) @@ -59,15 +61,39 @@ async def validate_installed_app(api, installed_app_id: str): return installed_app +def validate_webhook_requirements(hass: HomeAssistantType) -> bool: + """Ensure HASS is setup properly to receive webhooks.""" + if cloud.async_active_subscription(hass): + return True + return get_webhook_url(hass).lower().startswith('https://') + + +def get_webhook_url(hass: HomeAssistantType) -> str: + """ + Get the URL of the webhook. + + Return the cloudhook if available, otherwise local webhook. + """ + cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL] + if cloud.async_active_subscription(hass) and cloudhook_url is not None: + return cloudhook_url + return webhook.async_generate_url(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]) + + def _get_app_template(hass: HomeAssistantType): from pysmartthings import APP_TYPE_WEBHOOK, CLASSIFICATION_AUTOMATION + endpoint = "at " + hass.config.api.base_url + cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL] + if cloudhook_url is not None: + endpoint = "via Nabu Casa" + description = "{} {}".format(hass.config.location_name, endpoint) + return { 'app_name': APP_NAME_PREFIX + str(uuid4()), 'display_name': 'Home Assistant', - 'description': "Home Assistant at " + hass.config.api.base_url, - 'webhook_target_url': webhook.async_generate_url( - hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]), + 'description': description, + 'webhook_target_url': get_webhook_url(hass), 'app_type': APP_TYPE_WEBHOOK, 'single_instance': True, 'classifications': [CLASSIFICATION_AUTOMATION] @@ -162,33 +188,80 @@ async def setup_smartapp_endpoint(hass: HomeAssistantType): # Create config config = { CONF_INSTANCE_ID: str(uuid4()), - CONF_WEBHOOK_ID: webhook.generate_secret() + CONF_WEBHOOK_ID: webhook.generate_secret(), + CONF_CLOUDHOOK_URL: None } await store.async_save(config) + # Register webhook + webhook.async_register(hass, DOMAIN, 'SmartApp', + config[CONF_WEBHOOK_ID], smartapp_webhook) + + # Create webhook if eligible + cloudhook_url = config.get(CONF_CLOUDHOOK_URL) + if cloudhook_url is None \ + and cloud.async_active_subscription(hass) \ + and not hass.config_entries.async_entries(DOMAIN): + cloudhook_url = await cloud.async_create_cloudhook( + hass, config[CONF_WEBHOOK_ID]) + config[CONF_CLOUDHOOK_URL] = cloudhook_url + await store.async_save(config) + _LOGGER.debug("Created cloudhook '%s'", cloudhook_url) + # SmartAppManager uses a dispatcher to invoke callbacks when push events # occur. Use hass' implementation instead of the built-in one. dispatcher = Dispatcher( signal_prefix=SIGNAL_SMARTAPP_PREFIX, connect=functools.partial(async_dispatcher_connect, hass), send=functools.partial(async_dispatcher_send, hass)) - manager = SmartAppManager( - webhook.async_generate_path(config[CONF_WEBHOOK_ID]), - dispatcher=dispatcher) + # Path is used in digital signature validation + path = urlparse(cloudhook_url).path if cloudhook_url else \ + webhook.async_generate_path(config[CONF_WEBHOOK_ID]) + manager = SmartAppManager(path, dispatcher=dispatcher) manager.connect_install(functools.partial(smartapp_install, hass)) manager.connect_update(functools.partial(smartapp_update, hass)) manager.connect_uninstall(functools.partial(smartapp_uninstall, hass)) - webhook.async_register(hass, DOMAIN, 'SmartApp', - config[CONF_WEBHOOK_ID], smartapp_webhook) - hass.data[DOMAIN] = { DATA_MANAGER: manager, CONF_INSTANCE_ID: config[CONF_INSTANCE_ID], DATA_BROKERS: {}, CONF_WEBHOOK_ID: config[CONF_WEBHOOK_ID], + # Will not be present if not enabled + CONF_CLOUDHOOK_URL: config.get(CONF_CLOUDHOOK_URL), CONF_INSTALLED_APPS: [] } + _LOGGER.debug("Setup endpoint for %s", + cloudhook_url if cloudhook_url else + webhook.async_generate_url(hass, config[CONF_WEBHOOK_ID])) + + +async def unload_smartapp_endpoint(hass: HomeAssistantType): + """Tear down the component configuration.""" + if DOMAIN not in hass.data: + return + # Remove the cloudhook if it was created + cloudhook_url = hass.data[DOMAIN][CONF_CLOUDHOOK_URL] + if cloudhook_url and cloud.async_is_logged_in(hass): + await cloud.async_delete_cloudhook( + hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]) + # Remove cloudhook from storage + store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + await store.async_save({ + CONF_INSTANCE_ID: hass.data[DOMAIN][CONF_INSTANCE_ID], + CONF_WEBHOOK_ID: hass.data[DOMAIN][CONF_WEBHOOK_ID], + CONF_CLOUDHOOK_URL: None + }) + _LOGGER.debug("Cloudhook '%s' was removed", cloudhook_url) + # Remove the webhook + webhook.async_unregister(hass, hass.data[DOMAIN][CONF_WEBHOOK_ID]) + # Disconnect all brokers + for broker in hass.data[DOMAIN][DATA_BROKERS].values(): + broker.disconnect() + # Remove all handlers from manager + hass.data[DOMAIN][DATA_MANAGER].dispatcher.disconnect_all() + # Remove the component data + hass.data.pop(DOMAIN) async def smartapp_sync_subscriptions( @@ -285,6 +358,9 @@ async def smartapp_install(hass: HomeAssistantType, req, resp, app): # Store the data where the flow can find it hass.data[DOMAIN][CONF_INSTALLED_APPS].append(install_data) + _LOGGER.debug("Installed SmartApp '%s' under parent app '%s'", + req.installed_app_id, app.app_id) + async def smartapp_update(hass: HomeAssistantType, req, resp, app): """ @@ -301,7 +377,7 @@ async def smartapp_update(hass: HomeAssistantType, req, resp, app): entry.data[CONF_REFRESH_TOKEN] = req.refresh_token hass.config_entries.async_update_entry(entry) - _LOGGER.debug("SmartApp '%s' under parent app '%s' was updated", + _LOGGER.debug("Updated SmartApp '%s' under parent app '%s'", req.installed_app_id, app.app_id) @@ -316,12 +392,13 @@ async def smartapp_uninstall(hass: HomeAssistantType, req, resp, app): req.installed_app_id), None) if entry: - _LOGGER.debug("SmartApp '%s' under parent app '%s' was removed", - req.installed_app_id, app.app_id) # Add as job not needed because the current coroutine was invoked # from the dispatcher and is not being awaited. await hass.config_entries.async_remove(entry.entry_id) + _LOGGER.debug("Uninstalled SmartApp '%s' under parent app '%s'", + req.installed_app_id, app.app_id) + async def smartapp_webhook(hass: HomeAssistantType, webhook_id: str, request): """ diff --git a/requirements_all.txt b/requirements_all.txt index d249ae12d4c..c574b02cbbc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1268,7 +1268,7 @@ pysher==1.0.1 pysma==0.3.1 # homeassistant.components.smartthings -pysmartapp==0.3.1 +pysmartapp==0.3.2 # homeassistant.components.smartthings pysmartthings==0.6.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b6d11228b93..84d103065e1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -224,7 +224,7 @@ pyps4-homeassistant==0.4.8 pyqwikswitch==0.8 # homeassistant.components.smartthings -pysmartapp==0.3.1 +pysmartapp==0.3.2 # homeassistant.components.smartthings pysmartthings==0.6.7 diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index 67c35ba8232..3f346c9df0d 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -85,7 +85,8 @@ def app_fixture(hass, config_file): 'appType': 'WEBHOOK_SMART_APP', 'classifications': [CLASSIFICATION_AUTOMATION], 'displayName': 'Home Assistant', - 'description': "Home Assistant at " + hass.config.api.base_url, + 'description': + hass.config.location_name + " at " + hass.config.api.base_url, 'singleInstance': True, 'webhookSmartApp': { 'targetUrl': webhook.async_generate_url( diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 28aa759a359..b79ab59a98a 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -6,6 +6,8 @@ from aiohttp import ClientResponseError from pysmartthings import APIResponseError from homeassistant import data_entry_flow +from homeassistant.components import cloud +from homeassistant.components.smartthings import smartapp from homeassistant.components.smartthings.config_flow import ( SmartThingsFlowHandler) from homeassistant.components.smartthings.const import ( @@ -41,7 +43,7 @@ async def test_base_url_not_https(hass): hass.config.api.base_url = 'http://0.0.0.0' flow = SmartThingsFlowHandler() flow.hass = hass - result = await flow.async_step_import() + result = await flow.async_step_user({'access_token': str(uuid4())}) assert result['type'] == data_entry_flow.RESULT_TYPE_FORM assert result['step_id'] == 'user' @@ -193,6 +195,38 @@ async def test_app_created_then_show_wait_form( assert result['step_id'] == 'wait_install' +async def test_cloudhook_app_created_then_show_wait_form( + hass, app, app_oauth_client, smartthings_mock): + """Test SmartApp is created with a cloudhoko and shows wait form.""" + # Unload the endpoint so we can reload it under the cloud. + await smartapp.unload_smartapp_endpoint(hass) + + mock_async_active_subscription = Mock(return_value=True) + mock_create_cloudhook = Mock(return_value=mock_coro( + return_value="http://cloud.test")) + with patch.object(cloud, 'async_active_subscription', + new=mock_async_active_subscription), \ + patch.object(cloud, 'async_create_cloudhook', + new=mock_create_cloudhook): + + await smartapp.setup_smartapp_endpoint(hass) + + flow = SmartThingsFlowHandler() + flow.hass = hass + smartthings = smartthings_mock.return_value + smartthings.apps.return_value = mock_coro(return_value=[]) + smartthings.create_app.return_value = \ + mock_coro(return_value=(app, app_oauth_client)) + smartthings.update_app_settings.return_value = mock_coro() + smartthings.update_app_oauth.return_value = mock_coro() + + result = await flow.async_step_user({'access_token': str(uuid4())}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'wait_install' + assert mock_create_cloudhook.call_count == 1 + + async def test_app_updated_then_show_wait_form( hass, app, app_oauth_client, smartthings_mock): """Test SmartApp is updated when an existing is already created.""" diff --git a/tests/components/smartthings/test_init.py b/tests/components/smartthings/test_init.py index dfb596998b7..a5edc93fce6 100644 --- a/tests/components/smartthings/test_init.py +++ b/tests/components/smartthings/test_init.py @@ -6,10 +6,11 @@ from aiohttp import ClientConnectionError, ClientResponseError from pysmartthings import InstalledAppStatus import pytest -from homeassistant.components import smartthings +from homeassistant.components import cloud, smartthings from homeassistant.components.smartthings.const import ( - CONF_INSTALLED_APP_ID, CONF_REFRESH_TOKEN, DATA_BROKERS, DOMAIN, - EVENT_BUTTON, SIGNAL_SMARTTHINGS_UPDATE, SUPPORTED_PLATFORMS) + CONF_CLOUDHOOK_URL, CONF_INSTALLED_APP_ID, CONF_REFRESH_TOKEN, + DATA_BROKERS, DOMAIN, EVENT_BUTTON, SIGNAL_SMARTTHINGS_UPDATE, + SUPPORTED_PLATFORMS) from homeassistant.exceptions import ConfigEntryNotReady from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -224,6 +225,29 @@ async def test_remove_entry(hass, config_entry, smartthings_mock): assert api.delete_app.call_count == 1 +async def test_remove_entry_cloudhook(hass, config_entry, smartthings_mock): + """Test that the installed app, app, and cloudhook are removed up.""" + # Arrange + setattr(hass.config_entries, '_entries', [config_entry]) + hass.data[DOMAIN][CONF_CLOUDHOOK_URL] = "https://test.cloud" + api = smartthings_mock.return_value + api.delete_installed_app.side_effect = lambda _: mock_coro() + api.delete_app.side_effect = lambda _: mock_coro() + mock_async_is_logged_in = Mock(return_value=True) + mock_async_delete_cloudhook = Mock(return_value=mock_coro()) + # Act + with patch.object(cloud, 'async_is_logged_in', + new=mock_async_is_logged_in), \ + patch.object(cloud, 'async_delete_cloudhook', + new=mock_async_delete_cloudhook): + await smartthings.async_remove_entry(hass, config_entry) + # Assert + assert api.delete_installed_app.call_count == 1 + assert api.delete_app.call_count == 1 + assert mock_async_is_logged_in.call_count == 1 + assert mock_async_delete_cloudhook.call_count == 1 + + async def test_remove_entry_app_in_use(hass, config_entry, smartthings_mock): """Test app is not removed if in use by another config entry.""" # Arrange From 785fd273e31078fa279a68aa8ef22424ae8d4549 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 11 Mar 2019 05:34:58 -0700 Subject: [PATCH 189/291] If registration supports encryption then return encrypted payloads (#21853) --- .../components/mobile_app/helpers.py | 45 ++++++++++++++++--- .../components/mobile_app/http_api.py | 4 +- .../components/mobile_app/webhook.py | 16 ++++--- tests/components/mobile_app/__init__.py | 6 +++ tests/components/mobile_app/const.py | 12 +++++ tests/components/mobile_app/test_http_api.py | 8 +++- tests/components/mobile_app/test_webhook.py | 24 ++++++---- 7 files changed, 91 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py index 82e6c1b6afa..28d8a797a32 100644 --- a/homeassistant/components/mobile_app/helpers.py +++ b/homeassistant/components/mobile_app/helpers.py @@ -9,15 +9,15 @@ from homeassistant.core import Context from homeassistant.helpers.typing import HomeAssistantType from .const import (ATTR_APP_DATA, ATTR_APP_ID, ATTR_APP_NAME, - ATTR_APP_VERSION, DATA_DELETED_IDS, ATTR_DEVICE_NAME, - ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, - DATA_REGISTRATIONS, ATTR_SUPPORTS_ENCRYPTION, - CONF_USER_ID, DOMAIN) + ATTR_APP_VERSION, ATTR_DEVICE_NAME, ATTR_MANUFACTURER, + ATTR_MODEL, ATTR_OS_VERSION, ATTR_SUPPORTS_ENCRYPTION, + CONF_SECRET, CONF_USER_ID, DATA_DELETED_IDS, + DATA_REGISTRATIONS, DOMAIN) _LOGGER = logging.getLogger(__name__) -def get_cipher() -> Tuple[int, Callable]: +def setup_decrypt() -> Tuple[int, Callable]: """Return decryption function and length of key. Async friendly. @@ -31,10 +31,24 @@ def get_cipher() -> Tuple[int, Callable]: return (SecretBox.KEY_SIZE, decrypt) +def setup_encrypt() -> Tuple[int, Callable]: + """Return encryption function and length of key. + + Async friendly. + """ + from nacl.secret import SecretBox + from nacl.encoding import Base64Encoder + + def encrypt(ciphertext, key): + """Encrypt ciphertext using key.""" + return SecretBox(key).encrypt(ciphertext, encoder=Base64Encoder) + return (SecretBox.KEY_SIZE, encrypt) + + def _decrypt_payload(key: str, ciphertext: str) -> Dict[str, str]: """Decrypt encrypted payload.""" try: - keylen, decrypt = get_cipher() + keylen, decrypt = setup_decrypt() except OSError: _LOGGER.warning( "Ignoring encrypted payload because libsodium not installed") @@ -101,3 +115,22 @@ def savable_state(hass: HomeAssistantType) -> Dict: DATA_DELETED_IDS: hass.data[DOMAIN][DATA_DELETED_IDS], DATA_REGISTRATIONS: hass.data[DOMAIN][DATA_REGISTRATIONS] } + + +def webhook_response(data, *, registration: Dict, status: int = 200, + headers: Dict = None) -> Response: + """Return a encrypted response if registration supports it.""" + data = json.dumps(data) + + if CONF_SECRET in registration: + keylen, encrypt = setup_encrypt() + + key = registration[CONF_SECRET].encode("utf-8") + key = key[:keylen] + key = key.ljust(keylen, b'\0') + + enc_data = encrypt(data.encode("utf-8"), key).decode("utf-8") + data = json.dumps({'encrypted': True, 'encrypted_data': enc_data}) + + return Response(text=data, status=status, content_type='application/json', + headers=headers) diff --git a/homeassistant/components/mobile_app/http_api.py b/homeassistant/components/mobile_app/http_api.py index 8de1d954605..15e1385359e 100644 --- a/homeassistant/components/mobile_app/http_api.py +++ b/homeassistant/components/mobile_app/http_api.py @@ -53,9 +53,9 @@ class RegistrationsView(HomeAssistantView): data[CONF_WEBHOOK_ID] = webhook_id if data[ATTR_SUPPORTS_ENCRYPTION] and supports_encryption(): - secret = generate_secret(16) + from nacl.secret import SecretBox - data[CONF_SECRET] = secret + data[CONF_SECRET] = generate_secret(SecretBox.KEY_SIZE) data[CONF_USER_ID] = request['hass_user'].id diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index a5496c4395d..a14354e4ae3 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -3,7 +3,7 @@ from functools import partial import logging from typing import Dict -from aiohttp.web import HTTPBadRequest, json_response, Response, Request +from aiohttp.web import HTTPBadRequest, Response, Request import voluptuous as vol from homeassistant.components.device_tracker import (DOMAIN as DT_DOMAIN, @@ -32,7 +32,8 @@ from .const import (ATTR_APP_COMPONENT, DATA_DELETED_IDS, WEBHOOK_TYPE_UPDATE_REGISTRATION) from .helpers import (_decrypt_payload, empty_okay_response, - registration_context, safe_registration, savable_state) + registration_context, safe_registration, savable_state, + webhook_response) _LOGGER = logging.getLogger(__name__) @@ -127,14 +128,16 @@ async def handle_webhook(store: Store, hass: HomeAssistantType, try: tpl = template.Template(data[ATTR_TEMPLATE], hass) rendered = tpl.async_render(data.get(ATTR_TEMPLATE_VARIABLES)) - return json_response({"rendered": rendered}, headers=headers) + return webhook_response({"rendered": rendered}, + registration=registration, headers=headers) # noqa: E722 pylint: disable=broad-except except (ValueError, TemplateError, Exception) as ex: _LOGGER.error("Error when rendering template during mobile_app " "webhook (device name: %s): %s", registration[ATTR_DEVICE_NAME], ex) - return json_response(({"error": str(ex)}), status=HTTP_BAD_REQUEST, - headers=headers) + return webhook_response(({"error": str(ex)}), + status=HTTP_BAD_REQUEST, + registration=registration, headers=headers) if webhook_type == WEBHOOK_TYPE_UPDATE_LOCATION: try: @@ -159,4 +162,5 @@ async def handle_webhook(store: Store, hass: HomeAssistantType, _LOGGER.error("Error updating mobile_app registration: %s", ex) return empty_okay_response() - return json_response(safe_registration(new_registration)) + return webhook_response(safe_registration(new_registration), + registration=registration, headers=headers) diff --git a/tests/components/mobile_app/__init__.py b/tests/components/mobile_app/__init__.py index 02107eafb81..1f91eb7e442 100644 --- a/tests/components/mobile_app/__init__.py +++ b/tests/components/mobile_app/__init__.py @@ -26,6 +26,12 @@ def webhook_client(hass, aiohttp_client, hass_storage, hass_admin_user): CONF_WEBHOOK_ID: 'mobile_app_test', 'device_name': 'Test Device', CONF_USER_ID: hass_admin_user.id, + }, + 'mobile_app_test_cleartext': { + 'supports_encryption': False, + CONF_WEBHOOK_ID: 'mobile_app_test_cleartext', + 'device_name': 'Test Device (Cleartext)', + CONF_USER_ID: hass_admin_user.id, } }, DATA_DELETED_IDS: [], diff --git a/tests/components/mobile_app/const.py b/tests/components/mobile_app/const.py index 423af7929a4..63b37932104 100644 --- a/tests/components/mobile_app/const.py +++ b/tests/components/mobile_app/const.py @@ -32,6 +32,18 @@ REGISTER = { 'supports_encryption': True } +REGISTER_CLEARTEXT = { + 'app_data': {'foo': 'bar'}, + 'app_id': 'io.homeassistant.mobile_app_test', + 'app_name': 'Mobile App Tests', + 'app_version': '1.0.0', + 'device_name': 'Test 1', + 'manufacturer': 'mobile_app', + 'model': 'Test', + 'os_version': '1.0', + 'supports_encryption': False +} + RENDER_TEMPLATE = { 'type': 'render_template', 'data': { diff --git a/tests/components/mobile_app/test_http_api.py b/tests/components/mobile_app/test_http_api.py index 96b1a9d8cf4..3ff93bdfa75 100644 --- a/tests/components/mobile_app/test_http_api.py +++ b/tests/components/mobile_app/test_http_api.py @@ -56,4 +56,10 @@ async def test_registration(hass_client, authed_api_client): # noqa: F811 assert resp.status == 200 webhook_json = await resp.json() - assert webhook_json == {'rendered': 'Hello world'} + assert 'encrypted_data' in webhook_json + + decrypted_data = SecretBox(key).decrypt(webhook_json['encrypted_data'], + encoder=Base64Encoder) + decrypted_data = decrypted_data.decode("utf-8") + + assert json.loads(decrypted_data) == {'rendered': 'Hello world'} diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index f2e838fb3cb..a935110754c 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -10,14 +10,14 @@ from tests.common import async_mock_service from . import authed_api_client, webhook_client # noqa: F401 -from .const import (CALL_SERVICE, FIRE_EVENT, REGISTER, RENDER_TEMPLATE, - UPDATE) +from .const import (CALL_SERVICE, FIRE_EVENT, REGISTER_CLEARTEXT, + RENDER_TEMPLATE, UPDATE) async def test_webhook_handle_render_template(webhook_client): # noqa: F811 """Test that we render templates properly.""" resp = await webhook_client.post( - '/api/webhook/mobile_app_test', + '/api/webhook/mobile_app_test_cleartext', json=RENDER_TEMPLATE ) @@ -32,7 +32,7 @@ async def test_webhook_handle_call_services(hass, webhook_client): # noqa: E501 calls = async_mock_service(hass, 'test', 'mobile_app') resp = await webhook_client.post( - '/api/webhook/mobile_app_test', + '/api/webhook/mobile_app_test_cleartext', json=CALL_SERVICE ) @@ -53,7 +53,7 @@ async def test_webhook_handle_fire_event(hass, webhook_client): # noqa: F811 hass.bus.async_listen('test_event', store_event) resp = await webhook_client.post( - '/api/webhook/mobile_app_test', + '/api/webhook/mobile_app_test_cleartext', json=FIRE_EVENT ) @@ -69,7 +69,7 @@ async def test_webhook_update_registration(webhook_client, hass_client): # noqa """Test that a we can update an existing registration via webhook.""" authed_api_client = await hass_client() # noqa: F811 register_resp = await authed_api_client.post( - '/api/mobile_app/registrations', json=REGISTER + '/api/mobile_app/registrations', json=REGISTER_CLEARTEXT ) assert register_resp.status == 201 @@ -96,7 +96,7 @@ async def test_webhook_update_registration(webhook_client, hass_client): # noqa async def test_webhook_returns_error_incorrect_json(webhook_client, caplog): # noqa: E501 F811 """Test that an error is returned when JSON is invalid.""" resp = await webhook_client.post( - '/api/webhook/mobile_app_test', + '/api/webhook/mobile_app_test_cleartext', data='not json' ) @@ -141,5 +141,11 @@ async def test_webhook_handle_decryption(webhook_client): # noqa: F811 assert resp.status == 200 - json = await resp.json() - assert json == {'rendered': 'Hello world'} + webhook_json = await resp.json() + assert 'encrypted_data' in webhook_json + + decrypted_data = SecretBox(key).decrypt(webhook_json['encrypted_data'], + encoder=Base64Encoder) + decrypted_data = decrypted_data.decode("utf-8") + + assert json.loads(decrypted_data) == {'rendered': 'Hello world'} From bc76055c178c6e9b927518552561741e347557f7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C3=ABl=20Arnauts?= Date: Mon, 11 Mar 2019 16:13:53 +0100 Subject: [PATCH 190/291] Allow inverting netdata sensor values (#21711) * Allow inverting netdata sensor values * Fix lint issue * Use parentheses --- homeassistant/components/sensor/netdata.py | 12 +++++++++--- 1 file changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/sensor/netdata.py b/homeassistant/components/sensor/netdata.py index dc517a0c50d..6a6eea02005 100644 --- a/homeassistant/components/sensor/netdata.py +++ b/homeassistant/components/sensor/netdata.py @@ -26,6 +26,7 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) CONF_DATA_GROUP = 'data_group' CONF_ELEMENT = 'element' +CONF_INVERT = 'invert' DEFAULT_HOST = 'localhost' DEFAULT_NAME = 'Netdata' @@ -37,6 +38,7 @@ RESOURCE_SCHEMA = vol.Any({ vol.Required(CONF_DATA_GROUP): cv.string, vol.Required(CONF_ELEMENT): cv.string, vol.Optional(CONF_ICON, default=DEFAULT_ICON): cv.icon, + vol.Optional(CONF_INVERT, default=False): cv.boolean, }) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ @@ -69,6 +71,7 @@ async def async_setup_platform( icon = data[CONF_ICON] sensor = data[CONF_DATA_GROUP] element = data[CONF_ELEMENT] + invert = data[CONF_INVERT] sensor_name = entry try: resource_data = netdata.api.metrics[sensor] @@ -79,7 +82,7 @@ async def async_setup_platform( continue dev.append(NetdataSensor( - netdata, name, sensor, sensor_name, element, icon, unit)) + netdata, name, sensor, sensor_name, element, icon, unit, invert)) async_add_entities(dev, True) @@ -88,7 +91,8 @@ class NetdataSensor(Entity): """Implementation of a Netdata sensor.""" def __init__( - self, netdata, name, sensor, sensor_name, element, icon, unit): + self, netdata, name, sensor, sensor_name, element, icon, unit, + invert): """Initialize the Netdata sensor.""" self.netdata = netdata self._state = None @@ -99,6 +103,7 @@ class NetdataSensor(Entity): self._name = name self._icon = icon self._unit_of_measurement = unit + self._invert = invert @property def name(self): @@ -130,7 +135,8 @@ class NetdataSensor(Entity): await self.netdata.async_update() resource_data = self.netdata.api.metrics.get(self._sensor) self._state = round( - resource_data['dimensions'][self._element]['value'], 2) + resource_data['dimensions'][self._element]['value'], 2) \ + * (-1 if self._invert else 1) class NetdataData: From e7c85d350ed80ad2b54883d4cc7b41bb3a75a6cc Mon Sep 17 00:00:00 2001 From: Marco Orovecchia Date: Mon, 11 Mar 2019 16:16:32 +0100 Subject: [PATCH 191/291] Changed from nanoleaf_aurora to nanoleaf (#21913) Nanoleaf component now supports both nanoleaf lights, Aurora and Canvas Changed the dependency to pynanoleaf, nanoleaf does not seem to be maintained anymore --- .coveragerc | 2 +- .../components/discovery/__init__.py | 2 +- .../light/{nanoleaf_aurora.py => nanoleaf.py} | 53 ++++++++++--------- requirements_all.txt | 6 +-- 4 files changed, 32 insertions(+), 31 deletions(-) rename homeassistant/components/light/{nanoleaf_aurora.py => nanoleaf.py} (80%) diff --git a/.coveragerc b/.coveragerc index fdf9c2962cc..b25d8e1f54b 100644 --- a/.coveragerc +++ b/.coveragerc @@ -238,7 +238,7 @@ omit = homeassistant/components/light/limitlessled.py homeassistant/components/light/lw12wifi.py homeassistant/components/light/mystrom.py - homeassistant/components/light/nanoleaf_aurora.py + homeassistant/components/light/nanoleaf.py homeassistant/components/light/niko_home_control.py homeassistant/components/light/opple.py homeassistant/components/light/osramlightify.py diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 1243804ecec..2f94cf48f4d 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -95,7 +95,7 @@ SERVICE_HANDLERS = { 'kodi': ('media_player', 'kodi'), 'volumio': ('media_player', 'volumio'), 'lg_smart_device': ('media_player', 'lg_soundbar'), - 'nanoleaf_aurora': ('light', 'nanoleaf_aurora'), + 'nanoleaf_aurora': ('light', 'nanoleaf'), } OPTIONAL_SERVICE_HANDLERS = { diff --git a/homeassistant/components/light/nanoleaf_aurora.py b/homeassistant/components/light/nanoleaf.py similarity index 80% rename from homeassistant/components/light/nanoleaf_aurora.py rename to homeassistant/components/light/nanoleaf.py index 6d9c1a50f79..571f4efc4ad 100644 --- a/homeassistant/components/light/nanoleaf_aurora.py +++ b/homeassistant/components/light/nanoleaf.py @@ -1,8 +1,8 @@ """ -Support for Nanoleaf Aurora platform. +Support for Nanoleaf Lights. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/light.nanoleaf_aurora/ +https://home-assistant.io/components/light.nanoleaf/ """ import logging @@ -19,20 +19,20 @@ 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'] +REQUIREMENTS = ['pynanoleaf==0.0.2'] _LOGGER = logging.getLogger(__name__) -DEFAULT_NAME = 'Aurora' +DEFAULT_NAME = 'Nanoleaf' -DATA_NANOLEAF_AURORA = 'nanoleaf_aurora' +DATA_NANOLEAF = 'nanoleaf' -CONFIG_FILE = '.nanoleaf_aurora.conf' +CONFIG_FILE = '.nanoleaf.conf' ICON = 'mdi:triangle-outline' -SUPPORT_AURORA = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT | - SUPPORT_COLOR) +SUPPORT_NANOLEAF = (SUPPORT_BRIGHTNESS | SUPPORT_COLOR_TEMP | SUPPORT_EFFECT | + SUPPORT_COLOR) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, @@ -42,20 +42,19 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the Nanoleaf Aurora device.""" - import nanoleaf - import nanoleaf.setup - if DATA_NANOLEAF_AURORA not in hass.data: - hass.data[DATA_NANOLEAF_AURORA] = dict() + """Set up the Nanoleaf light.""" + import pynanoleaf + if DATA_NANOLEAF not in hass.data: + hass.data[DATA_NANOLEAF] = 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]: + if host in hass.data[DATA_NANOLEAF]: return - _LOGGER.info("Discovered a new Aurora: %s", discovery_info) + _LOGGER.info("Discovered a new Nanoleaf: %s", discovery_info) conf = load_json(hass.config.path(CONFIG_FILE)) if conf.get(host, {}).get('token'): token = conf[host]['token'] @@ -64,8 +63,10 @@ def setup_platform(hass, config, add_entities, discovery_info=None): name = config[CONF_NAME] token = config[CONF_TOKEN] + nanoleaf_light = pynanoleaf.Nanoleaf(host) + if not token: - token = nanoleaf.setup.generate_auth_token(host) + token = nanoleaf_light.request_token() if not token: _LOGGER.error("Could not generate the auth token, did you press " "and hold the power button on %s" @@ -75,22 +76,22 @@ def setup_platform(hass, config, add_entities, discovery_info=None): conf[host] = {'token': token} save_json(hass.config.path(CONFIG_FILE), conf) - aurora_light = nanoleaf.Aurora(host, token) + nanoleaf_light.token = token - if aurora_light.on is None: + if nanoleaf_light.on is None: _LOGGER.error( - "Could not connect to Nanoleaf Aurora: %s on %s", name, host) + "Could not connect to Nanoleaf Light: %s on %s", name, host) return - hass.data[DATA_NANOLEAF_AURORA][host] = aurora_light - add_entities([AuroraLight(aurora_light, name)], True) + hass.data[DATA_NANOLEAF][host] = nanoleaf_light + add_entities([NanoleafLight(nanoleaf_light, name)], True) -class AuroraLight(Light): - """Representation of a Nanoleaf Aurora.""" +class NanoleafLight(Light): + """Representation of a Nanoleaf Light.""" def __init__(self, light, name): - """Initialize an Aurora light.""" + """Initialize an Nanoleaf light.""" self._brightness = None self._color_temp = None self._effect = None @@ -158,7 +159,7 @@ class AuroraLight(Light): @property def supported_features(self): """Flag supported features.""" - return SUPPORT_AURORA + return SUPPORT_NANOLEAF def turn_on(self, **kwargs): """Instruct the light to turn on.""" @@ -189,6 +190,6 @@ class AuroraLight(Light): self._brightness = self._light.brightness self._color_temp = self._light.color_temperature self._effect = self._light.effect - self._effects_list = self._light.effects_list + self._effects_list = self._light.effects self._hs_color = self._light.hue, self._light.saturation self._state = self._light.on diff --git a/requirements_all.txt b/requirements_all.txt index c574b02cbbc..8ebca5e61ef 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -725,9 +725,6 @@ myusps==1.3.2 # homeassistant.components.media_player.nad nad_receiver==0.0.11 -# homeassistant.components.light.nanoleaf_aurora -nanoleaf==0.4.1 - # homeassistant.components.device_tracker.keenetic_ndms2 ndms2_client==0.0.6 @@ -1170,6 +1167,9 @@ pymyq==1.1.0 # homeassistant.components.mysensors pymysensors==0.18.0 +# homeassistant.components.light.nanoleaf +pynanoleaf==0.0.2 + # homeassistant.components.lock.nello pynello==2.0.2 From 4f49bdf262fc54a9c32224c5dc57bdd4769f9e9d Mon Sep 17 00:00:00 2001 From: David McNett Date: Mon, 11 Mar 2019 10:17:57 -0500 Subject: [PATCH 192/291] Minor version bump for anthemav package (#21932) Additional Python 3.7 fixes in the anthemav package. See also: https://pypi.org/project/anthemav/ --- homeassistant/components/media_player/anthemav.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/media_player/anthemav.py b/homeassistant/components/media_player/anthemav.py index f867a10ccd0..36bc5ae10e1 100644 --- a/homeassistant/components/media_player/anthemav.py +++ b/homeassistant/components/media_player/anthemav.py @@ -18,7 +18,7 @@ from homeassistant.const import ( STATE_ON) import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['anthemav==1.1.9'] +REQUIREMENTS = ['anthemav==1.1.10'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 8ebca5e61ef..29e23e1e29d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -161,7 +161,7 @@ amcrest==1.2.5 anel_pwrctrl-homeassistant==0.0.1.dev2 # homeassistant.components.media_player.anthemav -anthemav==1.1.9 +anthemav==1.1.10 # homeassistant.components.apcupsd apcaccess==0.0.13 From 4f5446ff02abba3175712e6c6ff064da4a4cf3f6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 11 Mar 2019 11:02:37 -0700 Subject: [PATCH 193/291] Add area permission check (#21835) --- homeassistant/auth/auth_store.py | 7 +- homeassistant/auth/permissions/entities.py | 192 ++++++--------------- homeassistant/auth/permissions/models.py | 4 + homeassistant/auth/permissions/types.py | 6 +- homeassistant/auth/permissions/util.py | 98 +++++++++++ homeassistant/helpers/device_registry.py | 7 +- tests/auth/permissions/test_entities.py | 55 +++++- tests/auth/test_auth_store.py | 7 +- 8 files changed, 230 insertions(+), 146 deletions(-) create mode 100644 homeassistant/auth/permissions/util.py diff --git a/homeassistant/auth/auth_store.py b/homeassistant/auth/auth_store.py index 08ff2d7bb52..a64c14454a6 100644 --- a/homeassistant/auth/auth_store.py +++ b/homeassistant/auth/auth_store.py @@ -281,8 +281,9 @@ class AuthStore: async def _async_load_task(self) -> None: """Load the users.""" - [ent_reg, data] = await asyncio.gather( + [ent_reg, dev_reg, data] = await asyncio.gather( self.hass.helpers.entity_registry.async_get_registry(), + self.hass.helpers.device_registry.async_get_registry(), self._store.async_load(), ) @@ -291,7 +292,9 @@ class AuthStore: if self._users is not None: return - self._perm_lookup = perm_lookup = PermissionLookup(ent_reg) + self._perm_lookup = perm_lookup = PermissionLookup( + ent_reg, dev_reg + ) if data is None: self._set_defaults() diff --git a/homeassistant/auth/permissions/entities.py b/homeassistant/auth/permissions/entities.py index 0073c952648..3d7fc80307e 100644 --- a/homeassistant/auth/permissions/entities.py +++ b/homeassistant/auth/permissions/entities.py @@ -1,12 +1,14 @@ """Entity permissions.""" -from functools import wraps -from typing import Callable, List, Union # noqa: F401 +from collections import OrderedDict +from typing import Callable, Optional # noqa: F401 import voluptuous as vol from .const import SUBCAT_ALL, POLICY_READ, POLICY_CONTROL, POLICY_EDIT from .models import PermissionLookup -from .types import CategoryType, ValueType +from .types import CategoryType, SubCategoryDict, ValueType +# pylint: disable=unused-import +from .util import SubCatLookupType, lookup_all, compile_policy # noqa SINGLE_ENTITY_SCHEMA = vol.Any(True, vol.Schema({ vol.Optional(POLICY_READ): True, @@ -15,6 +17,7 @@ SINGLE_ENTITY_SCHEMA = vol.Any(True, vol.Schema({ })) ENTITY_DOMAINS = 'domains' +ENTITY_AREAS = 'area_ids' ENTITY_DEVICE_IDS = 'device_ids' ENTITY_ENTITY_IDS = 'entity_ids' @@ -24,148 +27,65 @@ ENTITY_VALUES_SCHEMA = vol.Any(True, vol.Schema({ ENTITY_POLICY_SCHEMA = vol.Any(True, vol.Schema({ vol.Optional(SUBCAT_ALL): SINGLE_ENTITY_SCHEMA, + vol.Optional(ENTITY_AREAS): ENTITY_VALUES_SCHEMA, vol.Optional(ENTITY_DEVICE_IDS): ENTITY_VALUES_SCHEMA, vol.Optional(ENTITY_DOMAINS): ENTITY_VALUES_SCHEMA, vol.Optional(ENTITY_ENTITY_IDS): ENTITY_VALUES_SCHEMA, })) -def _entity_allowed(schema: ValueType, key: str) \ - -> Union[bool, None]: - """Test if an entity is allowed based on the keys.""" - if schema is None or isinstance(schema, bool): - return schema - assert isinstance(schema, dict) - return schema.get(key) +def _lookup_domain(perm_lookup: PermissionLookup, + domains_dict: SubCategoryDict, + entity_id: str) -> Optional[ValueType]: + """Look up entity permissions by domain.""" + return domains_dict.get(entity_id.split(".", 1)[0]) + + +def _lookup_area(perm_lookup: PermissionLookup, area_dict: SubCategoryDict, + entity_id: str) -> Optional[ValueType]: + """Look up entity permissions by area.""" + entity_entry = perm_lookup.entity_registry.async_get(entity_id) + + if entity_entry is None or entity_entry.device_id is None: + return None + + device_entry = perm_lookup.device_registry.async_get( + entity_entry.device_id + ) + + if device_entry is None or device_entry.area_id is None: + return None + + return area_dict.get(device_entry.area_id) + + +def _lookup_device(perm_lookup: PermissionLookup, + devices_dict: SubCategoryDict, + entity_id: str) -> Optional[ValueType]: + """Look up entity permissions by device.""" + entity_entry = perm_lookup.entity_registry.async_get(entity_id) + + if entity_entry is None or entity_entry.device_id is None: + return None + + return devices_dict.get(entity_entry.device_id) + + +def _lookup_entity_id(perm_lookup: PermissionLookup, + entities_dict: SubCategoryDict, + entity_id: str) -> Optional[ValueType]: + """Look up entity permission by entity id.""" + return entities_dict.get(entity_id) def compile_entities(policy: CategoryType, perm_lookup: PermissionLookup) \ -> Callable[[str, str], bool]: """Compile policy into a function that tests policy.""" - # None, Empty Dict, False - if not policy: - def apply_policy_deny_all(entity_id: str, key: str) -> bool: - """Decline all.""" - return False + subcategories = OrderedDict() # type: SubCatLookupType + subcategories[ENTITY_ENTITY_IDS] = _lookup_entity_id + subcategories[ENTITY_DEVICE_IDS] = _lookup_device + subcategories[ENTITY_AREAS] = _lookup_area + subcategories[ENTITY_DOMAINS] = _lookup_domain + subcategories[SUBCAT_ALL] = lookup_all - return apply_policy_deny_all - - if policy is True: - def apply_policy_allow_all(entity_id: str, key: str) -> bool: - """Approve all.""" - return True - - return apply_policy_allow_all - - assert isinstance(policy, dict) - - domains = policy.get(ENTITY_DOMAINS) - device_ids = policy.get(ENTITY_DEVICE_IDS) - entity_ids = policy.get(ENTITY_ENTITY_IDS) - all_entities = policy.get(SUBCAT_ALL) - - funcs = [] # type: List[Callable[[str, str], Union[None, bool]]] - - # The order of these functions matter. The more precise are at the top. - # If a function returns None, they cannot handle it. - # If a function returns a boolean, that's the result to return. - - # Setting entity_ids to a boolean is final decision for permissions - # So return right away. - if isinstance(entity_ids, bool): - def allowed_entity_id_bool(entity_id: str, key: str) -> bool: - """Test if allowed entity_id.""" - return entity_ids # type: ignore - - return allowed_entity_id_bool - - if entity_ids is not None: - def allowed_entity_id_dict(entity_id: str, key: str) \ - -> Union[None, bool]: - """Test if allowed entity_id.""" - return _entity_allowed( - entity_ids.get(entity_id), key) # type: ignore - - funcs.append(allowed_entity_id_dict) - - if isinstance(device_ids, bool): - def allowed_device_id_bool(entity_id: str, key: str) \ - -> Union[None, bool]: - """Test if allowed device_id.""" - return device_ids - - funcs.append(allowed_device_id_bool) - - elif device_ids is not None: - def allowed_device_id_dict(entity_id: str, key: str) \ - -> Union[None, bool]: - """Test if allowed device_id.""" - entity_entry = perm_lookup.entity_registry.async_get(entity_id) - - if entity_entry is None or entity_entry.device_id is None: - return None - - return _entity_allowed( - device_ids.get(entity_entry.device_id), key # type: ignore - ) - - funcs.append(allowed_device_id_dict) - - if isinstance(domains, bool): - def allowed_domain_bool(entity_id: str, key: str) \ - -> Union[None, bool]: - """Test if allowed domain.""" - return domains - - funcs.append(allowed_domain_bool) - - elif domains is not None: - def allowed_domain_dict(entity_id: str, key: str) \ - -> Union[None, bool]: - """Test if allowed domain.""" - domain = entity_id.split(".", 1)[0] - return _entity_allowed(domains.get(domain), key) # type: ignore - - funcs.append(allowed_domain_dict) - - if isinstance(all_entities, bool): - def allowed_all_entities_bool(entity_id: str, key: str) \ - -> Union[None, bool]: - """Test if allowed domain.""" - return all_entities - funcs.append(allowed_all_entities_bool) - - elif all_entities is not None: - def allowed_all_entities_dict(entity_id: str, key: str) \ - -> Union[None, bool]: - """Test if allowed domain.""" - return _entity_allowed(all_entities, key) - funcs.append(allowed_all_entities_dict) - - # Can happen if no valid subcategories specified - if not funcs: - def apply_policy_deny_all_2(entity_id: str, key: str) -> bool: - """Decline all.""" - return False - - return apply_policy_deny_all_2 - - if len(funcs) == 1: - func = funcs[0] - - @wraps(func) - def apply_policy_func(entity_id: str, key: str) -> bool: - """Apply a single policy function.""" - return func(entity_id, key) is True - - return apply_policy_func - - def apply_policy_funcs(entity_id: str, key: str) -> bool: - """Apply several policy functions.""" - for func in funcs: - result = func(entity_id, key) - if result is not None: - return result - return False - - return apply_policy_funcs + return compile_policy(policy, subcategories, perm_lookup) diff --git a/homeassistant/auth/permissions/models.py b/homeassistant/auth/permissions/models.py index 7ad7d5521c5..10a76a4ec73 100644 --- a/homeassistant/auth/permissions/models.py +++ b/homeassistant/auth/permissions/models.py @@ -8,6 +8,9 @@ if TYPE_CHECKING: from homeassistant.helpers import ( # noqa entity_registry as ent_reg, ) + from homeassistant.helpers import ( # noqa + device_registry as dev_reg, + ) @attr.s(slots=True) @@ -15,3 +18,4 @@ class PermissionLookup: """Class to hold data for permission lookups.""" entity_registry = attr.ib(type='ent_reg.EntityRegistry') + device_registry = attr.ib(type='dev_reg.DeviceRegistry') diff --git a/homeassistant/auth/permissions/types.py b/homeassistant/auth/permissions/types.py index 78d13b9679f..5479e59dcb6 100644 --- a/homeassistant/auth/permissions/types.py +++ b/homeassistant/auth/permissions/types.py @@ -10,9 +10,11 @@ ValueType = Union[ None ] +# Example: entities.domains = { light: … } +SubCategoryDict = Mapping[str, ValueType] + SubCategoryType = Union[ - # Example: entities.domains = { light: … } - Mapping[str, ValueType], + SubCategoryDict, bool, None ] diff --git a/homeassistant/auth/permissions/util.py b/homeassistant/auth/permissions/util.py new file mode 100644 index 00000000000..d2d259fb32e --- /dev/null +++ b/homeassistant/auth/permissions/util.py @@ -0,0 +1,98 @@ +"""Helpers to deal with permissions.""" +from functools import wraps + +from typing import Callable, Dict, List, Optional, Union, cast # noqa: F401 + +from .models import PermissionLookup +from .types import CategoryType, SubCategoryDict, ValueType + +LookupFunc = Callable[[PermissionLookup, SubCategoryDict, str], + Optional[ValueType]] +SubCatLookupType = Dict[str, LookupFunc] + + +def lookup_all(perm_lookup: PermissionLookup, lookup_dict: SubCategoryDict, + object_id: str) -> ValueType: + """Look up permission for all.""" + # In case of ALL category, lookup_dict IS the schema. + return cast(ValueType, lookup_dict) + + +def compile_policy( + policy: CategoryType, subcategories: SubCatLookupType, + perm_lookup: PermissionLookup + ) -> Callable[[str, str], bool]: # noqa + """Compile policy into a function that tests policy. + Subcategories are mapping key -> lookup function, ordered by highest + priority first. + """ + # None, False, empty dict + if not policy: + def apply_policy_deny_all(entity_id: str, key: str) -> bool: + """Decline all.""" + return False + + return apply_policy_deny_all + + if policy is True: + def apply_policy_allow_all(entity_id: str, key: str) -> bool: + """Approve all.""" + return True + + return apply_policy_allow_all + + assert isinstance(policy, dict) + + funcs = [] # type: List[Callable[[str, str], Union[None, bool]]] + + for key, lookup_func in subcategories.items(): + lookup_value = policy.get(key) + + # If any lookup value is `True`, it will always be positive + if isinstance(lookup_value, bool): + return lambda object_id, key: True + + if lookup_value is not None: + funcs.append(_gen_dict_test_func( + perm_lookup, lookup_func, lookup_value)) + + if len(funcs) == 1: + func = funcs[0] + + @wraps(func) + def apply_policy_func(object_id: str, key: str) -> bool: + """Apply a single policy function.""" + return func(object_id, key) is True + + return apply_policy_func + + def apply_policy_funcs(object_id: str, key: str) -> bool: + """Apply several policy functions.""" + for func in funcs: + result = func(object_id, key) + if result is not None: + return result + return False + + return apply_policy_funcs + + +def _gen_dict_test_func( + perm_lookup: PermissionLookup, + lookup_func: LookupFunc, + lookup_dict: SubCategoryDict + ) -> Callable[[str, str], Optional[bool]]: # noqa + """Generate a lookup function.""" + def test_value(object_id: str, key: str) -> Optional[bool]: + """Test if permission is allowed based on the keys.""" + schema = lookup_func( + perm_lookup, lookup_dict, object_id) # type: ValueType + + if schema is None or isinstance(schema, bool): + return schema + + assert isinstance(schema, dict) + + return schema.get(key) + + return test_value diff --git a/homeassistant/helpers/device_registry.py b/homeassistant/helpers/device_registry.py index 9c8ee27d0d2..1ea6c400208 100644 --- a/homeassistant/helpers/device_registry.py +++ b/homeassistant/helpers/device_registry.py @@ -1,7 +1,7 @@ """Provide a way to connect entities belonging to one device.""" import logging import uuid -from typing import List +from typing import List, Optional from collections import OrderedDict @@ -71,6 +71,11 @@ class DeviceRegistry: self.devices = None self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) + @callback + def async_get(self, device_id: str) -> Optional[DeviceEntry]: + """Get device.""" + return self.devices.get(device_id) + @callback def async_get_device(self, identifiers: set, connections: set): """Check if device is registered.""" diff --git a/tests/auth/permissions/test_entities.py b/tests/auth/permissions/test_entities.py index 1fd70668f8b..119deac3311 100644 --- a/tests/auth/permissions/test_entities.py +++ b/tests/auth/permissions/test_entities.py @@ -6,8 +6,9 @@ from homeassistant.auth.permissions.entities import ( compile_entities, ENTITY_POLICY_SCHEMA) from homeassistant.auth.permissions.models import PermissionLookup from homeassistant.helpers.entity_registry import RegistryEntry +from homeassistant.helpers.device_registry import DeviceEntry -from tests.common import mock_registry +from tests.common import mock_registry, mock_device_registry def test_entities_none(): @@ -193,7 +194,7 @@ def test_entities_all_control(): def test_entities_device_id_boolean(hass): """Test entity ID policy applying control on device id.""" - registry = mock_registry(hass, { + entity_registry = mock_registry(hass, { 'test_domain.allowed': RegistryEntry( entity_id='test_domain.allowed', unique_id='1234', @@ -207,6 +208,7 @@ def test_entities_device_id_boolean(hass): device_id='mock-not-allowed-dev-id' ), }) + device_registry = mock_device_registry(hass) policy = { 'device_ids': { @@ -216,8 +218,55 @@ def test_entities_device_id_boolean(hass): } } ENTITY_POLICY_SCHEMA(policy) - compiled = compile_entities(policy, PermissionLookup(registry)) + compiled = compile_entities(policy, PermissionLookup( + entity_registry, device_registry + )) assert compiled('test_domain.allowed', 'read') is True assert compiled('test_domain.allowed', 'control') is False assert compiled('test_domain.not_allowed', 'read') is False assert compiled('test_domain.not_allowed', 'control') is False + + +def test_entities_areas_true(): + """Test entity ID policy for areas.""" + policy = { + 'area_ids': True + } + ENTITY_POLICY_SCHEMA(policy) + compiled = compile_entities(policy, None) + assert compiled('light.kitchen', 'read') is True + + +def test_entities_areas_area_true(hass): + """Test entity ID policy for areas with specific area.""" + entity_registry = mock_registry(hass, { + 'light.kitchen': RegistryEntry( + entity_id='light.kitchen', + unique_id='1234', + platform='test_platform', + device_id='mock-dev-id' + ), + }) + device_registry = mock_device_registry(hass, { + 'mock-dev-id': DeviceEntry( + id='mock-dev-id', + area_id='mock-area-id' + ) + }) + + policy = { + 'area_ids': { + 'mock-area-id': { + 'read': True, + 'control': True, + } + } + } + ENTITY_POLICY_SCHEMA(policy) + compiled = compile_entities(policy, PermissionLookup( + entity_registry, device_registry + )) + assert compiled('light.kitchen', 'read') is True + assert compiled('light.kitchen', 'control') is True + assert compiled('light.kitchen', 'edit') is False + assert compiled('switch.kitchen', 'read') is False diff --git a/tests/auth/test_auth_store.py b/tests/auth/test_auth_store.py index 136bc3d62ec..32c314b56d6 100644 --- a/tests/auth/test_auth_store.py +++ b/tests/auth/test_auth_store.py @@ -245,7 +245,9 @@ async def test_loading_race_condition(hass): store = auth_store.AuthStore(hass) with asynctest.patch( 'homeassistant.helpers.entity_registry.async_get_registry', - ) as mock_registry, asynctest.patch( + ) as mock_ent_registry, asynctest.patch( + 'homeassistant.helpers.device_registry.async_get_registry', + ) as mock_dev_registry, asynctest.patch( 'homeassistant.helpers.storage.Store.async_load', ) as mock_load: results = await asyncio.gather( @@ -253,6 +255,7 @@ async def test_loading_race_condition(hass): store.async_get_users(), ) - mock_registry.assert_called_once_with(hass) + mock_ent_registry.assert_called_once_with(hass) + mock_dev_registry.assert_called_once_with(hass) mock_load.assert_called_once_with() assert results[0] == results[1] From 5e2302e469809ebb38a31fd13df9cb3cd39650a8 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Mon, 11 Mar 2019 18:59:41 +0000 Subject: [PATCH 194/291] Add an asyncio Lock around pairing, which cant be used concurrently (#21933) --- .../components/homekit_controller/__init__.py | 53 ++++++++++++------- .../homekit_controller/alarm_control_panel.py | 20 +++---- .../components/homekit_controller/climate.py | 8 +-- .../components/homekit_controller/cover.py | 30 +++++------ .../components/homekit_controller/light.py | 8 +-- .../components/homekit_controller/lock.py | 12 ++--- .../components/homekit_controller/switch.py | 8 +-- 7 files changed, 76 insertions(+), 63 deletions(-) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 3e481db96da..072e323ecd1 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -1,4 +1,5 @@ """Support for Homekit device discovery.""" +import asyncio import json import logging import os @@ -74,6 +75,8 @@ class HKDevice(): self.configurator = hass.components.configurator self._connection_warning_logged = False + self.pairing_lock = asyncio.Lock(loop=hass.loop) + self.pairing = self.controller.pairings.get(hkid) if self.pairing is not None: @@ -168,6 +171,32 @@ class HKDevice(): 'name': 'HomeKit code', 'type': 'string'}]) + async def get_characteristics(self, *args, **kwargs): + """Read latest state from homekit accessory.""" + async with self.pairing_lock: + chars = await self.hass.async_add_executor_job( + self.pairing.get_characteristics, + *args, + **kwargs, + ) + return chars + + async def put_characteristics(self, characteristics): + """Control a HomeKit device state from Home Assistant.""" + chars = [] + for row in characteristics: + chars.append(( + row['aid'], + row['iid'], + row['value'], + )) + + async with self.pairing_lock: + await self.hass.async_add_executor_job( + self.pairing.put_characteristics, + chars + ) + class HomeKitEntity(Entity): """Representation of a Home Assistant HomeKit device.""" @@ -238,15 +267,15 @@ class HomeKitEntity(Entity): # pylint: disable=not-callable setup_fn(char) - def update(self): + async def async_update(self): """Obtain a HomeKit device's state.""" # pylint: disable=import-error from homekit.exceptions import AccessoryDisconnectedError - pairing = self._accessory.pairing - try: - new_values_dict = pairing.get_characteristics(self._chars_to_poll) + new_values_dict = await self._accessory.get_characteristics( + self._chars_to_poll + ) except AccessoryDisconnectedError: return @@ -280,22 +309,6 @@ class HomeKitEntity(Entity): """Define the homekit characteristics the entity cares about.""" raise NotImplementedError - def update_characteristics(self, characteristics): - """Synchronise a HomeKit device state with Home Assistant.""" - pass - - def put_characteristics(self, characteristics): - """Control a HomeKit device state from Home Assistant.""" - chars = [] - for row in characteristics: - chars.append(( - row['aid'], - row['iid'], - row['value'], - )) - - self._accessory.pairing.put_characteristics(chars) - def setup(hass, config): """Set up for Homekit devices.""" diff --git a/homeassistant/components/homekit_controller/alarm_control_panel.py b/homeassistant/components/homekit_controller/alarm_control_panel.py index 5d366b6e27b..61352c3bedc 100644 --- a/homeassistant/components/homekit_controller/alarm_control_panel.py +++ b/homeassistant/components/homekit_controller/alarm_control_panel.py @@ -74,28 +74,28 @@ class HomeKitAlarmControlPanel(HomeKitEntity, AlarmControlPanel): """Return the state of the device.""" return self._state - def alarm_disarm(self, code=None): + async def async_alarm_disarm(self, code=None): """Send disarm command.""" - self.set_alarm_state(STATE_ALARM_DISARMED, code) + await self.set_alarm_state(STATE_ALARM_DISARMED, code) - def alarm_arm_away(self, code=None): + async def async_alarm_arm_away(self, code=None): """Send arm command.""" - self.set_alarm_state(STATE_ALARM_ARMED_AWAY, code) + await self.set_alarm_state(STATE_ALARM_ARMED_AWAY, code) - def alarm_arm_home(self, code=None): + async def async_alarm_arm_home(self, code=None): """Send stay command.""" - self.set_alarm_state(STATE_ALARM_ARMED_HOME, code) + await self.set_alarm_state(STATE_ALARM_ARMED_HOME, code) - def alarm_arm_night(self, code=None): + async def async_alarm_arm_night(self, code=None): """Send night command.""" - self.set_alarm_state(STATE_ALARM_ARMED_NIGHT, code) + await self.set_alarm_state(STATE_ALARM_ARMED_NIGHT, code) - def set_alarm_state(self, state, code=None): + async def set_alarm_state(self, state, code=None): """Send state command.""" characteristics = [{'aid': self._aid, 'iid': self._chars['security-system-state.target'], 'value': TARGET_STATE_MAP[state]}] - self.put_characteristics(characteristics) + await self._accessory.put_characteristics(characteristics) @property def device_state_attributes(self): diff --git a/homeassistant/components/homekit_controller/climate.py b/homeassistant/components/homekit_controller/climate.py index ceadcd46b9d..8696d2b1f97 100644 --- a/homeassistant/components/homekit_controller/climate.py +++ b/homeassistant/components/homekit_controller/climate.py @@ -80,21 +80,21 @@ class HomeKitClimateDevice(HomeKitEntity, ClimateDevice): def _update_temperature_target(self, value): self._target_temp = value - def set_temperature(self, **kwargs): + async def async_set_temperature(self, **kwargs): """Set new target temperature.""" temp = kwargs.get(ATTR_TEMPERATURE) characteristics = [{'aid': self._aid, 'iid': self._chars['temperature.target'], 'value': temp}] - self.put_characteristics(characteristics) + await self._accessory.put_characteristics(characteristics) - def set_operation_mode(self, operation_mode): + async def async_set_operation_mode(self, operation_mode): """Set new target operation mode.""" characteristics = [{'aid': self._aid, 'iid': self._chars['heating-cooling.target'], 'value': MODE_HASS_TO_HOMEKIT[operation_mode]}] - self.put_characteristics(characteristics) + await self._accessory.put_characteristics(characteristics) @property def state(self): diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index 3951cf577d4..1426112094e 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -114,20 +114,20 @@ class HomeKitGarageDoorCover(HomeKitEntity, CoverDevice): """Return if the cover is opening or not.""" return self._state == STATE_OPENING - def open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs): """Send open command.""" - self.set_door_state(STATE_OPEN) + await self.set_door_state(STATE_OPEN) - def close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs): """Send close command.""" - self.set_door_state(STATE_CLOSED) + await self.set_door_state(STATE_CLOSED) - def set_door_state(self, state): + async def set_door_state(self, state): """Send state command.""" characteristics = [{'aid': self._aid, 'iid': self._chars['door-state.target'], 'value': TARGET_GARAGE_STATE_MAP[state]}] - self.put_characteristics(characteristics) + await self._accessory.put_characteristics(characteristics) @property def device_state_attributes(self): @@ -232,41 +232,41 @@ class HomeKitWindowCover(HomeKitEntity, CoverDevice): """Return if the cover is opening or not.""" return self._state == STATE_OPENING - def open_cover(self, **kwargs): + async def async_open_cover(self, **kwargs): """Send open command.""" - self.set_cover_position(position=100) + await self.async_set_cover_position(position=100) - def close_cover(self, **kwargs): + async def close_cover(self, **kwargs): """Send close command.""" - self.set_cover_position(position=0) + await self.async_set_cover_position(position=0) - def set_cover_position(self, **kwargs): + async def async_set_cover_position(self, **kwargs): """Send position command.""" position = kwargs[ATTR_POSITION] characteristics = [{'aid': self._aid, 'iid': self._chars['position.target'], 'value': position}] - self.put_characteristics(characteristics) + await self._accessory.put_characteristics(characteristics) @property def current_cover_tilt_position(self): """Return current position of cover tilt.""" return self._tilt_position - def set_cover_tilt_position(self, **kwargs): + async def async_set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" tilt_position = kwargs[ATTR_TILT_POSITION] if 'vertical-tilt.target' in self._chars: characteristics = [{'aid': self._aid, 'iid': self._chars['vertical-tilt.target'], 'value': tilt_position}] - self.put_characteristics(characteristics) + await self._accessory.put_characteristics(characteristics) elif 'horizontal-tilt.target' in self._chars: characteristics = [{'aid': self._aid, 'iid': self._chars['horizontal-tilt.target'], 'value': tilt_position}] - self.put_characteristics(characteristics) + await self._accessory.put_characteristics(characteristics) @property def device_state_attributes(self): diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py index f39e793c184..193d0326b75 100644 --- a/homeassistant/components/homekit_controller/light.py +++ b/homeassistant/components/homekit_controller/light.py @@ -101,7 +101,7 @@ class HomeKitLight(HomeKitEntity, Light): """Flag supported features.""" return self._features - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the specified light on.""" hs_color = kwargs.get(ATTR_HS_COLOR) temperature = kwargs.get(ATTR_COLOR_TEMP) @@ -127,11 +127,11 @@ class HomeKitLight(HomeKitEntity, Light): characteristics.append({'aid': self._aid, 'iid': self._chars['on'], 'value': True}) - self.put_characteristics(characteristics) + await self._accessory.put_characteristics(characteristics) - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the specified light off.""" characteristics = [{'aid': self._aid, 'iid': self._chars['on'], 'value': False}] - self.put_characteristics(characteristics) + await self._accessory.put_characteristics(characteristics) diff --git a/homeassistant/components/homekit_controller/lock.py b/homeassistant/components/homekit_controller/lock.py index 635d457198a..6da5fa35655 100644 --- a/homeassistant/components/homekit_controller/lock.py +++ b/homeassistant/components/homekit_controller/lock.py @@ -75,20 +75,20 @@ class HomeKitLock(HomeKitEntity, LockDevice): """Return True if entity is available.""" return self._state is not None - def lock(self, **kwargs): + async def async_lock(self, **kwargs): """Lock the device.""" - self._set_lock_state(STATE_LOCKED) + await self._set_lock_state(STATE_LOCKED) - def unlock(self, **kwargs): + async def async_unlock(self, **kwargs): """Unlock the device.""" - self._set_lock_state(STATE_UNLOCKED) + await self._set_lock_state(STATE_UNLOCKED) - def _set_lock_state(self, state): + async def _set_lock_state(self, state): """Send state command.""" characteristics = [{'aid': self._aid, 'iid': self._chars['lock-mechanism.target-state'], 'value': TARGET_STATE_MAP[state]}] - self.put_characteristics(characteristics) + await self._accessory.put_characteristics(characteristics) @property def device_state_attributes(self): diff --git a/homeassistant/components/homekit_controller/switch.py b/homeassistant/components/homekit_controller/switch.py index daa4ede6898..21f10e6243c 100644 --- a/homeassistant/components/homekit_controller/switch.py +++ b/homeassistant/components/homekit_controller/switch.py @@ -48,20 +48,20 @@ class HomeKitSwitch(HomeKitEntity, SwitchDevice): """Return true if device is on.""" return self._on - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): """Turn the specified switch on.""" self._on = True characteristics = [{'aid': self._aid, 'iid': self._chars['on'], 'value': True}] - self.put_characteristics(characteristics) + await self._accessory.put_characteristics(characteristics) - def turn_off(self, **kwargs): + async def async_turn_off(self, **kwargs): """Turn the specified switch off.""" characteristics = [{'aid': self._aid, 'iid': self._chars['on'], 'value': False}] - self.put_characteristics(characteristics) + await self._accessory.put_characteristics(characteristics) @property def device_state_attributes(self): From 8bfbe3e0852fac9b1cc79a65c08326e7f8cc0c88 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 11 Mar 2019 12:08:02 -0700 Subject: [PATCH 195/291] Add update user command (#21922) * Add update user command * Add is_admin to current user --- homeassistant/components/auth/__init__.py | 1 + homeassistant/components/config/auth.py | 35 ++++++++++++ tests/components/config/test_auth.py | 68 ++++++++++++++++++++++- 3 files changed, 102 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/auth/__init__.py b/homeassistant/components/auth/__init__.py index 8c0c17844f9..19edfe5a618 100644 --- a/homeassistant/components/auth/__init__.py +++ b/homeassistant/components/auth/__init__.py @@ -459,6 +459,7 @@ async def websocket_current_user( 'id': user.id, 'name': user.name, 'is_owner': user.is_owner, + 'is_admin': user.is_admin, '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/config/auth.py b/homeassistant/components/config/auth.py index 625dbefbbb3..e6451e09a98 100644 --- a/homeassistant/components/config/auth.py +++ b/homeassistant/components/config/auth.py @@ -36,6 +36,7 @@ async def async_setup(hass): WS_TYPE_CREATE, websocket_create, SCHEMA_WS_CREATE ) + hass.components.websocket_api.async_register_command(websocket_update) return True @@ -84,6 +85,40 @@ async def websocket_create(hass, connection, msg): })) +@websocket_api.require_admin +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required('type'): 'config/auth/update', + vol.Required('user_id'): str, + vol.Optional('name'): str, + vol.Optional('group_ids'): [str] +}) +async def websocket_update(hass, connection, msg): + """Update a user.""" + user = await hass.auth.async_get_user(msg.pop('user_id')) + + if not user: + connection.send_message(websocket_api.error_message( + msg['id'], websocket_api.const.ERR_NOT_FOUND, 'User not found')) + return + + if user.system_generated: + connection.send_message(websocket_api.error_message( + msg['id'], 'cannot_modify_system_generated', + 'Unable to update system generated users.')) + return + + msg.pop('type') + msg_id = msg.pop('id') + + await hass.auth.async_update_user(user, **msg) + + connection.send_message( + websocket_api.result_message(msg_id, { + 'user': _user_info(user), + })) + + def _user_info(user): """Format a user.""" return { diff --git a/tests/components/config/test_auth.py b/tests/components/config/test_auth.py index 5cc7b4bd82e..316740488e3 100644 --- a/tests/components/config/test_auth.py +++ b/tests/components/config/test_auth.py @@ -112,7 +112,7 @@ async def test_list(hass, hass_ws_client, hass_admin_user): async def test_delete_requires_admin(hass, hass_ws_client, hass_read_only_access_token): - """Test delete command requires an owner.""" + """Test delete command requires an admin.""" client = await hass_ws_client(hass, hass_read_only_access_token) await client.send_json({ @@ -205,7 +205,7 @@ async def test_create(hass, hass_ws_client, hass_access_token): async def test_create_requires_admin(hass, hass_ws_client, hass_read_only_access_token): - """Test create command requires an owner.""" + """Test create command requires an admin.""" client = await hass_ws_client(hass, hass_read_only_access_token) await client.send_json({ @@ -217,3 +217,67 @@ async def test_create_requires_admin(hass, hass_ws_client, result = await client.receive_json() assert not result['success'], result assert result['error']['code'] == 'unauthorized' + + +async def test_update(hass, hass_ws_client): + """Test update command works.""" + client = await hass_ws_client(hass) + + user = await hass.auth.async_create_user("Test user") + + await client.send_json({ + 'id': 5, + 'type': 'config/auth/update', + 'user_id': user.id, + 'name': 'Updated name', + 'group_ids': ['system-read-only'], + }) + + result = await client.receive_json() + assert result['success'], result + data_user = result['result']['user'] + + assert user.name == "Updated name" + assert data_user['name'] == "Updated name" + assert len(user.groups) == 1 + assert user.groups[0].id == "system-read-only" + assert data_user['group_ids'] == ["system-read-only"] + + +async def test_update_requires_admin(hass, hass_ws_client, + hass_read_only_access_token): + """Test update command requires an admin.""" + client = await hass_ws_client(hass, hass_read_only_access_token) + + user = await hass.auth.async_create_user("Test user") + + await client.send_json({ + 'id': 5, + 'type': 'config/auth/update', + 'user_id': user.id, + 'name': 'Updated name', + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'unauthorized' + assert user.name == "Test user" + + +async def test_update_system_generated(hass, hass_ws_client): + """Test update command cannot update a system generated.""" + client = await hass_ws_client(hass) + + user = await hass.auth.async_create_system_user("Test user") + + await client.send_json({ + 'id': 5, + 'type': 'config/auth/update', + 'user_id': user.id, + 'name': 'Updated name', + }) + + result = await client.receive_json() + assert not result['success'], result + assert result['error']['code'] == 'cannot_modify_system_generated' + assert user.name == "Test user" From 92ff49212b8baeb2f9f2249123db7ea377c48a12 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Mon, 11 Mar 2019 20:21:20 +0100 Subject: [PATCH 196/291] Offload Cloud component (#21937) * Offload Cloud component & Remote support * Make hound happy * Address comments --- homeassistant/components/cloud/__init__.py | 269 +++------- homeassistant/components/cloud/auth_api.py | 232 --------- homeassistant/components/cloud/client.py | 180 +++++++ homeassistant/components/cloud/cloud_api.py | 42 -- homeassistant/components/cloud/cloudhooks.py | 69 --- homeassistant/components/cloud/const.py | 43 +- homeassistant/components/cloud/http_api.py | 52 +- homeassistant/components/cloud/iot.py | 392 --------------- homeassistant/components/cloud/services.yaml | 7 + requirements_all.txt | 6 +- requirements_test_all.txt | 6 +- script/gen_requirements_all.py | 6 +- tests/components/cloud/__init__.py | 5 +- tests/components/cloud/conftest.py | 9 + tests/components/cloud/test_auth_api.py | 196 -------- tests/components/cloud/test_client.py | 199 ++++++++ tests/components/cloud/test_cloud_api.py | 33 -- tests/components/cloud/test_cloudhooks.py | 96 ---- tests/components/cloud/test_http_api.py | 184 +++---- tests/components/cloud/test_init.py | 290 ++--------- tests/components/cloud/test_iot.py | 500 ------------------- 21 files changed, 646 insertions(+), 2170 deletions(-) delete mode 100644 homeassistant/components/cloud/auth_api.py create mode 100644 homeassistant/components/cloud/client.py delete mode 100644 homeassistant/components/cloud/cloud_api.py delete mode 100644 homeassistant/components/cloud/cloudhooks.py delete mode 100644 homeassistant/components/cloud/iot.py create mode 100644 homeassistant/components/cloud/services.yaml delete mode 100644 tests/components/cloud/test_auth_api.py create mode 100644 tests/components/cloud/test_client.py delete mode 100644 tests/components/cloud/test_cloud_api.py delete mode 100644 tests/components/cloud/test_cloudhooks.py delete mode 100644 tests/components/cloud/test_iot.py diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 1d46eb91b86..55a6f1ac615 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -1,47 +1,38 @@ """Component to integrate the Home Assistant cloud.""" -from datetime import datetime, timedelta -import json import logging -import os import voluptuous as vol +from homeassistant.components.alexa import smart_home as alexa_sh +from homeassistant.components.google_assistant import const as ga_c +from homeassistant.const import ( + CONF_MODE, CONF_NAME, CONF_REGION, EVENT_HOMEASSISTANT_START, + EVENT_HOMEASSISTANT_STOP) from homeassistant.core import callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.const import ( - EVENT_HOMEASSISTANT_START, CLOUD_NEVER_EXPOSED_ENTITIES, CONF_REGION, - CONF_MODE, CONF_NAME) -from homeassistant.helpers import entityfilter, config_validation as cv +from homeassistant.helpers import config_validation as cv, entityfilter from homeassistant.loader import bind_hass -from homeassistant.util import dt as dt_util from homeassistant.util.aiohttp import MockRequest -from homeassistant.components.alexa import smart_home as alexa_sh -from homeassistant.components.google_assistant import helpers as ga_h -from homeassistant.components.google_assistant import const as ga_c -from . import http_api, iot, auth_api, prefs, cloudhooks -from .const import CONFIG_DIR, DOMAIN, SERVERS, STATE_CONNECTED +from . import http_api +from .const import ( + CONF_ACME_DIRECTORY_SERVER, CONF_ALEXA, CONF_ALIASES, + CONF_CLOUDHOOK_CREATE_URL, CONF_COGNITO_CLIENT_ID, CONF_ENTITY_CONFIG, + CONF_FILTER, CONF_GOOGLE_ACTIONS, CONF_GOOGLE_ACTIONS_SYNC_URL, + CONF_RELAYER, CONF_REMOTE_API_URL, CONF_SUBSCRIPTION_INFO_URL, + CONF_USER_POOL_ID, DOMAIN, MODE_DEV, MODE_PROD) +from .prefs import CloudPreferences -REQUIREMENTS = ['warrant==0.6.1'] +REQUIREMENTS = ['hass-nabucasa==0.3'] +DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) -CONF_ALEXA = 'alexa' -CONF_ALIASES = 'aliases' -CONF_COGNITO_CLIENT_ID = 'cognito_client_id' -CONF_ENTITY_CONFIG = 'entity_config' -CONF_FILTER = 'filter' -CONF_GOOGLE_ACTIONS = 'google_actions' -CONF_RELAYER = 'relayer' -CONF_USER_POOL_ID = 'user_pool_id' -CONF_GOOGLE_ACTIONS_SYNC_URL = 'google_actions_sync_url' -CONF_SUBSCRIPTION_INFO_URL = 'subscription_info_url' -CONF_CLOUDHOOK_CREATE_URL = 'cloudhook_create_url' +DEFAULT_MODE = MODE_PROD -DEFAULT_MODE = 'production' -DEPENDENCIES = ['http'] +SERVICE_REMOTE_CONNECT = 'remote_connect' +SERVICE_REMOTE_DISCONNECT = 'remote_disconnect' -MODE_DEV = 'development' ALEXA_ENTITY_SCHEMA = vol.Schema({ vol.Optional(alexa_sh.CONF_DESCRIPTION): cv.string, @@ -56,7 +47,7 @@ GOOGLE_ENTITY_SCHEMA = vol.Schema({ }) ASSISTANT_SCHEMA = vol.Schema({ - vol.Optional(CONF_FILTER, default={}): entityfilter.FILTER_SCHEMA, + vol.Optional(CONF_FILTER, default=dict): entityfilter.FILTER_SCHEMA, }) ALEXA_SCHEMA = ASSISTANT_SCHEMA.extend({ @@ -67,18 +58,21 @@ GACTIONS_SCHEMA = ASSISTANT_SCHEMA.extend({ vol.Optional(CONF_ENTITY_CONFIG): {cv.entity_id: GOOGLE_ENTITY_SCHEMA}, }) +# pylint: disable=no-value-for-parameter CONFIG_SCHEMA = vol.Schema({ DOMAIN: vol.Schema({ vol.Optional(CONF_MODE, default=DEFAULT_MODE): - vol.In([MODE_DEV] + list(SERVERS)), + vol.In([MODE_DEV, MODE_PROD]), # Change to optional when we include real servers vol.Optional(CONF_COGNITO_CLIENT_ID): str, vol.Optional(CONF_USER_POOL_ID): str, vol.Optional(CONF_REGION): str, vol.Optional(CONF_RELAYER): str, - vol.Optional(CONF_GOOGLE_ACTIONS_SYNC_URL): str, - vol.Optional(CONF_SUBSCRIPTION_INFO_URL): str, - vol.Optional(CONF_CLOUDHOOK_CREATE_URL): str, + vol.Optional(CONF_GOOGLE_ACTIONS_SYNC_URL): vol.Url(), + vol.Optional(CONF_SUBSCRIPTION_INFO_URL): vol.Url(), + vol.Optional(CONF_CLOUDHOOK_CREATE_URL): vol.Url(), + vol.Optional(CONF_REMOTE_API_URL): vol.Url(), + vol.Optional(CONF_ACME_DIRECTORY_SERVER): vol.Url(), vol.Optional(CONF_ALEXA): ALEXA_SCHEMA, vol.Optional(CONF_GOOGLE_ACTIONS): GACTIONS_SCHEMA, }), @@ -133,189 +127,48 @@ def is_cloudhook_request(request): async def async_setup(hass, config): """Initialize the Home Assistant cloud.""" + from hass_nabucasa import Cloud + from .client import CloudClient + + # Process configs if DOMAIN in config: kwargs = dict(config[DOMAIN]) else: kwargs = {CONF_MODE: DEFAULT_MODE} alexa_conf = kwargs.pop(CONF_ALEXA, None) or ALEXA_SCHEMA({}) + google_conf = kwargs.pop(CONF_GOOGLE_ACTIONS, None) or GACTIONS_SCHEMA({}) - if CONF_GOOGLE_ACTIONS not in kwargs: - kwargs[CONF_GOOGLE_ACTIONS] = GACTIONS_SCHEMA({}) + prefs = CloudPreferences(hass) + await prefs.async_initialize() - kwargs[CONF_ALEXA] = alexa_sh.Config( - endpoint=None, - async_get_access_token=None, - should_expose=alexa_conf[CONF_FILTER], - entity_config=alexa_conf.get(CONF_ENTITY_CONFIG), - ) + websession = hass.helpers.aiohttp_client.async_get_clientsession() + client = CloudClient(hass, prefs, websession, alexa_conf, google_conf) + cloud = hass.data[DOMAIN] = Cloud(client, **kwargs) + + async def _startup(event): + """Startup event.""" + await cloud.start() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, _startup) + + async def _shutdown(event): + """Shutdown event.""" + await cloud.stop() + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _shutdown) + + async def _service_handler(service): + """Handle service for cloud.""" + if service.service == SERVICE_REMOTE_CONNECT: + await cloud.remote.connect() + elif service.service == SERVICE_REMOTE_DISCONNECT: + await cloud.remote.disconnect() + + hass.services.async_register( + DOMAIN, SERVICE_REMOTE_CONNECT, _service_handler) + hass.services.async_register( + DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler) - cloud = hass.data[DOMAIN] = Cloud(hass, **kwargs) - await auth_api.async_setup(hass, cloud) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, cloud.async_start) await http_api.async_setup(hass) return True - - -class Cloud: - """Store the configuration of the cloud connection.""" - - def __init__(self, hass, mode, alexa, google_actions, - cognito_client_id=None, user_pool_id=None, region=None, - relayer=None, google_actions_sync_url=None, - subscription_info_url=None, cloudhook_create_url=None): - """Create an instance of Cloud.""" - self.hass = hass - self.mode = mode - self.alexa_config = alexa - self.google_actions_user_conf = google_actions - self._gactions_config = None - self.prefs = prefs.CloudPreferences(hass) - self.id_token = None - self.access_token = None - self.refresh_token = None - self.iot = iot.CloudIoT(self) - self.cloudhooks = cloudhooks.Cloudhooks(self) - - if mode == MODE_DEV: - self.cognito_client_id = cognito_client_id - self.user_pool_id = user_pool_id - self.region = region - self.relayer = relayer - self.google_actions_sync_url = google_actions_sync_url - self.subscription_info_url = subscription_info_url - self.cloudhook_create_url = cloudhook_create_url - - else: - info = SERVERS[mode] - - self.cognito_client_id = info['cognito_client_id'] - self.user_pool_id = info['user_pool_id'] - self.region = info['region'] - self.relayer = info['relayer'] - self.google_actions_sync_url = info['google_actions_sync_url'] - self.subscription_info_url = info['subscription_info_url'] - self.cloudhook_create_url = info['cloudhook_create_url'] - - @property - def is_logged_in(self): - """Get if cloud is logged in.""" - return self.id_token is not None - - @property - def is_connected(self): - """Get if cloud is connected.""" - return self.iot.state == STATE_CONNECTED - - @property - def subscription_expired(self): - """Return a boolean if the subscription has expired.""" - return dt_util.utcnow() > self.expiration_date + timedelta(days=7) - - @property - def expiration_date(self): - """Return the subscription expiration as a UTC datetime object.""" - return datetime.combine( - dt_util.parse_date(self.claims['custom:sub-exp']), - datetime.min.time()).replace(tzinfo=dt_util.UTC) - - @property - def claims(self): - """Return the claims from the id token.""" - return self._decode_claims(self.id_token) - - @property - def user_info_path(self): - """Get path to the stored auth.""" - return self.path('{}_auth.json'.format(self.mode)) - - @property - def gactions_config(self): - """Return the Google Assistant config.""" - if self._gactions_config is None: - conf = self.google_actions_user_conf - - def should_expose(entity): - """If an entity should be exposed.""" - if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: - return False - - return conf['filter'](entity.entity_id) - - self._gactions_config = ga_h.Config( - should_expose=should_expose, - allow_unlock=self.prefs.google_allow_unlock, - entity_config=conf.get(CONF_ENTITY_CONFIG), - ) - - return self._gactions_config - - def path(self, *parts): - """Get config path inside cloud dir. - - Async friendly. - """ - return self.hass.config.path(CONFIG_DIR, *parts) - - async def fetch_subscription_info(self): - """Fetch subscription info.""" - await self.hass.async_add_executor_job(auth_api.check_token, self) - websession = self.hass.helpers.aiohttp_client.async_get_clientsession() - return await websession.get( - self.subscription_info_url, headers={ - 'authorization': self.id_token - }) - - async def logout(self): - """Close connection and remove all credentials.""" - await self.iot.disconnect() - - self.id_token = None - self.access_token = None - self.refresh_token = None - self._gactions_config = None - - await self.hass.async_add_job( - lambda: os.remove(self.user_info_path)) - - def write_user_info(self): - """Write user info to a file.""" - with open(self.user_info_path, 'wt') as file: - file.write(json.dumps({ - 'id_token': self.id_token, - 'access_token': self.access_token, - 'refresh_token': self.refresh_token, - }, indent=4)) - - async def async_start(self, _): - """Start the cloud component.""" - def load_config(): - """Load config.""" - # Ensure config dir exists - path = self.hass.config.path(CONFIG_DIR) - if not os.path.isdir(path): - os.mkdir(path) - - user_info = self.user_info_path - if not os.path.isfile(user_info): - return None - - with open(user_info, 'rt') as file: - return json.loads(file.read()) - - info = await self.hass.async_add_job(load_config) - await self.prefs.async_initialize() - - if info is None: - return - - self.id_token = info['id_token'] - self.access_token = info['access_token'] - self.refresh_token = info['refresh_token'] - - self.hass.async_create_task(self.iot.connect()) - - def _decode_claims(self, token): # pylint: disable=no-self-use - """Decode the claims in a token.""" - from jose import jwt - return jwt.get_unverified_claims(token) diff --git a/homeassistant/components/cloud/auth_api.py b/homeassistant/components/cloud/auth_api.py deleted file mode 100644 index 6019dac87b9..00000000000 --- a/homeassistant/components/cloud/auth_api.py +++ /dev/null @@ -1,232 +0,0 @@ -"""Package to communicate with the authentication API.""" -import asyncio -import logging -import random - - -_LOGGER = logging.getLogger(__name__) - - -class CloudError(Exception): - """Base class for cloud related errors.""" - - -class Unauthenticated(CloudError): - """Raised when authentication failed.""" - - -class UserNotFound(CloudError): - """Raised when a user is not found.""" - - -class UserNotConfirmed(CloudError): - """Raised when a user has not confirmed email yet.""" - - -class PasswordChangeRequired(CloudError): - """Raised when a password change is required.""" - - # https://github.com/PyCQA/pylint/issues/1085 - # pylint: disable=useless-super-delegation - def __init__(self, message='Password change required.'): - """Initialize a password change required error.""" - super().__init__(message) - - -class UnknownError(CloudError): - """Raised when an unknown error occurs.""" - - -AWS_EXCEPTIONS = { - 'UserNotFoundException': UserNotFound, - 'NotAuthorizedException': Unauthenticated, - 'UserNotConfirmedException': UserNotConfirmed, - 'PasswordResetRequiredException': PasswordChangeRequired, -} - - -async def async_setup(hass, cloud): - """Configure the auth api.""" - refresh_task = None - - async def handle_token_refresh(): - """Handle Cloud access token refresh.""" - sleep_time = 5 - sleep_time = random.randint(2400, 3600) - while True: - try: - await asyncio.sleep(sleep_time) - await hass.async_add_executor_job(renew_access_token, cloud) - except CloudError as err: - _LOGGER.error("Can't refresh cloud token: %s", err) - except asyncio.CancelledError: - # Task is canceled, stop it. - break - - sleep_time = random.randint(3100, 3600) - - async def on_connect(): - """When the instance is connected.""" - nonlocal refresh_task - refresh_task = hass.async_create_task(handle_token_refresh()) - - async def on_disconnect(): - """When the instance is disconnected.""" - nonlocal refresh_task - refresh_task.cancel() - - cloud.iot.register_on_connect(on_connect) - cloud.iot.register_on_disconnect(on_disconnect) - - -def _map_aws_exception(err): - """Map AWS exception to our exceptions.""" - ex = AWS_EXCEPTIONS.get(err.response['Error']['Code'], UnknownError) - return ex(err.response['Error']['Message']) - - -def register(cloud, email, password): - """Register a new account.""" - from botocore.exceptions import ClientError, EndpointConnectionError - - cognito = _cognito(cloud) - # Workaround for bug in Warrant. PR with fix: - # https://github.com/capless/warrant/pull/82 - cognito.add_base_attributes() - try: - cognito.register(email, password) - - except ClientError as err: - raise _map_aws_exception(err) - except EndpointConnectionError: - raise UnknownError() - - -def resend_email_confirm(cloud, email): - """Resend email confirmation.""" - from botocore.exceptions import ClientError, EndpointConnectionError - - cognito = _cognito(cloud, username=email) - - try: - cognito.client.resend_confirmation_code( - Username=email, - ClientId=cognito.client_id - ) - except ClientError as err: - raise _map_aws_exception(err) - except EndpointConnectionError: - raise UnknownError() - - -def forgot_password(cloud, email): - """Initialize forgotten password flow.""" - from botocore.exceptions import ClientError, EndpointConnectionError - - cognito = _cognito(cloud, username=email) - - try: - cognito.initiate_forgot_password() - - except ClientError as err: - raise _map_aws_exception(err) - except EndpointConnectionError: - raise UnknownError() - - -def login(cloud, email, password): - """Log user in and fetch certificate.""" - cognito = _authenticate(cloud, email, password) - cloud.id_token = cognito.id_token - cloud.access_token = cognito.access_token - cloud.refresh_token = cognito.refresh_token - cloud.write_user_info() - - -def check_token(cloud): - """Check that the token is valid and verify if needed.""" - from botocore.exceptions import ClientError, EndpointConnectionError - - cognito = _cognito( - cloud, - access_token=cloud.access_token, - refresh_token=cloud.refresh_token) - - try: - if cognito.check_token(): - cloud.id_token = cognito.id_token - cloud.access_token = cognito.access_token - cloud.write_user_info() - - except ClientError as err: - raise _map_aws_exception(err) - - except EndpointConnectionError: - raise UnknownError() - - -def renew_access_token(cloud): - """Renew access token.""" - from botocore.exceptions import ClientError, EndpointConnectionError - - cognito = _cognito( - cloud, - access_token=cloud.access_token, - refresh_token=cloud.refresh_token) - - try: - cognito.renew_access_token() - cloud.id_token = cognito.id_token - cloud.access_token = cognito.access_token - cloud.write_user_info() - - except ClientError as err: - raise _map_aws_exception(err) - - except EndpointConnectionError: - raise UnknownError() - - -def _authenticate(cloud, email, password): - """Log in and return an authenticated Cognito instance.""" - from botocore.exceptions import ClientError, EndpointConnectionError - from warrant.exceptions import ForceChangePasswordException - - assert not cloud.is_logged_in, 'Cannot login if already logged in.' - - cognito = _cognito(cloud, username=email) - - try: - cognito.authenticate(password=password) - return cognito - - except ForceChangePasswordException: - raise PasswordChangeRequired() - - except ClientError as err: - raise _map_aws_exception(err) - - except EndpointConnectionError: - raise UnknownError() - - -def _cognito(cloud, **kwargs): - """Get the client credentials.""" - import botocore - import boto3 - from warrant import Cognito - - cognito = Cognito( - user_pool_id=cloud.user_pool_id, - client_id=cloud.cognito_client_id, - user_pool_region=cloud.region, - **kwargs - ) - cognito.client = boto3.client( - 'cognito-idp', - region_name=cloud.region, - config=botocore.config.Config( - signature_version=botocore.UNSIGNED - ) - ) - return cognito diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py new file mode 100644 index 00000000000..c1165091e11 --- /dev/null +++ b/homeassistant/components/cloud/client.py @@ -0,0 +1,180 @@ +"""Interface implementation for cloud client.""" +import asyncio +from pathlib import Path +from typing import Any, Dict + +import aiohttp +from hass_nabucasa.client import CloudClient as Interface + +from homeassistant.components.alexa import smart_home as alexa_sh +from homeassistant.components.google_assistant import ( + helpers as ga_h, smart_home as ga) +from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES +from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.util.aiohttp import MockRequest + +from . import utils +from .const import CONF_ENTITY_CONFIG, CONF_FILTER, DOMAIN +from .prefs import CloudPreferences + + +class CloudClient(Interface): + """Interface class for Home Assistant Cloud.""" + + def __init__(self, hass: HomeAssistantType, prefs: CloudPreferences, + websession: aiohttp.ClientSession, + alexa_config: Dict[str, Any], google_config: Dict[str, Any]): + """Initialize client interface to Cloud.""" + self._hass = hass + self._prefs = prefs + self._websession = websession + self._alexa_user_config = alexa_config + self._google_user_config = google_config + + self._alexa_config = None + self._google_config = None + + @property + def base_path(self) -> Path: + """Return path to base dir.""" + return Path(self._hass.config.config_dir) + + @property + def prefs(self) -> CloudPreferences: + """Return Cloud preferences.""" + return self._prefs + + @property + def loop(self) -> asyncio.BaseEventLoop: + """Return client loop.""" + return self._hass.loop + + @property + def websession(self) -> aiohttp.ClientSession: + """Return client session for aiohttp.""" + return self._websession + + @property + def aiohttp_runner(self) -> aiohttp.web.AppRunner: + """Return client webinterface aiohttp application.""" + return self._hass.http.runner + + @property + def cloudhooks(self) -> Dict[str, Dict[str, str]]: + """Return list of cloudhooks.""" + return self._prefs.cloudhooks + + @property + def alexa_config(self) -> alexa_sh.Config: + """Return Alexa config.""" + if not self._alexa_config: + alexa_conf = self._alexa_user_config + + self._alexa_config = alexa_sh.Config( + endpoint=None, + async_get_access_token=None, + should_expose=alexa_conf[CONF_FILTER], + entity_config=alexa_conf.get(CONF_ENTITY_CONFIG), + ) + + return self._alexa_config + + @property + def google_config(self) -> ga_h.Config: + """Return Google config.""" + if not self._google_config: + google_conf = self._google_user_config + + def should_expose(entity): + """If an entity should be exposed.""" + if entity.entity_id in CLOUD_NEVER_EXPOSED_ENTITIES: + return False + + return google_conf['filter'](entity.entity_id) + + self._google_config = ga_h.Config( + should_expose=should_expose, + allow_unlock=self._prefs.google_allow_unlock, + entity_config=google_conf.get(CONF_ENTITY_CONFIG), + ) + + return self._google_config + + @property + def google_user_config(self) -> Dict[str, Any]: + """Return google action user config.""" + return self._google_user_config + + async def cleanups(self) -> None: + """Cleanup some stuff after logout.""" + self._alexa_config = None + self._google_config = None + + async def async_user_message( + self, identifier: str, title: str, message: str) -> None: + """Create a message for user to UI.""" + self._hass.components.persistent_notification.async_create( + message, title, identifier + ) + + async def async_alexa_message( + self, payload: Dict[Any, Any]) -> Dict[Any, Any]: + """Process cloud alexa message to client.""" + return await alexa_sh.async_handle_message( + self._hass, self.alexa_config, payload, + enabled=self._prefs.alexa_enabled + ) + + async def async_google_message( + self, payload: Dict[Any, Any]) -> Dict[Any, Any]: + """Process cloud google message to client.""" + if not self._prefs.google_enabled: + return ga.turned_off_response(payload) + + cloud = self._hass.data[DOMAIN] + return await ga.async_handle_message( + self._hass, self.google_config, + cloud.claims['cognito:username'], payload + ) + + async def async_webhook_message( + self, payload: Dict[Any, Any]) -> Dict[Any, Any]: + """Process cloud webhook message to client.""" + cloudhook_id = payload['cloudhook_id'] + + found = None + for cloudhook in self._prefs.cloudhooks.values(): + if cloudhook['cloudhook_id'] == cloudhook_id: + found = cloudhook + break + + if found is None: + return { + 'status': 200 + } + + request = MockRequest( + content=payload['body'].encode('utf-8'), + headers=payload['headers'], + method=payload['method'], + query_string=payload['query'], + ) + + response = await self._hass.components.webhook.async_handle_webhook( + found['webhook_id'], request) + + response_dict = utils.aiohttp_serialize_response(response) + body = response_dict.get('body') + + return { + 'body': body, + 'status': response_dict['status'], + 'headers': { + 'Content-Type': response.content_type + } + } + + async def async_cloudhooks_update( + self, data: Dict[str, Dict[str, str]]) -> None: + """Update local list of cloudhooks.""" + await self._prefs.async_update(cloudhooks=data) diff --git a/homeassistant/components/cloud/cloud_api.py b/homeassistant/components/cloud/cloud_api.py deleted file mode 100644 index c62768cc514..00000000000 --- a/homeassistant/components/cloud/cloud_api.py +++ /dev/null @@ -1,42 +0,0 @@ -"""Cloud APIs.""" -from functools import wraps -import logging - -from . import auth_api - -_LOGGER = logging.getLogger(__name__) - - -def _check_token(func): - """Decorate a function to verify valid token.""" - @wraps(func) - async def check_token(cloud, *args): - """Validate token, then call func.""" - await cloud.hass.async_add_executor_job(auth_api.check_token, cloud) - return await func(cloud, *args) - - return check_token - - -def _log_response(func): - """Decorate a function to log bad responses.""" - @wraps(func) - async def log_response(*args): - """Log response if it's bad.""" - resp = await func(*args) - meth = _LOGGER.debug if resp.status < 400 else _LOGGER.warning - meth('Fetched %s (%s)', resp.url, resp.status) - return resp - - return log_response - - -@_check_token -@_log_response -async def async_create_cloudhook(cloud): - """Create a cloudhook.""" - websession = cloud.hass.helpers.aiohttp_client.async_get_clientsession() - return await websession.post( - cloud.cloudhook_create_url, headers={ - 'authorization': cloud.id_token - }) diff --git a/homeassistant/components/cloud/cloudhooks.py b/homeassistant/components/cloud/cloudhooks.py deleted file mode 100644 index 1bec3cb4b01..00000000000 --- a/homeassistant/components/cloud/cloudhooks.py +++ /dev/null @@ -1,69 +0,0 @@ -"""Manage cloud cloudhooks.""" -import async_timeout - -from . import cloud_api - - -class Cloudhooks: - """Class to help manage cloudhooks.""" - - def __init__(self, cloud): - """Initialize cloudhooks.""" - self.cloud = cloud - self.cloud.iot.register_on_connect(self.async_publish_cloudhooks) - - async def async_publish_cloudhooks(self): - """Inform the Relayer of the cloudhooks that we support.""" - if not self.cloud.is_connected: - return - - cloudhooks = self.cloud.prefs.cloudhooks - await self.cloud.iot.async_send_message('webhook-register', { - 'cloudhook_ids': [info['cloudhook_id'] for info - in cloudhooks.values()] - }, expect_answer=False) - - async def async_create(self, webhook_id): - """Create a cloud webhook.""" - cloudhooks = self.cloud.prefs.cloudhooks - - if webhook_id in cloudhooks: - raise ValueError('Hook is already enabled for the cloud.') - - if not self.cloud.iot.connected: - raise ValueError("Cloud is not connected") - - # Create cloud hook - with async_timeout.timeout(10): - resp = await cloud_api.async_create_cloudhook(self.cloud) - - data = await resp.json() - cloudhook_id = data['cloudhook_id'] - cloudhook_url = data['url'] - - # Store hook - cloudhooks = dict(cloudhooks) - hook = cloudhooks[webhook_id] = { - 'webhook_id': webhook_id, - 'cloudhook_id': cloudhook_id, - 'cloudhook_url': cloudhook_url - } - await self.cloud.prefs.async_update(cloudhooks=cloudhooks) - - await self.async_publish_cloudhooks() - - return hook - - async def async_delete(self, webhook_id): - """Delete a cloud webhook.""" - cloudhooks = self.cloud.prefs.cloudhooks - - if webhook_id not in cloudhooks: - raise ValueError('Hook is not enabled for the cloud.') - - # Remove hook - cloudhooks = dict(cloudhooks) - cloudhooks.pop(webhook_id) - await self.cloud.prefs.async_update(cloudhooks=cloudhooks) - - await self.async_publish_cloudhooks() diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 192ccd8ac67..642672f537c 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -1,6 +1,5 @@ """Constants for the cloud component.""" DOMAIN = 'cloud' -CONFIG_DIR = '.cloud' REQUEST_TIMEOUT = 10 PREF_ENABLE_ALEXA = 'alexa_enabled' @@ -8,31 +7,19 @@ PREF_ENABLE_GOOGLE = 'google_enabled' PREF_GOOGLE_ALLOW_UNLOCK = 'google_allow_unlock' PREF_CLOUDHOOKS = 'cloudhooks' -SERVERS = { - 'production': { - 'cognito_client_id': '60i2uvhvbiref2mftj7rgcrt9u', - 'user_pool_id': 'us-east-1_87ll5WOP8', - 'region': 'us-east-1', - 'relayer': 'wss://cloud.hass.io:8000/websocket', - 'google_actions_sync_url': ('https://24ab3v80xd.execute-api.us-east-1.' - 'amazonaws.com/prod/smart_home_sync'), - 'subscription_info_url': ('https://stripe-api.nabucasa.com/payments/' - 'subscription_info'), - 'cloudhook_create_url': 'https://webhooks-api.nabucasa.com/generate' - } -} +CONF_ALEXA = 'alexa' +CONF_ALIASES = 'aliases' +CONF_COGNITO_CLIENT_ID = 'cognito_client_id' +CONF_ENTITY_CONFIG = 'entity_config' +CONF_FILTER = 'filter' +CONF_GOOGLE_ACTIONS = 'google_actions' +CONF_RELAYER = 'relayer' +CONF_USER_POOL_ID = 'user_pool_id' +CONF_GOOGLE_ACTIONS_SYNC_URL = 'google_actions_sync_url' +CONF_SUBSCRIPTION_INFO_URL = 'subscription_info_url' +CONF_CLOUDHOOK_CREATE_URL = 'cloudhook_create_url' +CONF_REMOTE_API_URL = 'remote_api_url' +CONF_ACME_DIRECTORY_SERVER = 'acme_directory_server' -MESSAGE_EXPIRATION = """ -It looks like your Home Assistant Cloud subscription has expired. Please check -your [account page](/config/cloud/account) to continue using the service. -""" - -MESSAGE_AUTH_FAIL = """ -You have been logged out of Home Assistant Cloud because we have been unable -to verify your credentials. Please [log in](/config/cloud) again to continue -using the service. -""" - -STATE_CONNECTING = 'connecting' -STATE_CONNECTED = 'connected' -STATE_DISCONNECTED = 'disconnected' +MODE_DEV = "development" +MODE_PROD = "production" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index a2825eb6d7b..dd8d740f234 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -15,11 +15,9 @@ from homeassistant.components import websocket_api from homeassistant.components.alexa import smart_home as alexa_sh from homeassistant.components.google_assistant import smart_home as google_sh -from . import auth_api from .const import ( DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_GOOGLE_ALLOW_UNLOCK) -from .iot import STATE_DISCONNECTED, STATE_CONNECTED _LOGGER = logging.getLogger(__name__) @@ -59,6 +57,9 @@ SCHEMA_WS_HOOK_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ }) +_CLOUD_ERRORS = {} + + async def async_setup(hass): """Initialize the HTTP API.""" hass.components.websocket_api.async_register_command( @@ -88,14 +89,20 @@ async def async_setup(hass): hass.http.register_view(CloudResendConfirmView) hass.http.register_view(CloudForgotPasswordView) + from hass_nabucasa import auth -_CLOUD_ERRORS = { - auth_api.UserNotFound: (400, "User does not exist."), - auth_api.UserNotConfirmed: (400, 'Email not confirmed.'), - auth_api.Unauthenticated: (401, 'Authentication failed.'), - auth_api.PasswordChangeRequired: (400, 'Password change required.'), - asyncio.TimeoutError: (502, 'Unable to reach the Home Assistant cloud.') -} + _CLOUD_ERRORS.update({ + auth.UserNotFound: + (400, "User does not exist."), + auth.UserNotConfirmed: + (400, 'Email not confirmed.'), + auth.Unauthenticated: + (401, 'Authentication failed.'), + auth.PasswordChangeRequired: + (400, 'Password change required.'), + asyncio.TimeoutError: + (502, 'Unable to reach the Home Assistant cloud.') + }) def _handle_cloud_errors(handler): @@ -135,7 +142,7 @@ class GoogleActionsSyncView(HomeAssistantView): websession = hass.helpers.aiohttp_client.async_get_clientsession() with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - await hass.async_add_job(auth_api.check_token, cloud) + await hass.async_add_job(cloud.auth.check_token) with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): req = await websession.post( @@ -163,7 +170,7 @@ class CloudLoginView(HomeAssistantView): cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): - await hass.async_add_job(auth_api.login, cloud, data['email'], + await hass.async_add_job(cloud.auth.login, data['email'], data['password']) hass.async_add_job(cloud.iot.connect) @@ -206,7 +213,7 @@ class CloudRegisterView(HomeAssistantView): with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): await hass.async_add_job( - auth_api.register, cloud, data['email'], data['password']) + cloud.auth.register, data['email'], data['password']) return self.json_message('ok') @@ -228,7 +235,7 @@ class CloudResendConfirmView(HomeAssistantView): with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): await hass.async_add_job( - auth_api.resend_email_confirm, cloud, data['email']) + cloud.auth.resend_email_confirm, data['email']) return self.json_message('ok') @@ -250,7 +257,7 @@ class CloudForgotPasswordView(HomeAssistantView): with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): await hass.async_add_job( - auth_api.forgot_password, cloud, data['email']) + cloud.auth.forgot_password, data['email']) return self.json_message('ok') @@ -307,6 +314,7 @@ def _handle_aiohttp_errors(handler): @websocket_api.async_response async def websocket_subscription(hass, connection, msg): """Handle request for account info.""" + from hass_nabucasa.const import STATE_DISCONNECTED cloud = hass.data[DOMAIN] with async_timeout.timeout(REQUEST_TIMEOUT, loop=hass.loop): @@ -320,11 +328,10 @@ async def websocket_subscription(hass, connection, msg): # Check if a user is subscribed but local info is outdated # In that case, let's refresh and reconnect - if data.get('provider') and cloud.iot.state != STATE_CONNECTED: + if data.get('provider') and not cloud.is_connected: _LOGGER.debug( "Found disconnected account with valid subscriotion, connecting") - await hass.async_add_executor_job( - auth_api.renew_access_token, cloud) + await hass.async_add_executor_job(cloud.auth.renew_access_token) # Cancel reconnect in progress if cloud.iot.state != STATE_DISCONNECTED: @@ -344,7 +351,7 @@ async def websocket_update_prefs(hass, connection, msg): changes = dict(msg) changes.pop('id') changes.pop('type') - await cloud.prefs.async_update(**changes) + await cloud.client.prefs.async_update(**changes) connection.send_message(websocket_api.result_message(msg['id'])) @@ -370,6 +377,8 @@ async def websocket_hook_delete(hass, connection, msg): def _account_data(cloud): """Generate the auth data JSON response.""" + from hass_nabucasa.const import STATE_DISCONNECTED + if not cloud.is_logged_in: return { 'logged_in': False, @@ -377,14 +386,15 @@ def _account_data(cloud): } claims = cloud.claims + client = cloud.client return { 'logged_in': True, 'email': claims['email'], 'cloud': cloud.iot.state, - 'prefs': cloud.prefs.as_dict(), - 'google_entities': cloud.google_actions_user_conf['filter'].config, + 'prefs': client.prefs.as_dict(), + 'google_entities': client.google_user_config['filter'].config, 'google_domains': list(google_sh.DOMAIN_TO_GOOGLE_TYPES), - 'alexa_entities': cloud.alexa_config.should_expose.config, + 'alexa_entities': client.alexa_config.should_expose.config, 'alexa_domains': list(alexa_sh.ENTITY_ADAPTERS), } diff --git a/homeassistant/components/cloud/iot.py b/homeassistant/components/cloud/iot.py deleted file mode 100644 index 76999e703fe..00000000000 --- a/homeassistant/components/cloud/iot.py +++ /dev/null @@ -1,392 +0,0 @@ -"""Module to handle messages from Home Assistant cloud.""" -import asyncio -import logging -import pprint -import random -import uuid - -from aiohttp import hdrs, client_exceptions, WSMsgType - -from homeassistant.const import EVENT_HOMEASSISTANT_STOP -from homeassistant.components.alexa import smart_home as alexa -from homeassistant.components.google_assistant import smart_home as ga -from homeassistant.core import callback -from homeassistant.util.decorator import Registry -from homeassistant.util.aiohttp import MockRequest -from homeassistant.helpers.aiohttp_client import async_get_clientsession -from . import auth_api -from . import utils -from .const import ( - MESSAGE_EXPIRATION, MESSAGE_AUTH_FAIL, STATE_CONNECTED, STATE_CONNECTING, - STATE_DISCONNECTED -) - -HANDLERS = Registry() -_LOGGER = logging.getLogger(__name__) - - -class UnknownHandler(Exception): - """Exception raised when trying to handle unknown handler.""" - - -class NotConnected(Exception): - """Exception raised when trying to handle unknown handler.""" - - -class ErrorMessage(Exception): - """Exception raised when there was error handling message in the cloud.""" - - def __init__(self, error): - """Initialize Error Message.""" - super().__init__(self, "Error in Cloud") - self.error = error - - -class CloudIoT: - """Class to manage the IoT connection.""" - - def __init__(self, cloud): - """Initialize the CloudIoT class.""" - self.cloud = cloud - # The WebSocket client - self.client = None - # Scheduled sleep task till next connection retry - self.retry_task = None - # Boolean to indicate if we wanted the connection to close - self.close_requested = False - # The current number of attempts to connect, impacts wait time - self.tries = 0 - # Current state of the connection - self.state = STATE_DISCONNECTED - # Local code waiting for a response - self._response_handler = {} - self._on_connect = [] - self._on_disconnect = [] - - @callback - def register_on_connect(self, on_connect_cb): - """Register an async on_connect callback.""" - self._on_connect.append(on_connect_cb) - - @callback - def register_on_disconnect(self, on_disconnect_cb): - """Register an async on_disconnect callback.""" - self._on_disconnect.append(on_disconnect_cb) - - @property - def connected(self): - """Return if we're currently connected.""" - return self.state == STATE_CONNECTED - - @asyncio.coroutine - def connect(self): - """Connect to the IoT broker.""" - if self.state != STATE_DISCONNECTED: - raise RuntimeError('Connect called while not disconnected') - - hass = self.cloud.hass - self.close_requested = False - self.state = STATE_CONNECTING - self.tries = 0 - - @asyncio.coroutine - def _handle_hass_stop(event): - """Handle Home Assistant shutting down.""" - nonlocal remove_hass_stop_listener - remove_hass_stop_listener = None - yield from self.disconnect() - - remove_hass_stop_listener = hass.bus.async_listen_once( - EVENT_HOMEASSISTANT_STOP, _handle_hass_stop) - - while True: - try: - yield from self._handle_connection() - except Exception: # pylint: disable=broad-except - # Safety net. This should never hit. - # Still adding it here to make sure we can always reconnect - _LOGGER.exception("Unexpected error") - - if self.state == STATE_CONNECTED and self._on_disconnect: - try: - yield from asyncio.wait([ - cb() for cb in self._on_disconnect - ]) - except Exception: # pylint: disable=broad-except - # Safety net. This should never hit. - # Still adding it here to make sure we don't break the flow - _LOGGER.exception( - "Unexpected error in on_disconnect callbacks") - - if self.close_requested: - break - - self.state = STATE_CONNECTING - self.tries += 1 - - try: - # Sleep 2^tries + 0…tries*3 seconds between retries - self.retry_task = hass.async_create_task( - asyncio.sleep(2**min(9, self.tries) + - random.randint(0, self.tries * 3), - loop=hass.loop)) - yield from self.retry_task - self.retry_task = None - except asyncio.CancelledError: - # Happens if disconnect called - break - - self.state = STATE_DISCONNECTED - if remove_hass_stop_listener is not None: - remove_hass_stop_listener() - - async def async_send_message(self, handler, payload, - expect_answer=True): - """Send a message.""" - if self.state != STATE_CONNECTED: - raise NotConnected - - msgid = uuid.uuid4().hex - - if expect_answer: - fut = self._response_handler[msgid] = asyncio.Future() - - message = { - 'msgid': msgid, - 'handler': handler, - 'payload': payload, - } - if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug("Publishing message:\n%s\n", - pprint.pformat(message)) - await self.client.send_json(message) - - if expect_answer: - return await fut - - @asyncio.coroutine - def _handle_connection(self): - """Connect to the IoT broker.""" - hass = self.cloud.hass - - try: - yield from hass.async_add_job(auth_api.check_token, self.cloud) - except auth_api.Unauthenticated as err: - _LOGGER.error('Unable to refresh token: %s', err) - - hass.components.persistent_notification.async_create( - MESSAGE_AUTH_FAIL, 'Home Assistant Cloud', - 'cloud_subscription_expired') - - # Don't await it because it will cancel this task - hass.async_create_task(self.cloud.logout()) - return - except auth_api.CloudError as err: - _LOGGER.warning("Unable to refresh token: %s", err) - return - - if self.cloud.subscription_expired: - hass.components.persistent_notification.async_create( - MESSAGE_EXPIRATION, 'Home Assistant Cloud', - 'cloud_subscription_expired') - self.close_requested = True - return - - session = async_get_clientsession(self.cloud.hass) - client = None - disconnect_warn = None - - try: - self.client = client = yield from session.ws_connect( - self.cloud.relayer, heartbeat=55, headers={ - hdrs.AUTHORIZATION: - 'Bearer {}'.format(self.cloud.id_token) - }) - self.tries = 0 - - _LOGGER.info("Connected") - self.state = STATE_CONNECTED - - if self._on_connect: - try: - yield from asyncio.wait([cb() for cb in self._on_connect]) - except Exception: # pylint: disable=broad-except - # Safety net. This should never hit. - # Still adding it here to make sure we don't break the flow - _LOGGER.exception( - "Unexpected error in on_connect callbacks") - - while not client.closed: - msg = yield from client.receive() - - if msg.type in (WSMsgType.CLOSED, WSMsgType.CLOSING): - break - - elif msg.type == WSMsgType.ERROR: - disconnect_warn = 'Connection error' - break - - elif msg.type != WSMsgType.TEXT: - disconnect_warn = 'Received non-Text message: {}'.format( - msg.type) - break - - try: - msg = msg.json() - except ValueError: - disconnect_warn = 'Received invalid JSON.' - break - - if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug("Received message:\n%s\n", - pprint.pformat(msg)) - - response_handler = self._response_handler.pop(msg['msgid'], - None) - - if response_handler is not None: - if 'payload' in msg: - response_handler.set_result(msg["payload"]) - else: - response_handler.set_exception( - ErrorMessage(msg['error'])) - continue - - response = { - 'msgid': msg['msgid'], - } - try: - result = yield from async_handle_message( - hass, self.cloud, msg['handler'], msg['payload']) - - # No response from handler - if result is None: - continue - - response['payload'] = result - - except UnknownHandler: - response['error'] = 'unknown-handler' - - except Exception: # pylint: disable=broad-except - _LOGGER.exception("Error handling message") - response['error'] = 'exception' - - if _LOGGER.isEnabledFor(logging.DEBUG): - _LOGGER.debug("Publishing message:\n%s\n", - pprint.pformat(response)) - yield from client.send_json(response) - - except client_exceptions.WSServerHandshakeError as err: - if err.status == 401: - disconnect_warn = 'Invalid auth.' - self.close_requested = True - # Should we notify user? - else: - _LOGGER.warning("Unable to connect: %s", err) - - except client_exceptions.ClientError as err: - _LOGGER.warning("Unable to connect: %s", err) - - finally: - if disconnect_warn is None: - _LOGGER.info("Connection closed") - else: - _LOGGER.warning("Connection closed: %s", disconnect_warn) - - @asyncio.coroutine - def disconnect(self): - """Disconnect the client.""" - self.close_requested = True - - if self.client is not None: - yield from self.client.close() - elif self.retry_task is not None: - self.retry_task.cancel() - - -@asyncio.coroutine -def async_handle_message(hass, cloud, handler_name, payload): - """Handle incoming IoT message.""" - handler = HANDLERS.get(handler_name) - - if handler is None: - raise UnknownHandler() - - return (yield from handler(hass, cloud, payload)) - - -@HANDLERS.register('alexa') -@asyncio.coroutine -def async_handle_alexa(hass, cloud, payload): - """Handle an incoming IoT message for Alexa.""" - result = yield from alexa.async_handle_message( - hass, cloud.alexa_config, payload, - enabled=cloud.prefs.alexa_enabled) - return result - - -@HANDLERS.register('google_actions') -@asyncio.coroutine -def async_handle_google_actions(hass, cloud, payload): - """Handle an incoming IoT message for Google Actions.""" - if not cloud.prefs.google_enabled: - return ga.turned_off_response(payload) - - result = yield from ga.async_handle_message( - hass, cloud.gactions_config, - cloud.claims['cognito:username'], - payload) - return result - - -@HANDLERS.register('cloud') -async def async_handle_cloud(hass, cloud, payload): - """Handle an incoming IoT message for cloud component.""" - action = payload['action'] - - if action == 'logout': - # Log out of Home Assistant Cloud - await cloud.logout() - _LOGGER.error("You have been logged out from Home Assistant cloud: %s", - payload['reason']) - else: - _LOGGER.warning("Received unknown cloud action: %s", action) - - -@HANDLERS.register('webhook') -async def async_handle_webhook(hass, cloud, payload): - """Handle an incoming IoT message for cloud webhooks.""" - cloudhook_id = payload['cloudhook_id'] - - found = None - for cloudhook in cloud.prefs.cloudhooks.values(): - if cloudhook['cloudhook_id'] == cloudhook_id: - found = cloudhook - break - - if found is None: - return { - 'status': 200 - } - - request = MockRequest( - content=payload['body'].encode('utf-8'), - headers=payload['headers'], - method=payload['method'], - query_string=payload['query'], - ) - - response = await hass.components.webhook.async_handle_webhook( - found['webhook_id'], request) - - response_dict = utils.aiohttp_serialize_response(response) - body = response_dict.get('body') - - return { - 'body': body, - 'status': response_dict['status'], - 'headers': { - 'Content-Type': response.content_type - } - } diff --git a/homeassistant/components/cloud/services.yaml b/homeassistant/components/cloud/services.yaml new file mode 100644 index 00000000000..9ef814e0087 --- /dev/null +++ b/homeassistant/components/cloud/services.yaml @@ -0,0 +1,7 @@ +# Describes the format for available light services + +remote_connect: + description: Make instance UI available outside over NabuCasa cloud. + +remote_disconnect: + description: Disconnect UI from NabuCasa cloud. diff --git a/requirements_all.txt b/requirements_all.txt index 29e23e1e29d..168e4f37ed5 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -520,6 +520,9 @@ habitipy==0.2.0 # homeassistant.components.hangouts hangups==0.4.6 +# homeassistant.components.cloud +hass-nabucasa==0.3 + # homeassistant.components.mqtt.server hbmqtt==0.9.4 @@ -1763,9 +1766,6 @@ wakeonlan==1.1.6 # homeassistant.components.sensor.waqi waqiasync==1.0.0 -# homeassistant.components.cloud -warrant==0.6.1 - # homeassistant.components.folder_watcher watchdog==0.8.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 84d103065e1..2da5247417d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -110,6 +110,9 @@ ha-ffmpeg==1.11 # homeassistant.components.hangouts hangups==0.4.6 +# homeassistant.components.cloud +hass-nabucasa==0.3 + # homeassistant.components.mqtt.server hbmqtt==0.9.4 @@ -309,8 +312,5 @@ vultr==0.1.2 # homeassistant.components.switch.wake_on_lan wakeonlan==1.1.6 -# homeassistant.components.cloud -warrant==0.6.1 - # homeassistant.components.zha zigpy-homeassistant==0.3.0 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 25f7fbfc419..04c32ff2b26 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -62,6 +62,7 @@ TEST_REQUIREMENTS = ( 'ha-ffmpeg', 'hangups', 'HAP-python', + 'hass-nabucasa', 'haversine', 'hbmqtt', 'hdate', @@ -136,9 +137,10 @@ TEST_REQUIREMENTS = ( ) IGNORE_PACKAGES = ( - 'homeassistant.components.recorder.models', + 'homeassistant.components.hangouts.hangups_utils', + 'homeassistant.components.cloud.client', 'homeassistant.components.homekit.*', - 'homeassistant.components.hangouts.hangups_utils' + 'homeassistant.components.recorder.models', ) IGNORE_PIN = ('colorlog>2.1,<3', 'keyring>=9.3,<10.0', 'urllib3') diff --git a/tests/components/cloud/__init__.py b/tests/components/cloud/__init__.py index ba63e43d091..3a07e52724f 100644 --- a/tests/components/cloud/__init__.py +++ b/tests/components/cloud/__init__.py @@ -11,8 +11,7 @@ from tests.common import mock_coro def mock_cloud(hass, config={}): """Mock cloud.""" - with patch('homeassistant.components.cloud.Cloud.async_start', - return_value=mock_coro()): + with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()): assert hass.loop.run_until_complete(async_setup_component( hass, cloud.DOMAIN, { 'cloud': config @@ -30,5 +29,5 @@ def mock_cloud_prefs(hass, prefs={}): const.PREF_GOOGLE_ALLOW_UNLOCK: True, } prefs_to_set.update(prefs) - hass.data[cloud.DOMAIN].prefs._prefs = prefs_to_set + hass.data[cloud.DOMAIN].client._prefs._prefs = prefs_to_set return prefs_to_set diff --git a/tests/components/cloud/conftest.py b/tests/components/cloud/conftest.py index 81ecb7250ef..163754dd3e1 100644 --- a/tests/components/cloud/conftest.py +++ b/tests/components/cloud/conftest.py @@ -1,9 +1,18 @@ """Fixtures for cloud tests.""" import pytest +from unittest.mock import patch + from . import mock_cloud, mock_cloud_prefs +@pytest.fixture(autouse=True) +def mock_user_data(): + """Mock os module.""" + with patch('hass_nabucasa.Cloud.write_user_info') as writer: + yield writer + + @pytest.fixture def mock_cloud_fixture(hass): """Fixture for cloud component.""" diff --git a/tests/components/cloud/test_auth_api.py b/tests/components/cloud/test_auth_api.py deleted file mode 100644 index bdf9939cb2b..00000000000 --- a/tests/components/cloud/test_auth_api.py +++ /dev/null @@ -1,196 +0,0 @@ -"""Tests for the tools to communicate with the cloud.""" -import asyncio -from unittest.mock import MagicMock, patch - -from botocore.exceptions import ClientError -import pytest - -from homeassistant.components.cloud import auth_api - - -@pytest.fixture -def mock_cognito(): - """Mock warrant.""" - with patch('homeassistant.components.cloud.auth_api._cognito') as mock_cog: - yield mock_cog() - - -def aws_error(code, message='Unknown', operation_name='fake_operation_name'): - """Generate AWS error response.""" - response = { - 'Error': { - 'Code': code, - 'Message': message - } - } - return ClientError(response, operation_name) - - -def test_login_invalid_auth(mock_cognito): - """Test trying to login with invalid credentials.""" - cloud = MagicMock(is_logged_in=False) - mock_cognito.authenticate.side_effect = aws_error('NotAuthorizedException') - - with pytest.raises(auth_api.Unauthenticated): - auth_api.login(cloud, 'user', 'pass') - - assert len(cloud.write_user_info.mock_calls) == 0 - - -def test_login_user_not_found(mock_cognito): - """Test trying to login with invalid credentials.""" - cloud = MagicMock(is_logged_in=False) - mock_cognito.authenticate.side_effect = aws_error('UserNotFoundException') - - with pytest.raises(auth_api.UserNotFound): - auth_api.login(cloud, 'user', 'pass') - - assert len(cloud.write_user_info.mock_calls) == 0 - - -def test_login_user_not_confirmed(mock_cognito): - """Test trying to login without confirming account.""" - cloud = MagicMock(is_logged_in=False) - mock_cognito.authenticate.side_effect = \ - aws_error('UserNotConfirmedException') - - with pytest.raises(auth_api.UserNotConfirmed): - auth_api.login(cloud, 'user', 'pass') - - assert len(cloud.write_user_info.mock_calls) == 0 - - -def test_login(mock_cognito): - """Test trying to login without confirming account.""" - cloud = MagicMock(is_logged_in=False) - mock_cognito.id_token = 'test_id_token' - mock_cognito.access_token = 'test_access_token' - mock_cognito.refresh_token = 'test_refresh_token' - - auth_api.login(cloud, 'user', 'pass') - - assert len(mock_cognito.authenticate.mock_calls) == 1 - assert cloud.id_token == 'test_id_token' - assert cloud.access_token == 'test_access_token' - assert cloud.refresh_token == 'test_refresh_token' - assert len(cloud.write_user_info.mock_calls) == 1 - - -def test_register(mock_cognito): - """Test registering an account.""" - cloud = MagicMock() - cloud = MagicMock() - auth_api.register(cloud, 'email@home-assistant.io', 'password') - assert len(mock_cognito.register.mock_calls) == 1 - result_user, result_password = mock_cognito.register.mock_calls[0][1] - assert result_user == 'email@home-assistant.io' - assert result_password == 'password' - - -def test_register_fails(mock_cognito): - """Test registering an account.""" - cloud = MagicMock() - mock_cognito.register.side_effect = aws_error('SomeError') - with pytest.raises(auth_api.CloudError): - auth_api.register(cloud, 'email@home-assistant.io', 'password') - - -def test_resend_email_confirm(mock_cognito): - """Test starting forgot password flow.""" - cloud = MagicMock() - auth_api.resend_email_confirm(cloud, 'email@home-assistant.io') - assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 1 - - -def test_resend_email_confirm_fails(mock_cognito): - """Test failure when starting forgot password flow.""" - cloud = MagicMock() - mock_cognito.client.resend_confirmation_code.side_effect = \ - aws_error('SomeError') - with pytest.raises(auth_api.CloudError): - auth_api.resend_email_confirm(cloud, 'email@home-assistant.io') - - -def test_forgot_password(mock_cognito): - """Test starting forgot password flow.""" - cloud = MagicMock() - auth_api.forgot_password(cloud, 'email@home-assistant.io') - assert len(mock_cognito.initiate_forgot_password.mock_calls) == 1 - - -def test_forgot_password_fails(mock_cognito): - """Test failure when starting forgot password flow.""" - cloud = MagicMock() - mock_cognito.initiate_forgot_password.side_effect = aws_error('SomeError') - with pytest.raises(auth_api.CloudError): - auth_api.forgot_password(cloud, 'email@home-assistant.io') - - -def test_check_token_writes_new_token_on_refresh(mock_cognito): - """Test check_token writes new token if refreshed.""" - cloud = MagicMock() - mock_cognito.check_token.return_value = True - mock_cognito.id_token = 'new id token' - mock_cognito.access_token = 'new access token' - - auth_api.check_token(cloud) - - assert len(mock_cognito.check_token.mock_calls) == 1 - assert cloud.id_token == 'new id token' - assert cloud.access_token == 'new access token' - assert len(cloud.write_user_info.mock_calls) == 1 - - -def test_check_token_does_not_write_existing_token(mock_cognito): - """Test check_token won't write new token if still valid.""" - cloud = MagicMock() - mock_cognito.check_token.return_value = False - - auth_api.check_token(cloud) - - assert len(mock_cognito.check_token.mock_calls) == 1 - assert cloud.id_token != mock_cognito.id_token - assert cloud.access_token != mock_cognito.access_token - assert len(cloud.write_user_info.mock_calls) == 0 - - -def test_check_token_raises(mock_cognito): - """Test we raise correct error.""" - cloud = MagicMock() - mock_cognito.check_token.side_effect = aws_error('SomeError') - - with pytest.raises(auth_api.CloudError): - auth_api.check_token(cloud) - - assert len(mock_cognito.check_token.mock_calls) == 1 - assert cloud.id_token != mock_cognito.id_token - assert cloud.access_token != mock_cognito.access_token - assert len(cloud.write_user_info.mock_calls) == 0 - - -async def test_async_setup(hass): - """Test async setup.""" - cloud = MagicMock() - await auth_api.async_setup(hass, cloud) - assert len(cloud.iot.mock_calls) == 2 - on_connect = cloud.iot.mock_calls[0][1][0] - on_disconnect = cloud.iot.mock_calls[1][1][0] - - with patch('random.randint', return_value=0), patch( - 'homeassistant.components.cloud.auth_api.renew_access_token' - ) as mock_renew: - await on_connect() - # Let handle token sleep once - await asyncio.sleep(0) - # Let handle token refresh token - await asyncio.sleep(0) - - assert len(mock_renew.mock_calls) == 1 - assert mock_renew.mock_calls[0][1][0] is cloud - - await on_disconnect() - - # Make sure task is no longer being called - await asyncio.sleep(0) - await asyncio.sleep(0) - assert len(mock_renew.mock_calls) == 1 diff --git a/tests/components/cloud/test_client.py b/tests/components/cloud/test_client.py new file mode 100644 index 00000000000..4440651d089 --- /dev/null +++ b/tests/components/cloud/test_client.py @@ -0,0 +1,199 @@ +"""Test the cloud.iot module.""" +from unittest.mock import patch, MagicMock + +from aiohttp import web +import pytest + +from homeassistant.setup import async_setup_component +from homeassistant.components.cloud.const import ( + PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE) +from tests.components.alexa import test_smart_home as test_alexa +from tests.common import mock_coro + +from . import mock_cloud_prefs + + +@pytest.fixture +def mock_cloud(): + """Mock cloud class.""" + return MagicMock(subscription_expired=False) + + +async def test_handler_alexa(hass): + """Test handler Alexa.""" + hass.states.async_set( + 'switch.test', 'on', {'friendly_name': "Test switch"}) + hass.states.async_set( + 'switch.test2', 'on', {'friendly_name': "Test switch 2"}) + + with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()): + setup = await async_setup_component(hass, 'cloud', { + 'cloud': { + 'alexa': { + 'filter': { + 'exclude_entities': 'switch.test2' + }, + 'entity_config': { + 'switch.test': { + 'name': 'Config name', + 'description': 'Config description', + 'display_categories': 'LIGHT' + } + } + } + } + }) + assert setup + + mock_cloud_prefs(hass) + cloud = hass.data['cloud'] + + resp = await cloud.client.async_alexa_message( + test_alexa.get_new_request('Alexa.Discovery', 'Discover')) + + endpoints = resp['event']['payload']['endpoints'] + + assert len(endpoints) == 1 + device = endpoints[0] + + assert device['description'] == 'Config description' + assert device['friendlyName'] == 'Config name' + assert device['displayCategories'] == ['LIGHT'] + assert device['manufacturerName'] == 'Home Assistant' + + +async def test_handler_alexa_disabled(hass, mock_cloud_fixture): + """Test handler Alexa when user has disabled it.""" + mock_cloud_fixture[PREF_ENABLE_ALEXA] = False + cloud = hass.data['cloud'] + + resp = await cloud.client.async_alexa_message( + test_alexa.get_new_request('Alexa.Discovery', 'Discover')) + + assert resp['event']['header']['namespace'] == 'Alexa' + assert resp['event']['header']['name'] == 'ErrorResponse' + assert resp['event']['payload']['type'] == 'BRIDGE_UNREACHABLE' + + +async def test_handler_google_actions(hass): + """Test handler Google Actions.""" + hass.states.async_set( + 'switch.test', 'on', {'friendly_name': "Test switch"}) + hass.states.async_set( + 'switch.test2', 'on', {'friendly_name': "Test switch 2"}) + hass.states.async_set( + 'group.all_locks', 'on', {'friendly_name': "Evil locks"}) + + with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()): + setup = await async_setup_component(hass, 'cloud', { + 'cloud': { + 'google_actions': { + 'filter': { + 'exclude_entities': 'switch.test2' + }, + 'entity_config': { + 'switch.test': { + 'name': 'Config name', + 'aliases': 'Config alias', + 'room': 'living room' + } + } + } + } + }) + assert setup + + mock_cloud_prefs(hass) + cloud = hass.data['cloud'] + + reqid = '5711642932632160983' + data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]} + + with patch( + 'hass_nabucasa.Cloud._decode_claims', + return_value={'cognito:username': 'myUserName'} + ): + resp = await cloud.client.async_google_message(data) + + assert resp['requestId'] == reqid + payload = resp['payload'] + + assert payload['agentUserId'] == 'myUserName' + + devices = payload['devices'] + assert len(devices) == 1 + + device = devices[0] + assert device['id'] == 'switch.test' + assert device['name']['name'] == 'Config name' + assert device['name']['nicknames'] == ['Config alias'] + assert device['type'] == 'action.devices.types.SWITCH' + assert device['roomHint'] == 'living room' + + +async def test_handler_google_actions_disabled(hass, mock_cloud_fixture): + """Test handler Google Actions when user has disabled it.""" + mock_cloud_fixture[PREF_ENABLE_GOOGLE] = False + + with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()): + assert await async_setup_component(hass, 'cloud', {}) + + reqid = '5711642932632160983' + data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]} + + cloud = hass.data['cloud'] + resp = await cloud.client.async_google_message(data) + + assert resp['requestId'] == reqid + assert resp['payload']['errorCode'] == 'deviceTurnedOff' + + +async def test_webhook_msg(hass): + """Test webhook msg.""" + with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()): + setup = await async_setup_component(hass, 'cloud', { + 'cloud': {} + }) + assert setup + cloud = hass.data['cloud'] + + await cloud.client.prefs.async_initialize() + await cloud.client.prefs.async_update(cloudhooks={ + 'hello': { + 'webhook_id': 'mock-webhook-id', + 'cloudhook_id': 'mock-cloud-id' + } + }) + + received = [] + + async def handler(hass, webhook_id, request): + """Handle a webhook.""" + received.append(request) + return web.json_response({'from': 'handler'}) + + hass.components.webhook.async_register( + 'test', 'Test', 'mock-webhook-id', handler) + + response = await cloud.client.async_webhook_message({ + 'cloudhook_id': 'mock-cloud-id', + 'body': '{"hello": "world"}', + 'headers': { + 'content-type': 'application/json' + }, + 'method': 'POST', + 'query': None, + }) + + assert response == { + 'status': 200, + 'body': '{"from": "handler"}', + 'headers': { + 'Content-Type': 'application/json' + } + } + + assert len(received) == 1 + assert await received[0].json() == { + 'hello': 'world' + } diff --git a/tests/components/cloud/test_cloud_api.py b/tests/components/cloud/test_cloud_api.py deleted file mode 100644 index 0ddb8ecce50..00000000000 --- a/tests/components/cloud/test_cloud_api.py +++ /dev/null @@ -1,33 +0,0 @@ -"""Test cloud API.""" -from unittest.mock import Mock, patch - -import pytest - -from homeassistant.components.cloud import cloud_api - - -@pytest.fixture(autouse=True) -def mock_check_token(): - """Mock check token.""" - with patch('homeassistant.components.cloud.auth_api.' - 'check_token') as mock_check_token: - yield mock_check_token - - -async def test_create_cloudhook(hass, aioclient_mock): - """Test creating a cloudhook.""" - aioclient_mock.post('https://example.com/bla', json={ - 'cloudhook_id': 'mock-webhook', - 'url': 'https://blabla' - }) - cloud = Mock( - hass=hass, - id_token='mock-id-token', - cloudhook_create_url='https://example.com/bla', - ) - resp = await cloud_api.async_create_cloudhook(cloud) - assert len(aioclient_mock.mock_calls) == 1 - assert await resp.json() == { - 'cloudhook_id': 'mock-webhook', - 'url': 'https://blabla' - } diff --git a/tests/components/cloud/test_cloudhooks.py b/tests/components/cloud/test_cloudhooks.py deleted file mode 100644 index e98b697e6ab..00000000000 --- a/tests/components/cloud/test_cloudhooks.py +++ /dev/null @@ -1,96 +0,0 @@ -"""Test cloud cloudhooks.""" -from unittest.mock import Mock - -import pytest - -from homeassistant.components.cloud import prefs, cloudhooks - -from tests.common import mock_coro - - -@pytest.fixture -def mock_cloudhooks(hass): - """Mock cloudhooks class.""" - cloud = Mock() - cloud.hass = hass - cloud.hass.async_add_executor_job = Mock(return_value=mock_coro()) - cloud.iot = Mock(async_send_message=Mock(return_value=mock_coro())) - cloud.cloudhook_create_url = 'https://webhook-create.url' - cloud.prefs = prefs.CloudPreferences(hass) - hass.loop.run_until_complete(cloud.prefs.async_initialize()) - return cloudhooks.Cloudhooks(cloud) - - -async def test_enable(mock_cloudhooks, aioclient_mock): - """Test enabling cloudhooks.""" - aioclient_mock.post('https://webhook-create.url', json={ - 'cloudhook_id': 'mock-cloud-id', - 'url': 'https://hooks.nabu.casa/ZXCZCXZ', - }) - - hook = { - 'webhook_id': 'mock-webhook-id', - 'cloudhook_id': 'mock-cloud-id', - 'cloudhook_url': 'https://hooks.nabu.casa/ZXCZCXZ', - } - - assert hook == await mock_cloudhooks.async_create('mock-webhook-id') - - assert mock_cloudhooks.cloud.prefs.cloudhooks == { - 'mock-webhook-id': hook - } - - publish_calls = mock_cloudhooks.cloud.iot.async_send_message.mock_calls - assert len(publish_calls) == 1 - assert publish_calls[0][1][0] == 'webhook-register' - assert publish_calls[0][1][1] == { - 'cloudhook_ids': ['mock-cloud-id'] - } - - -async def test_disable(mock_cloudhooks): - """Test disabling cloudhooks.""" - mock_cloudhooks.cloud.prefs._prefs['cloudhooks'] = { - 'mock-webhook-id': { - 'webhook_id': 'mock-webhook-id', - 'cloudhook_id': 'mock-cloud-id', - 'cloudhook_url': 'https://hooks.nabu.casa/ZXCZCXZ', - } - } - - await mock_cloudhooks.async_delete('mock-webhook-id') - - assert mock_cloudhooks.cloud.prefs.cloudhooks == {} - - publish_calls = mock_cloudhooks.cloud.iot.async_send_message.mock_calls - assert len(publish_calls) == 1 - assert publish_calls[0][1][0] == 'webhook-register' - assert publish_calls[0][1][1] == { - 'cloudhook_ids': [] - } - - -async def test_create_without_connected(mock_cloudhooks, aioclient_mock): - """Test we don't publish a hook if not connected.""" - mock_cloudhooks.cloud.is_connected = False - # Make sure we fail test when we send a message. - mock_cloudhooks.cloud.iot.async_send_message.side_effect = ValueError - - aioclient_mock.post('https://webhook-create.url', json={ - 'cloudhook_id': 'mock-cloud-id', - 'url': 'https://hooks.nabu.casa/ZXCZCXZ', - }) - - hook = { - 'webhook_id': 'mock-webhook-id', - 'cloudhook_id': 'mock-cloud-id', - 'cloudhook_url': 'https://hooks.nabu.casa/ZXCZCXZ', - } - - assert hook == await mock_cloudhooks.async_create('mock-webhook-id') - - assert mock_cloudhooks.cloud.prefs.cloudhooks == { - 'mock-webhook-id': hook - } - - assert len(mock_cloudhooks.cloud.iot.async_send_message.mock_calls) == 0 diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 06de6bf0b59..50b31dd780f 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -4,11 +4,11 @@ from unittest.mock import patch, MagicMock import pytest from jose import jwt +from hass_nabucasa.auth import Unauthenticated, UnknownError +from hass_nabucasa.const import STATE_CONNECTED -from homeassistant.components.cloud import ( - DOMAIN, auth_api, iot) from homeassistant.components.cloud.const import ( - PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_ALLOW_UNLOCK) + PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_ALLOW_UNLOCK, DOMAIN) from homeassistant.util import dt as dt_util from tests.common import mock_coro @@ -22,12 +22,12 @@ SUBSCRIPTION_INFO_URL = 'https://api-test.hass.io/subscription_info' @pytest.fixture() def mock_auth(): """Mock check token.""" - with patch('homeassistant.components.cloud.auth_api.check_token'): + with patch('hass_nabucasa.auth.CognitoAuth.check_token'): yield @pytest.fixture(autouse=True) -def setup_api(hass): +def setup_api(hass, aioclient_mock): """Initialize HTTP API.""" mock_cloud(hass, { 'mode': 'development', @@ -54,14 +54,14 @@ def setup_api(hass): @pytest.fixture def cloud_client(hass, hass_client): """Fixture that can fetch from the cloud client.""" - with patch('homeassistant.components.cloud.Cloud.write_user_info'): + with patch('hass_nabucasa.Cloud.write_user_info'): yield hass.loop.run_until_complete(hass_client()) @pytest.fixture def mock_cognito(): """Mock warrant.""" - with patch('homeassistant.components.cloud.auth_api._cognito') as mock_cog: + with patch('hass_nabucasa.auth.CognitoAuth._cognito') as mock_cog: yield mock_cog() @@ -80,8 +80,7 @@ async def test_google_actions_sync_fails(mock_cognito, cloud_client, assert req.status == 403 -@asyncio.coroutine -def test_login_view(hass, cloud_client, mock_cognito): +async def test_login_view(hass, cloud_client, mock_cognito): """Test logging in.""" mock_cognito.id_token = jwt.encode({ 'email': 'hello@home-assistant.io', @@ -90,23 +89,22 @@ def test_login_view(hass, cloud_client, mock_cognito): mock_cognito.access_token = 'access_token' mock_cognito.refresh_token = 'refresh_token' - with patch('homeassistant.components.cloud.iot.CloudIoT.' - 'connect') as mock_connect, \ - patch('homeassistant.components.cloud.auth_api._authenticate', + with patch('hass_nabucasa.iot.CloudIoT.connect') as mock_connect, \ + patch('hass_nabucasa.auth.CognitoAuth._authenticate', return_value=mock_cognito) as mock_auth: - req = yield from cloud_client.post('/api/cloud/login', json={ + req = await cloud_client.post('/api/cloud/login', json={ 'email': 'my_username', 'password': 'my_password' }) assert req.status == 200 - result = yield from req.json() + result = await req.json() assert result == {'success': True} assert len(mock_connect.mock_calls) == 1 assert len(mock_auth.mock_calls) == 1 - cloud, result_user, result_pass = mock_auth.mock_calls[0][1] + result_user, result_pass = mock_auth.mock_calls[0][1] assert result_user == 'my_username' assert result_pass == 'my_password' @@ -123,32 +121,29 @@ async def test_login_view_random_exception(cloud_client): assert resp == {'code': 'valueerror', 'message': 'Unexpected error: Boom'} -@asyncio.coroutine -def test_login_view_invalid_json(cloud_client): +async def test_login_view_invalid_json(cloud_client): """Try logging in with invalid JSON.""" - with patch('homeassistant.components.cloud.auth_api.login') as mock_login: - req = yield from cloud_client.post('/api/cloud/login', data='Not JSON') + with patch('hass_nabucasa.auth.CognitoAuth.login') as mock_login: + req = await cloud_client.post('/api/cloud/login', data='Not JSON') assert req.status == 400 assert len(mock_login.mock_calls) == 0 -@asyncio.coroutine -def test_login_view_invalid_schema(cloud_client): +async def test_login_view_invalid_schema(cloud_client): """Try logging in with invalid schema.""" - with patch('homeassistant.components.cloud.auth_api.login') as mock_login: - req = yield from cloud_client.post('/api/cloud/login', json={ + with patch('hass_nabucasa.auth.CognitoAuth.login') as mock_login: + req = await cloud_client.post('/api/cloud/login', json={ 'invalid': 'schema' }) assert req.status == 400 assert len(mock_login.mock_calls) == 0 -@asyncio.coroutine -def test_login_view_request_timeout(cloud_client): +async def test_login_view_request_timeout(cloud_client): """Test request timeout while trying to log in.""" - with patch('homeassistant.components.cloud.auth_api.login', + with patch('hass_nabucasa.auth.CognitoAuth.login', side_effect=asyncio.TimeoutError): - req = yield from cloud_client.post('/api/cloud/login', json={ + req = await cloud_client.post('/api/cloud/login', json={ 'email': 'my_username', 'password': 'my_password' }) @@ -156,12 +151,11 @@ def test_login_view_request_timeout(cloud_client): assert req.status == 502 -@asyncio.coroutine -def test_login_view_invalid_credentials(cloud_client): +async def test_login_view_invalid_credentials(cloud_client): """Test logging in with invalid credentials.""" - with patch('homeassistant.components.cloud.auth_api.login', - side_effect=auth_api.Unauthenticated): - req = yield from cloud_client.post('/api/cloud/login', json={ + with patch('hass_nabucasa.auth.CognitoAuth.login', + side_effect=Unauthenticated): + req = await cloud_client.post('/api/cloud/login', json={ 'email': 'my_username', 'password': 'my_password' }) @@ -169,12 +163,11 @@ def test_login_view_invalid_credentials(cloud_client): assert req.status == 401 -@asyncio.coroutine -def test_login_view_unknown_error(cloud_client): +async def test_login_view_unknown_error(cloud_client): """Test unknown error while logging in.""" - with patch('homeassistant.components.cloud.auth_api.login', - side_effect=auth_api.UnknownError): - req = yield from cloud_client.post('/api/cloud/login', json={ + with patch('hass_nabucasa.auth.CognitoAuth.login', + side_effect=UnknownError): + req = await cloud_client.post('/api/cloud/login', json={ 'email': 'my_username', 'password': 'my_password' }) @@ -182,40 +175,36 @@ def test_login_view_unknown_error(cloud_client): assert req.status == 502 -@asyncio.coroutine -def test_logout_view(hass, cloud_client): +async def test_logout_view(hass, cloud_client): """Test logging out.""" cloud = hass.data['cloud'] = MagicMock() cloud.logout.return_value = mock_coro() - req = yield from cloud_client.post('/api/cloud/logout') + req = await cloud_client.post('/api/cloud/logout') assert req.status == 200 - data = yield from req.json() + data = await req.json() assert data == {'message': 'ok'} assert len(cloud.logout.mock_calls) == 1 -@asyncio.coroutine -def test_logout_view_request_timeout(hass, cloud_client): +async def test_logout_view_request_timeout(hass, cloud_client): """Test timeout while logging out.""" cloud = hass.data['cloud'] = MagicMock() cloud.logout.side_effect = asyncio.TimeoutError - req = yield from cloud_client.post('/api/cloud/logout') + req = await cloud_client.post('/api/cloud/logout') assert req.status == 502 -@asyncio.coroutine -def test_logout_view_unknown_error(hass, cloud_client): +async def test_logout_view_unknown_error(hass, cloud_client): """Test unknown error while logging out.""" cloud = hass.data['cloud'] = MagicMock() - cloud.logout.side_effect = auth_api.UnknownError - req = yield from cloud_client.post('/api/cloud/logout') + cloud.logout.side_effect = UnknownError + req = await cloud_client.post('/api/cloud/logout') assert req.status == 502 -@asyncio.coroutine -def test_register_view(mock_cognito, cloud_client): +async def test_register_view(mock_cognito, cloud_client): """Test logging out.""" - req = yield from cloud_client.post('/api/cloud/register', json={ + req = await cloud_client.post('/api/cloud/register', json={ 'email': 'hello@bla.com', 'password': 'falcon42' }) @@ -226,10 +215,9 @@ def test_register_view(mock_cognito, cloud_client): assert result_pass == 'falcon42' -@asyncio.coroutine -def test_register_view_bad_data(mock_cognito, cloud_client): +async def test_register_view_bad_data(mock_cognito, cloud_client): """Test logging out.""" - req = yield from cloud_client.post('/api/cloud/register', json={ + req = await cloud_client.post('/api/cloud/register', json={ 'email': 'hello@bla.com', 'not_password': 'falcon' }) @@ -237,105 +225,95 @@ def test_register_view_bad_data(mock_cognito, cloud_client): assert len(mock_cognito.logout.mock_calls) == 0 -@asyncio.coroutine -def test_register_view_request_timeout(mock_cognito, cloud_client): +async def test_register_view_request_timeout(mock_cognito, cloud_client): """Test timeout while logging out.""" mock_cognito.register.side_effect = asyncio.TimeoutError - req = yield from cloud_client.post('/api/cloud/register', json={ + req = await cloud_client.post('/api/cloud/register', json={ 'email': 'hello@bla.com', 'password': 'falcon42' }) assert req.status == 502 -@asyncio.coroutine -def test_register_view_unknown_error(mock_cognito, cloud_client): +async def test_register_view_unknown_error(mock_cognito, cloud_client): """Test unknown error while logging out.""" - mock_cognito.register.side_effect = auth_api.UnknownError - req = yield from cloud_client.post('/api/cloud/register', json={ + mock_cognito.register.side_effect = UnknownError + req = await cloud_client.post('/api/cloud/register', json={ 'email': 'hello@bla.com', 'password': 'falcon42' }) assert req.status == 502 -@asyncio.coroutine -def test_forgot_password_view(mock_cognito, cloud_client): +async def test_forgot_password_view(mock_cognito, cloud_client): """Test logging out.""" - req = yield from cloud_client.post('/api/cloud/forgot_password', json={ + req = await cloud_client.post('/api/cloud/forgot_password', json={ 'email': 'hello@bla.com', }) assert req.status == 200 assert len(mock_cognito.initiate_forgot_password.mock_calls) == 1 -@asyncio.coroutine -def test_forgot_password_view_bad_data(mock_cognito, cloud_client): +async def test_forgot_password_view_bad_data(mock_cognito, cloud_client): """Test logging out.""" - req = yield from cloud_client.post('/api/cloud/forgot_password', json={ + req = await cloud_client.post('/api/cloud/forgot_password', json={ 'not_email': 'hello@bla.com', }) assert req.status == 400 assert len(mock_cognito.initiate_forgot_password.mock_calls) == 0 -@asyncio.coroutine -def test_forgot_password_view_request_timeout(mock_cognito, cloud_client): +async def test_forgot_password_view_request_timeout(mock_cognito, + cloud_client): """Test timeout while logging out.""" mock_cognito.initiate_forgot_password.side_effect = asyncio.TimeoutError - req = yield from cloud_client.post('/api/cloud/forgot_password', json={ + req = await cloud_client.post('/api/cloud/forgot_password', json={ 'email': 'hello@bla.com', }) assert req.status == 502 -@asyncio.coroutine -def test_forgot_password_view_unknown_error(mock_cognito, cloud_client): +async def test_forgot_password_view_unknown_error(mock_cognito, cloud_client): """Test unknown error while logging out.""" - mock_cognito.initiate_forgot_password.side_effect = auth_api.UnknownError - req = yield from cloud_client.post('/api/cloud/forgot_password', json={ + mock_cognito.initiate_forgot_password.side_effect = UnknownError + req = await cloud_client.post('/api/cloud/forgot_password', json={ 'email': 'hello@bla.com', }) assert req.status == 502 -@asyncio.coroutine -def test_resend_confirm_view(mock_cognito, cloud_client): +async def test_resend_confirm_view(mock_cognito, cloud_client): """Test logging out.""" - req = yield from cloud_client.post('/api/cloud/resend_confirm', json={ + req = await cloud_client.post('/api/cloud/resend_confirm', json={ 'email': 'hello@bla.com', }) assert req.status == 200 assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 1 -@asyncio.coroutine -def test_resend_confirm_view_bad_data(mock_cognito, cloud_client): +async def test_resend_confirm_view_bad_data(mock_cognito, cloud_client): """Test logging out.""" - req = yield from cloud_client.post('/api/cloud/resend_confirm', json={ + req = await cloud_client.post('/api/cloud/resend_confirm', json={ 'not_email': 'hello@bla.com', }) assert req.status == 400 assert len(mock_cognito.client.resend_confirmation_code.mock_calls) == 0 -@asyncio.coroutine -def test_resend_confirm_view_request_timeout(mock_cognito, cloud_client): +async def test_resend_confirm_view_request_timeout(mock_cognito, cloud_client): """Test timeout while logging out.""" mock_cognito.client.resend_confirmation_code.side_effect = \ asyncio.TimeoutError - req = yield from cloud_client.post('/api/cloud/resend_confirm', json={ + req = await cloud_client.post('/api/cloud/resend_confirm', json={ 'email': 'hello@bla.com', }) assert req.status == 502 -@asyncio.coroutine -def test_resend_confirm_view_unknown_error(mock_cognito, cloud_client): +async def test_resend_confirm_view_unknown_error(mock_cognito, cloud_client): """Test unknown error while logging out.""" - mock_cognito.client.resend_confirmation_code.side_effect = \ - auth_api.UnknownError - req = yield from cloud_client.post('/api/cloud/resend_confirm', json={ + mock_cognito.client.resend_confirmation_code.side_effect = UnknownError + req = await cloud_client.post('/api/cloud/resend_confirm', json={ 'email': 'hello@bla.com', }) assert req.status == 502 @@ -347,7 +325,7 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture): 'email': 'hello@home-assistant.io', 'custom:sub-exp': '2018-01-03' }, 'test') - hass.data[DOMAIN].iot.state = iot.STATE_CONNECTED + hass.data[DOMAIN].iot.state = STATE_CONNECTED client = await hass_ws_client(hass) with patch.dict( @@ -407,9 +385,9 @@ async def test_websocket_subscription_reconnect( client = await hass_ws_client(hass) with patch( - 'homeassistant.components.cloud.auth_api.renew_access_token' + 'hass_nabucasa.auth.CognitoAuth.renew_access_token' ) as mock_renew, patch( - 'homeassistant.components.cloud.iot.CloudIoT.connect' + 'hass_nabucasa.iot.CloudIoT.connect' ) as mock_connect: await client.send_json({ 'id': 5, @@ -428,7 +406,7 @@ async def test_websocket_subscription_no_reconnect_if_connected( hass, hass_ws_client, aioclient_mock, mock_auth): """Test querying the status and not reconnecting because still expired.""" aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={'provider': 'stripe'}) - hass.data[DOMAIN].iot.state = iot.STATE_CONNECTED + hass.data[DOMAIN].iot.state = STATE_CONNECTED hass.data[DOMAIN].id_token = jwt.encode({ 'email': 'hello@home-assistant.io', 'custom:sub-exp': dt_util.utcnow().date().isoformat() @@ -436,9 +414,9 @@ async def test_websocket_subscription_no_reconnect_if_connected( client = await hass_ws_client(hass) with patch( - 'homeassistant.components.cloud.auth_api.renew_access_token' + 'hass_nabucasa.auth.CognitoAuth.renew_access_token' ) as mock_renew, patch( - 'homeassistant.components.cloud.iot.CloudIoT.connect' + 'hass_nabucasa.iot.CloudIoT.connect' ) as mock_connect: await client.send_json({ 'id': 5, @@ -464,9 +442,9 @@ async def test_websocket_subscription_no_reconnect_if_expired( client = await hass_ws_client(hass) with patch( - 'homeassistant.components.cloud.auth_api.renew_access_token' + 'hass_nabucasa.auth.CognitoAuth.renew_access_token' ) as mock_renew, patch( - 'homeassistant.components.cloud.iot.CloudIoT.connect' + 'hass_nabucasa.iot.CloudIoT.connect' ) as mock_connect: await client.send_json({ 'id': 5, @@ -503,7 +481,7 @@ async def test_websocket_subscription_fail(hass, hass_ws_client, async def test_websocket_subscription_not_logged_in(hass, hass_ws_client): """Test querying the status.""" client = await hass_ws_client(hass) - with patch('homeassistant.components.cloud.Cloud.fetch_subscription_info', + with patch('hass_nabucasa.Cloud.fetch_subscription_info', return_value=mock_coro({'return': 'value'})): await client.send_json({ 'id': 5, @@ -548,8 +526,10 @@ async def test_enabling_webhook(hass, hass_ws_client, setup_api): 'custom:sub-exp': '2018-01-03' }, 'test') client = await hass_ws_client(hass) - with patch('homeassistant.components.cloud.cloudhooks.Cloudhooks' - '.async_create', return_value=mock_coro()) as mock_enable: + with patch( + 'hass_nabucasa.cloudhooks.Cloudhooks.async_create', + return_value=mock_coro() + ) as mock_enable: await client.send_json({ 'id': 5, 'type': 'cloud/cloudhook/create', @@ -569,8 +549,10 @@ async def test_disabling_webhook(hass, hass_ws_client, setup_api): 'custom:sub-exp': '2018-01-03' }, 'test') client = await hass_ws_client(hass) - with patch('homeassistant.components.cloud.cloudhooks.Cloudhooks' - '.async_delete', return_value=mock_coro()) as mock_disable: + with patch( + 'hass_nabucasa.cloudhooks.Cloudhooks.async_delete', + return_value=mock_coro() + ) as mock_disable: await client.send_json({ 'id': 5, 'type': 'cloud/cloudhook/delete', diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 0780826afd3..818e67c9804 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -1,72 +1,34 @@ """Test the cloud component.""" -import asyncio -import json -from unittest.mock import patch, MagicMock, mock_open +from unittest.mock import MagicMock, patch -import pytest - -from homeassistant.setup import async_setup_component +from homeassistant.const import ( + EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START) from homeassistant.components import cloud -from homeassistant.util.dt import utcnow +from homeassistant.components.cloud.const import DOMAIN from tests.common import mock_coro -@pytest.fixture -def mock_os(): - """Mock os module.""" - with patch('homeassistant.components.cloud.os') as os: - os.path.isdir.return_value = True - yield os - - -@asyncio.coroutine -def test_constructor_loads_info_from_constant(): +async def test_constructor_loads_info_from_config(): """Test non-dev mode loads info from SERVERS constant.""" hass = MagicMock(data={}) - with patch.dict(cloud.SERVERS, { - 'beer': { - 'cognito_client_id': 'test-cognito_client_id', - 'user_pool_id': 'test-user_pool_id', - 'region': 'test-region', - 'relayer': 'test-relayer', - 'google_actions_sync_url': 'test-google_actions_sync_url', - 'subscription_info_url': 'test-subscription-info-url', - 'cloudhook_create_url': 'test-cloudhook_create_url', - } - }): - result = yield from cloud.async_setup(hass, { - 'cloud': {cloud.CONF_MODE: 'beer'} + + with patch( + "homeassistant.components.cloud.prefs.CloudPreferences." + "async_initialize", + return_value=mock_coro() + ): + result = await cloud.async_setup(hass, { + 'cloud': { + cloud.CONF_MODE: cloud.MODE_DEV, + 'cognito_client_id': 'test-cognito_client_id', + 'user_pool_id': 'test-user_pool_id', + 'region': 'test-region', + 'relayer': 'test-relayer', + } }) assert result - cl = hass.data['cloud'] - assert cl.mode == 'beer' - assert cl.cognito_client_id == 'test-cognito_client_id' - assert cl.user_pool_id == 'test-user_pool_id' - assert cl.region == 'test-region' - assert cl.relayer == 'test-relayer' - assert cl.google_actions_sync_url == 'test-google_actions_sync_url' - assert cl.subscription_info_url == 'test-subscription-info-url' - assert cl.cloudhook_create_url == 'test-cloudhook_create_url' - - -@asyncio.coroutine -def test_constructor_loads_info_from_config(): - """Test non-dev mode loads info from SERVERS constant.""" - hass = MagicMock(data={}) - - result = yield from cloud.async_setup(hass, { - 'cloud': { - cloud.CONF_MODE: cloud.MODE_DEV, - 'cognito_client_id': 'test-cognito_client_id', - 'user_pool_id': 'test-user_pool_id', - 'region': 'test-region', - 'relayer': 'test-relayer', - } - }) - assert result - cl = hass.data['cloud'] assert cl.mode == cloud.MODE_DEV assert cl.cognito_client_id == 'test-cognito_client_id' @@ -75,195 +37,41 @@ def test_constructor_loads_info_from_config(): assert cl.relayer == 'test-relayer' -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({ - 'id_token': 'test-id-token', - 'access_token': 'test-access-token', - 'refresh_token': 'test-refresh-token', - })) +async def test_remote_services(hass, mock_cloud_fixture): + """Setup cloud component and test services.""" + assert hass.services.has_service(DOMAIN, 'remote_connect') + assert hass.services.has_service(DOMAIN, 'remote_disconnect') - cl = cloud.Cloud(hass, cloud.MODE_DEV, None, None) - cl.iot = MagicMock() - cl.iot.connect.return_value = mock_coro() + with patch( + "hass_nabucasa.remote.RemoteUI.connect", return_value=mock_coro() + ) as mock_connect: + await hass.services.async_call(DOMAIN, "remote_connect", blocking=True) - with patch('homeassistant.components.cloud.open', mopen, create=True), \ - patch('homeassistant.components.cloud.Cloud._decode_claims'): - await cl.async_start(None) + assert mock_connect.called - assert cl.id_token == 'test-id-token' - assert cl.access_token == 'test-access-token' - assert cl.refresh_token == 'test-refresh-token' - assert len(cl.iot.connect.mock_calls) == 1 + with patch( + "hass_nabucasa.remote.RemoteUI.disconnect", return_value=mock_coro() + ) as mock_disconnect: + await hass.services.async_call( + DOMAIN, "remote_disconnect", blocking=True) + + assert mock_disconnect.called -@asyncio.coroutine -def test_logout_clears_info(mock_os, hass): - """Test logging out disconnects and removes info.""" - cl = cloud.Cloud(hass, cloud.MODE_DEV, None, None) - cl.iot = MagicMock() - cl.iot.disconnect.return_value = mock_coro() +async def test_startup_shutdown_events(hass, mock_cloud_fixture): + """Test if the cloud will start on startup event.""" + with patch( + "hass_nabucasa.Cloud.start", return_value=mock_coro() + ) as mock_start: + hass.bus.async_fire(EVENT_HOMEASSISTANT_START) + await hass.async_block_till_done() - yield from cl.logout() + assert mock_start.called - assert len(cl.iot.disconnect.mock_calls) == 1 - assert cl.id_token is None - assert cl.access_token is None - assert cl.refresh_token is None - assert len(mock_os.remove.mock_calls) == 1 + with patch( + "hass_nabucasa.Cloud.stop", return_value=mock_coro() + ) as mock_stop: + hass.bus.async_fire(EVENT_HOMEASSISTANT_STOP) + await hass.async_block_till_done() - -@asyncio.coroutine -def test_write_user_info(): - """Test writing user info works.""" - mopen = mock_open() - - cl = cloud.Cloud(MagicMock(), cloud.MODE_DEV, None, None) - cl.id_token = 'test-id-token' - cl.access_token = 'test-access-token' - cl.refresh_token = 'test-refresh-token' - - with patch('homeassistant.components.cloud.open', mopen, create=True): - cl.write_user_info() - - handle = mopen() - - assert len(handle.write.mock_calls) == 1 - data = json.loads(handle.write.mock_calls[0][1][0]) - assert data == { - 'access_token': 'test-access-token', - 'id_token': 'test-id-token', - 'refresh_token': 'test-refresh-token', - } - - -@asyncio.coroutine -def test_subscription_expired(hass): - """Test subscription being expired after 3 days of expiration.""" - cl = cloud.Cloud(hass, cloud.MODE_DEV, None, None) - token_val = { - 'custom:sub-exp': '2017-11-13' - } - with patch.object(cl, '_decode_claims', return_value=token_val), \ - patch('homeassistant.util.dt.utcnow', - return_value=utcnow().replace(year=2017, month=11, day=13)): - assert not cl.subscription_expired - - with patch.object(cl, '_decode_claims', return_value=token_val), \ - patch('homeassistant.util.dt.utcnow', - return_value=utcnow().replace( - year=2017, month=11, day=19, hour=23, minute=59, - second=59)): - assert not cl.subscription_expired - - with patch.object(cl, '_decode_claims', return_value=token_val), \ - patch('homeassistant.util.dt.utcnow', - return_value=utcnow().replace( - year=2017, month=11, day=20, hour=0, minute=0, - second=0)): - assert cl.subscription_expired - - -@asyncio.coroutine -def test_subscription_not_expired(hass): - """Test subscription not being expired.""" - cl = cloud.Cloud(hass, cloud.MODE_DEV, None, None) - token_val = { - 'custom:sub-exp': '2017-11-13' - } - with patch.object(cl, '_decode_claims', return_value=token_val), \ - patch('homeassistant.util.dt.utcnow', - return_value=utcnow().replace(year=2017, month=11, day=9)): - assert not cl.subscription_expired - - -async def test_create_cloudhook_no_login(hass): - """Test create cloudhook when not logged in.""" - assert await async_setup_component(hass, 'cloud', {}) - coro = mock_coro({'yo': 'hey'}) - with patch('homeassistant.components.cloud.cloudhooks.' - 'Cloudhooks.async_create', return_value=coro) as mock_create, \ - pytest.raises(cloud.CloudNotAvailable): - await hass.components.cloud.async_create_cloudhook('hello') - - assert len(mock_create.mock_calls) == 0 - - -async def test_delete_cloudhook_no_setup(hass): - """Test delete cloudhook when not logged in.""" - coro = mock_coro() - with patch('homeassistant.components.cloud.cloudhooks.' - 'Cloudhooks.async_delete', return_value=coro) as mock_delete, \ - pytest.raises(cloud.CloudNotAvailable): - await hass.components.cloud.async_delete_cloudhook('hello') - - assert len(mock_delete.mock_calls) == 0 - - -async def test_create_cloudhook(hass): - """Test create cloudhook.""" - assert await async_setup_component(hass, 'cloud', {}) - coro = mock_coro({'cloudhook_url': 'hello'}) - with patch('homeassistant.components.cloud.cloudhooks.' - 'Cloudhooks.async_create', return_value=coro) as mock_create, \ - patch('homeassistant.components.cloud.async_is_logged_in', - return_value=True): - result = await hass.components.cloud.async_create_cloudhook('hello') - - assert result == 'hello' - assert len(mock_create.mock_calls) == 1 - - -async def test_delete_cloudhook(hass): - """Test delete cloudhook.""" - assert await async_setup_component(hass, 'cloud', {}) - coro = mock_coro() - with patch('homeassistant.components.cloud.cloudhooks.' - 'Cloudhooks.async_delete', return_value=coro) as mock_delete, \ - patch('homeassistant.components.cloud.async_is_logged_in', - return_value=True): - await hass.components.cloud.async_delete_cloudhook('hello') - - assert len(mock_delete.mock_calls) == 1 - - -async def test_async_logged_in(hass): - """Test if is_logged_in works.""" - # Cloud not loaded - assert hass.components.cloud.async_is_logged_in() is False - - assert await async_setup_component(hass, 'cloud', {}) - - # Cloud loaded, not logged in - assert hass.components.cloud.async_is_logged_in() is False - - hass.data['cloud'].id_token = "some token" - - # Cloud loaded, logged in - assert hass.components.cloud.async_is_logged_in() is True - - -async def test_async_active_subscription(hass): - """Test if is_logged_in works.""" - # Cloud not loaded - assert hass.components.cloud.async_active_subscription() is False - - assert await async_setup_component(hass, 'cloud', {}) - - # Cloud loaded, not logged in - assert hass.components.cloud.async_active_subscription() is False - - hass.data['cloud'].id_token = "some token" - - # Cloud loaded, logged in, invalid sub - with patch('jose.jwt.get_unverified_claims', return_value={ - 'custom:sub-exp': '{}-12-31'.format(utcnow().year - 1) - }): - assert hass.components.cloud.async_active_subscription() is False - - # Cloud loaded, logged in, valid sub - with patch('jose.jwt.get_unverified_claims', return_value={ - 'custom:sub-exp': '{}-01-01'.format(utcnow().year + 1) - }): - assert hass.components.cloud.async_active_subscription() is True + assert mock_stop.called diff --git a/tests/components/cloud/test_iot.py b/tests/components/cloud/test_iot.py deleted file mode 100644 index 10a94f46833..00000000000 --- a/tests/components/cloud/test_iot.py +++ /dev/null @@ -1,500 +0,0 @@ -"""Test the cloud.iot module.""" -import asyncio -from unittest.mock import patch, MagicMock, PropertyMock - -from aiohttp import WSMsgType, client_exceptions, web -import pytest - -from homeassistant.setup import async_setup_component -from homeassistant.components.cloud import ( - Cloud, iot, auth_api, MODE_DEV) -from homeassistant.components.cloud.const import ( - PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE) -from tests.components.alexa import test_smart_home as test_alexa -from tests.common import mock_coro - -from . import mock_cloud_prefs - - -@pytest.fixture -def mock_client(): - """Mock the IoT client.""" - client = MagicMock() - type(client).closed = PropertyMock(side_effect=[False, True]) - - # Trigger cancelled error to avoid reconnect. - with patch('asyncio.sleep', side_effect=asyncio.CancelledError), \ - patch('homeassistant.components.cloud.iot' - '.async_get_clientsession') as session: - session().ws_connect.return_value = mock_coro(client) - yield client - - -@pytest.fixture -def mock_handle_message(): - """Mock handle message.""" - with patch('homeassistant.components.cloud.iot' - '.async_handle_message') as mock: - yield mock - - -@pytest.fixture -def mock_cloud(): - """Mock cloud class.""" - return MagicMock(subscription_expired=False) - - -@asyncio.coroutine -def test_cloud_calling_handler(mock_client, mock_handle_message, mock_cloud): - """Test we call handle message with correct info.""" - conn = iot.CloudIoT(mock_cloud) - mock_client.receive.return_value = mock_coro(MagicMock( - type=WSMsgType.text, - json=MagicMock(return_value={ - 'msgid': 'test-msg-id', - 'handler': 'test-handler', - 'payload': 'test-payload' - }) - )) - mock_handle_message.return_value = mock_coro('response') - mock_client.send_json.return_value = mock_coro(None) - - yield from conn.connect() - - # Check that we sent message to handler correctly - assert len(mock_handle_message.mock_calls) == 1 - p_hass, p_cloud, handler_name, payload = \ - mock_handle_message.mock_calls[0][1] - - assert p_hass is mock_cloud.hass - assert p_cloud is mock_cloud - assert handler_name == 'test-handler' - assert payload == 'test-payload' - - # Check that we forwarded response from handler to cloud - assert len(mock_client.send_json.mock_calls) == 1 - assert mock_client.send_json.mock_calls[0][1][0] == { - 'msgid': 'test-msg-id', - 'payload': 'response' - } - - -@asyncio.coroutine -def test_connection_msg_for_unknown_handler(mock_client, mock_cloud): - """Test a msg for an unknown handler.""" - conn = iot.CloudIoT(mock_cloud) - mock_client.receive.return_value = mock_coro(MagicMock( - type=WSMsgType.text, - json=MagicMock(return_value={ - 'msgid': 'test-msg-id', - 'handler': 'non-existing-handler', - 'payload': 'test-payload' - }) - )) - mock_client.send_json.return_value = mock_coro(None) - - yield from conn.connect() - - # Check that we sent the correct error - assert len(mock_client.send_json.mock_calls) == 1 - assert mock_client.send_json.mock_calls[0][1][0] == { - 'msgid': 'test-msg-id', - 'error': 'unknown-handler', - } - - -@asyncio.coroutine -def test_connection_msg_for_handler_raising(mock_client, mock_handle_message, - mock_cloud): - """Test we sent error when handler raises exception.""" - conn = iot.CloudIoT(mock_cloud) - mock_client.receive.return_value = mock_coro(MagicMock( - type=WSMsgType.text, - json=MagicMock(return_value={ - 'msgid': 'test-msg-id', - 'handler': 'test-handler', - 'payload': 'test-payload' - }) - )) - mock_handle_message.side_effect = Exception('Broken') - mock_client.send_json.return_value = mock_coro(None) - - yield from conn.connect() - - # Check that we sent the correct error - assert len(mock_client.send_json.mock_calls) == 1 - assert mock_client.send_json.mock_calls[0][1][0] == { - 'msgid': 'test-msg-id', - 'error': 'exception', - } - - -@asyncio.coroutine -def test_handler_forwarding(): - """Test we forward messages to correct handler.""" - handler = MagicMock() - handler.return_value = mock_coro() - hass = object() - cloud = object() - with patch.dict(iot.HANDLERS, {'test': handler}): - yield from iot.async_handle_message( - hass, cloud, 'test', 'payload') - - assert len(handler.mock_calls) == 1 - r_hass, r_cloud, payload = handler.mock_calls[0][1] - assert r_hass is hass - assert r_cloud is cloud - assert payload == 'payload' - - -async def test_handling_core_messages_logout(hass, mock_cloud): - """Test handling core messages.""" - mock_cloud.logout.return_value = mock_coro() - await iot.async_handle_cloud(hass, mock_cloud, { - 'action': 'logout', - 'reason': 'Logged in at two places.' - }) - assert len(mock_cloud.logout.mock_calls) == 1 - - -@asyncio.coroutine -def test_cloud_getting_disconnected_by_server(mock_client, caplog, mock_cloud): - """Test server disconnecting instance.""" - conn = iot.CloudIoT(mock_cloud) - mock_client.receive.return_value = mock_coro(MagicMock( - type=WSMsgType.CLOSING, - )) - - with patch('asyncio.sleep', side_effect=[None, asyncio.CancelledError]): - yield from conn.connect() - - assert 'Connection closed' in caplog.text - - -@asyncio.coroutine -def test_cloud_receiving_bytes(mock_client, caplog, mock_cloud): - """Test server disconnecting instance.""" - conn = iot.CloudIoT(mock_cloud) - mock_client.receive.return_value = mock_coro(MagicMock( - type=WSMsgType.BINARY, - )) - - yield from conn.connect() - - assert 'Connection closed: Received non-Text message' in caplog.text - - -@asyncio.coroutine -def test_cloud_sending_invalid_json(mock_client, caplog, mock_cloud): - """Test cloud sending invalid JSON.""" - conn = iot.CloudIoT(mock_cloud) - mock_client.receive.return_value = mock_coro(MagicMock( - type=WSMsgType.TEXT, - json=MagicMock(side_effect=ValueError) - )) - - yield from conn.connect() - - assert 'Connection closed: Received invalid JSON.' in caplog.text - - -@asyncio.coroutine -def test_cloud_check_token_raising(mock_client, caplog, mock_cloud): - """Test cloud unable to check token.""" - conn = iot.CloudIoT(mock_cloud) - mock_cloud.hass.async_add_job.side_effect = auth_api.CloudError("BLA") - - yield from conn.connect() - - assert 'Unable to refresh token: BLA' in caplog.text - - -@asyncio.coroutine -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, status=401) - - yield from conn.connect() - - assert 'Connection closed: Invalid auth.' in caplog.text - - -@asyncio.coroutine -def test_cloud_unable_to_connect(mock_client, caplog, mock_cloud): - """Test unable to connect error.""" - conn = iot.CloudIoT(mock_cloud) - mock_client.receive.side_effect = client_exceptions.ClientError(None, None) - - yield from conn.connect() - - assert 'Unable to connect:' in caplog.text - - -@asyncio.coroutine -def test_cloud_random_exception(mock_client, caplog, mock_cloud): - """Test random exception.""" - conn = iot.CloudIoT(mock_cloud) - mock_client.receive.side_effect = Exception - - yield from conn.connect() - - assert 'Unexpected error' in caplog.text - - -@asyncio.coroutine -def test_refresh_token_before_expiration_fails(hass, mock_cloud): - """Test that we don't connect if token is expired.""" - mock_cloud.subscription_expired = True - mock_cloud.hass = hass - conn = iot.CloudIoT(mock_cloud) - - with patch('homeassistant.components.cloud.auth_api.check_token', - return_value=mock_coro()) as mock_check_token, \ - patch.object(hass.components.persistent_notification, - 'async_create') as mock_create: - yield from conn.connect() - - assert len(mock_check_token.mock_calls) == 1 - assert len(mock_create.mock_calls) == 1 - - -@asyncio.coroutine -def test_handler_alexa(hass): - """Test handler Alexa.""" - hass.states.async_set( - 'switch.test', 'on', {'friendly_name': "Test switch"}) - hass.states.async_set( - 'switch.test2', 'on', {'friendly_name': "Test switch 2"}) - - with patch('homeassistant.components.cloud.Cloud.async_start', - return_value=mock_coro()): - setup = yield from async_setup_component(hass, 'cloud', { - 'cloud': { - 'alexa': { - 'filter': { - 'exclude_entities': 'switch.test2' - }, - 'entity_config': { - 'switch.test': { - 'name': 'Config name', - 'description': 'Config description', - 'display_categories': 'LIGHT' - } - } - } - } - }) - assert setup - - mock_cloud_prefs(hass) - - resp = yield from iot.async_handle_alexa( - hass, hass.data['cloud'], - test_alexa.get_new_request('Alexa.Discovery', 'Discover')) - - endpoints = resp['event']['payload']['endpoints'] - - assert len(endpoints) == 1 - device = endpoints[0] - - assert device['description'] == 'Config description' - assert device['friendlyName'] == 'Config name' - assert device['displayCategories'] == ['LIGHT'] - assert device['manufacturerName'] == 'Home Assistant' - - -@asyncio.coroutine -def test_handler_alexa_disabled(hass, mock_cloud_fixture): - """Test handler Alexa when user has disabled it.""" - mock_cloud_fixture[PREF_ENABLE_ALEXA] = False - - resp = yield from iot.async_handle_alexa( - hass, hass.data['cloud'], - test_alexa.get_new_request('Alexa.Discovery', 'Discover')) - - assert resp['event']['header']['namespace'] == 'Alexa' - assert resp['event']['header']['name'] == 'ErrorResponse' - assert resp['event']['payload']['type'] == 'BRIDGE_UNREACHABLE' - - -@asyncio.coroutine -def test_handler_google_actions(hass): - """Test handler Google Actions.""" - hass.states.async_set( - 'switch.test', 'on', {'friendly_name': "Test switch"}) - hass.states.async_set( - 'switch.test2', 'on', {'friendly_name': "Test switch 2"}) - hass.states.async_set( - 'group.all_locks', 'on', {'friendly_name': "Evil locks"}) - - with patch('homeassistant.components.cloud.Cloud.async_start', - return_value=mock_coro()): - setup = yield from async_setup_component(hass, 'cloud', { - 'cloud': { - 'google_actions': { - 'filter': { - 'exclude_entities': 'switch.test2' - }, - 'entity_config': { - 'switch.test': { - 'name': 'Config name', - 'aliases': 'Config alias', - 'room': 'living room' - } - } - } - } - }) - assert setup - - mock_cloud_prefs(hass) - - reqid = '5711642932632160983' - data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]} - - with patch('homeassistant.components.cloud.Cloud._decode_claims', - return_value={'cognito:username': 'myUserName'}): - resp = yield from iot.async_handle_google_actions( - hass, hass.data['cloud'], data) - - assert resp['requestId'] == reqid - payload = resp['payload'] - - assert payload['agentUserId'] == 'myUserName' - - devices = payload['devices'] - assert len(devices) == 1 - - device = devices[0] - assert device['id'] == 'switch.test' - assert device['name']['name'] == 'Config name' - assert device['name']['nicknames'] == ['Config alias'] - assert device['type'] == 'action.devices.types.SWITCH' - assert device['roomHint'] == 'living room' - - -async def test_handler_google_actions_disabled(hass, mock_cloud_fixture): - """Test handler Google Actions when user has disabled it.""" - mock_cloud_fixture[PREF_ENABLE_GOOGLE] = False - - with patch('homeassistant.components.cloud.Cloud.async_start', - return_value=mock_coro()): - assert await async_setup_component(hass, 'cloud', {}) - - reqid = '5711642932632160983' - data = {'requestId': reqid, 'inputs': [{'intent': 'action.devices.SYNC'}]} - - resp = await iot.async_handle_google_actions( - hass, hass.data['cloud'], data) - - assert resp['requestId'] == reqid - assert resp['payload']['errorCode'] == 'deviceTurnedOff' - - -async def test_refresh_token_expired(hass): - """Test handling Unauthenticated error raised if refresh token expired.""" - cloud = Cloud(hass, MODE_DEV, None, None) - - with patch('homeassistant.components.cloud.auth_api.check_token', - side_effect=auth_api.Unauthenticated) as mock_check_token, \ - patch.object(hass.components.persistent_notification, - 'async_create') as mock_create: - await cloud.iot.connect() - - assert len(mock_check_token.mock_calls) == 1 - assert len(mock_create.mock_calls) == 1 - - -async def test_webhook_msg(hass): - """Test webhook msg.""" - cloud = Cloud(hass, MODE_DEV, None, None) - await cloud.prefs.async_initialize() - await cloud.prefs.async_update(cloudhooks={ - 'hello': { - 'webhook_id': 'mock-webhook-id', - 'cloudhook_id': 'mock-cloud-id' - } - }) - - received = [] - - async def handler(hass, webhook_id, request): - """Handle a webhook.""" - received.append(request) - return web.json_response({'from': 'handler'}) - - hass.components.webhook.async_register( - 'test', 'Test', 'mock-webhook-id', handler) - - response = await iot.async_handle_webhook(hass, cloud, { - 'cloudhook_id': 'mock-cloud-id', - 'body': '{"hello": "world"}', - 'headers': { - 'content-type': 'application/json' - }, - 'method': 'POST', - 'query': None, - }) - - assert response == { - 'status': 200, - 'body': '{"from": "handler"}', - 'headers': { - 'Content-Type': 'application/json' - } - } - - assert len(received) == 1 - assert await received[0].json() == { - 'hello': 'world' - } - - -async def test_send_message_not_connected(mock_cloud): - """Test sending a message that expects no answer.""" - cloud_iot = iot.CloudIoT(mock_cloud) - - with pytest.raises(iot.NotConnected): - await cloud_iot.async_send_message('webhook', {'msg': 'yo'}) - - -async def test_send_message_no_answer(mock_cloud): - """Test sending a message that expects no answer.""" - cloud_iot = iot.CloudIoT(mock_cloud) - cloud_iot.state = iot.STATE_CONNECTED - cloud_iot.client = MagicMock(send_json=MagicMock(return_value=mock_coro())) - - await cloud_iot.async_send_message('webhook', {'msg': 'yo'}, - expect_answer=False) - assert not cloud_iot._response_handler - assert len(cloud_iot.client.send_json.mock_calls) == 1 - msg = cloud_iot.client.send_json.mock_calls[0][1][0] - assert msg['handler'] == 'webhook' - assert msg['payload'] == {'msg': 'yo'} - - -async def test_send_message_answer(loop, mock_cloud): - """Test sending a message that expects no answer.""" - cloud_iot = iot.CloudIoT(mock_cloud) - cloud_iot.state = iot.STATE_CONNECTED - cloud_iot.client = MagicMock(send_json=MagicMock(return_value=mock_coro())) - - uuid = 5 - - with patch('homeassistant.components.cloud.iot.uuid.uuid4', - return_value=MagicMock(hex=uuid)): - send_task = loop.create_task(cloud_iot.async_send_message( - 'webhook', {'msg': 'yo'})) - await asyncio.sleep(0) - - assert len(cloud_iot.client.send_json.mock_calls) == 1 - assert len(cloud_iot._response_handler) == 1 - msg = cloud_iot.client.send_json.mock_calls[0][1][0] - assert msg['handler'] == 'webhook' - assert msg['payload'] == {'msg': 'yo'} - - cloud_iot._response_handler[uuid].set_result({'response': True}) - response = await send_task - assert response == {'response': True} From 650658ea018c0a1260afb8ff5d01f3d35440639c Mon Sep 17 00:00:00 2001 From: Fabian Affolter Date: Tue, 12 Mar 2019 01:55:29 +0530 Subject: [PATCH 197/291] Upgrade schiene to 0.23 (#21940) --- homeassistant/components/sensor/deutsche_bahn.py | 9 ++------- requirements_all.txt | 2 +- 2 files changed, 3 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/sensor/deutsche_bahn.py b/homeassistant/components/sensor/deutsche_bahn.py index 2cbf9a6d691..41584b2561f 100644 --- a/homeassistant/components/sensor/deutsche_bahn.py +++ b/homeassistant/components/sensor/deutsche_bahn.py @@ -1,9 +1,4 @@ -""" -Support for information about the German train system. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.deutsche_bahn/ -""" +"""Support for information about the German train system.""" from datetime import timedelta import logging @@ -14,7 +9,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity import homeassistant.util.dt as dt_util -REQUIREMENTS = ['schiene==0.22'] +REQUIREMENTS = ['schiene==0.23'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 168e4f37ed5..d34c3483eb1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1550,7 +1550,7 @@ samsungctl[websocket]==0.7.1 satel_integra==0.3.2 # homeassistant.components.sensor.deutsche_bahn -schiene==0.22 +schiene==0.23 # homeassistant.components.scsgate scsgate==0.1.0 From 0a6ba144446d0ceee25de26366b4f6eb67516675 Mon Sep 17 00:00:00 2001 From: Klaudiusz Staniek Date: Mon, 11 Mar 2019 21:27:41 +0100 Subject: [PATCH 198/291] Fixes issues #21821 and #21819 (#21911) * Fix #21821 * datetime fix * local time to utc conversion fix * Test cases update * date import removed * Update tod.py --- homeassistant/components/binary_sensor/tod.py | 20 ++++--- tests/components/binary_sensor/test_tod.py | 59 ++++++++++--------- 2 files changed, 43 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/binary_sensor/tod.py b/homeassistant/components/binary_sensor/tod.py index 7dc6e5ebe81..8e29bbd4678 100644 --- a/homeassistant/components/binary_sensor/tod.py +++ b/homeassistant/components/binary_sensor/tod.py @@ -119,6 +119,17 @@ class TodSensor(BinarySensorDevice): self.hass.config.time_zone).isoformat(), } + def _naive_time_to_utc_datetime(self, naive_time): + """Convert naive time from config to utc_datetime with current day.""" + # get the current local date from utc time + current_local_date = self.current_datetime.astimezone( + self.hass.config.time_zone).date() + # calcuate utc datetime corecponding to local time + utc_datetime = self.hass.config.time_zone.localize( + datetime.combine( + current_local_date, naive_time)).astimezone(tz=pytz.UTC) + return utc_datetime + def _calculate_initial_boudary_time(self): """Calculate internal absolute time boudaries.""" nowutc = self.current_datetime @@ -134,9 +145,7 @@ class TodSensor(BinarySensorDevice): # datetime.combine(date, time, tzinfo) is not supported # in python 3.5. The self._after is provided # with hass configured TZ not system wide - after_event_date = datetime.combine( - nowutc, self._after.replace( - tzinfo=self.hass.config.time_zone)).astimezone(tz=pytz.UTC) + after_event_date = self._naive_time_to_utc_datetime(self._after) self._time_after = after_event_date @@ -154,9 +163,7 @@ class TodSensor(BinarySensorDevice): self.hass, self._before, after_event_date) else: # Convert local time provided to UTC today, see above - before_event_date = datetime.combine( - nowutc, self._before.replace( - tzinfo=self.hass.config.time_zone)).astimezone(tz=pytz.UTC) + before_event_date = self._naive_time_to_utc_datetime(self._before) # It is safe to add timedelta days=1 to UTC as there is no DST if before_event_date < after_event_date + self._after_offset: @@ -190,7 +197,6 @@ class TodSensor(BinarySensorDevice): async def async_added_to_hass(self): """Call when entity about to be added to Home Assistant.""" - await super().async_added_to_hass() self._calculate_initial_boudary_time() self._calculate_next_update() self._point_in_time_listener(dt_util.now()) diff --git a/tests/components/binary_sensor/test_tod.py b/tests/components/binary_sensor/test_tod.py index 3c083141962..7af6ef95bfa 100644 --- a/tests/components/binary_sensor/test_tod.py +++ b/tests/components/binary_sensor/test_tod.py @@ -110,8 +110,8 @@ class TestBinarySensorTod(unittest.TestCase): def test_midnight_turnover_after_midnight_inside_period(self): """Test midnight turnover setting before midnight inside period .""" - test_time = datetime( - 2019, 1, 10, 21, 00, 0, tzinfo=self.hass.config.time_zone) + test_time = self.hass.config.time_zone.localize( + datetime(2019, 1, 10, 21, 0, 0)).astimezone(pytz.UTC) config = { 'binary_sensor': [ { @@ -143,8 +143,8 @@ class TestBinarySensorTod(unittest.TestCase): def test_midnight_turnover_before_midnight_outside_period(self): """Test midnight turnover setting before midnight outside period.""" - test_time = datetime( - 2019, 1, 10, 20, 30, 0, tzinfo=self.hass.config.time_zone) + test_time = self.hass.config.time_zone.localize( + datetime(2019, 1, 10, 20, 30, 0)).astimezone(pytz.UTC) config = { 'binary_sensor': [ { @@ -165,8 +165,9 @@ class TestBinarySensorTod(unittest.TestCase): def test_midnight_turnover_after_midnight_outside_period(self): """Test midnight turnover setting before midnight inside period .""" - test_time = datetime( - 2019, 1, 10, 20, 0, 0, tzinfo=self.hass.config.time_zone) + test_time = self.hass.config.time_zone.localize( + datetime(2019, 1, 10, 20, 0, 0)).astimezone(pytz.UTC) + config = { 'binary_sensor': [ { @@ -185,8 +186,8 @@ class TestBinarySensorTod(unittest.TestCase): state = self.hass.states.get('binary_sensor.night') assert state.state == STATE_OFF - switchover_time = datetime( - 2019, 1, 11, 4, 59, 0, tzinfo=self.hass.config.time_zone) + switchover_time = self.hass.config.time_zone.localize( + datetime(2019, 1, 11, 4, 59, 0)).astimezone(pytz.UTC) with patch('homeassistant.components.binary_sensor.tod.dt_util.utcnow', return_value=switchover_time): @@ -210,8 +211,8 @@ class TestBinarySensorTod(unittest.TestCase): def test_from_sunrise_to_sunset(self): """Test period from sunrise to sunset.""" - test_time = datetime( - 2019, 1, 12, tzinfo=self.hass.config.time_zone) + test_time = self.hass.config.time_zone.localize( + datetime(2019, 1, 12)).astimezone(pytz.UTC) sunrise = dt_util.as_local(get_astral_event_date( self.hass, 'sunrise', dt_util.as_utc(test_time))) sunset = dt_util.as_local(get_astral_event_date( @@ -299,8 +300,8 @@ class TestBinarySensorTod(unittest.TestCase): def test_from_sunset_to_sunrise(self): """Test period from sunset to sunrise.""" - test_time = datetime( - 2019, 1, 12, tzinfo=self.hass.config.time_zone) + test_time = self.hass.config.time_zone.localize( + datetime(2019, 1, 12)).astimezone(pytz.UTC) sunset = dt_util.as_local(get_astral_event_date( self.hass, 'sunset', test_time)) sunrise = dt_util.as_local(get_astral_event_next( @@ -385,14 +386,14 @@ class TestBinarySensorTod(unittest.TestCase): def test_offset(self): """Test offset.""" - after = datetime( - 2019, 1, 10, 18, 0, 0, - tzinfo=self.hass.config.time_zone) + \ + after = self.hass.config.time_zone.localize( + datetime(2019, 1, 10, 18, 0, 0)).astimezone(pytz.UTC) + \ timedelta(hours=1, minutes=34) - before = datetime( - 2019, 1, 10, 22, 0, 0, - tzinfo=self.hass.config.time_zone) + \ + + before = self.hass.config.time_zone.localize( + datetime(2019, 1, 10, 22, 0, 0)).astimezone(pytz.UTC) + \ timedelta(hours=1, minutes=45) + entity_id = 'binary_sensor.evening' config = { 'binary_sensor': [ @@ -457,9 +458,8 @@ class TestBinarySensorTod(unittest.TestCase): def test_offset_overnight(self): """Test offset overnight.""" - after = datetime( - 2019, 1, 10, 18, 0, 0, - tzinfo=self.hass.config.time_zone) + \ + after = self.hass.config.time_zone.localize( + datetime(2019, 1, 10, 18, 0, 0)).astimezone(pytz.UTC) + \ timedelta(hours=1, minutes=34) entity_id = 'binary_sensor.evening' config = { @@ -498,7 +498,8 @@ class TestBinarySensorTod(unittest.TestCase): self.hass.config.latitude = 69.6 self.hass.config.longitude = 18.8 - test_time = datetime(2010, 1, 1, tzinfo=self.hass.config.time_zone) + test_time = self.hass.config.time_zone.localize( + datetime(2010, 1, 1)).astimezone(pytz.UTC) sunrise = dt_util.as_local(get_astral_event_next( self.hass, 'sunrise', dt_util.as_utc(test_time))) sunset = dt_util.as_local(get_astral_event_next( @@ -600,13 +601,13 @@ class TestBinarySensorTod(unittest.TestCase): self.hass.config.latitude = 69.6 self.hass.config.longitude = 18.8 - test_time = datetime(2010, 6, 1, tzinfo=self.hass.config.time_zone) + test_time = self.hass.config.time_zone.localize( + datetime(2010, 6, 1)).astimezone(pytz.UTC) + sunrise = dt_util.as_local(get_astral_event_next( self.hass, 'sunrise', dt_util.as_utc(test_time))) sunset = dt_util.as_local(get_astral_event_next( self.hass, 'sunset', dt_util.as_utc(test_time))) - print(sunrise) - print(sunset) config = { 'binary_sensor': [ { @@ -701,8 +702,8 @@ class TestBinarySensorTod(unittest.TestCase): def test_sun_offset(self): """Test sun event with offset.""" - test_time = datetime( - 2019, 1, 12, tzinfo=self.hass.config.time_zone) + test_time = self.hass.config.time_zone.localize( + datetime(2019, 1, 12)).astimezone(pytz.UTC) sunrise = dt_util.as_local(get_astral_event_date( self.hass, 'sunrise', dt_util.as_utc(test_time)) + timedelta(hours=-1, minutes=-30)) @@ -810,8 +811,8 @@ class TestBinarySensorTod(unittest.TestCase): def test_dst(self): """Test sun event with offset.""" self.hass.config.time_zone = pytz.timezone('CET') - test_time = datetime( - 2019, 3, 30, 3, 0, 0, tzinfo=self.hass.config.time_zone) + test_time = self.hass.config.time_zone.localize( + datetime(2019, 3, 30, 3, 0, 0)).astimezone(pytz.UTC) config = { 'binary_sensor': [ { From 7ccd0bba9a992524d574ae3d0fe521e46d0643aa Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Mon, 11 Mar 2019 22:57:10 -0400 Subject: [PATCH 199/291] Live Streams Component (#21473) * initial commit of streams * refactor stream component * refactor so stream formats are not considered a platform * initial test and minor refactor * fix linting * update requirements * need av in tests as well * fix import in class def vs method * fix travis and docker builds * address code review comments * fix logger, add stream start/stop logs, listen to HASS stop * address additional code review comments * beef up tests * fix tests * fix lint * add stream_source to onvif camera * address pr comments * add keepalive to camera play_stream service * remove keepalive and move import * implement registry and have output provider remove itself from stream after idle, set libav log level to error --- .travis.yml | 13 +- homeassistant/components/camera/__init__.py | 73 ++++++++ homeassistant/components/camera/ffmpeg.py | 5 + homeassistant/components/camera/onvif.py | 5 + homeassistant/components/camera/services.yaml | 16 ++ homeassistant/components/stream/__init__.py | 153 +++++++++++++++++ homeassistant/components/stream/const.py | 14 ++ homeassistant/components/stream/core.py | 162 ++++++++++++++++++ homeassistant/components/stream/hls.py | 126 ++++++++++++++ homeassistant/components/stream/worker.py | 142 +++++++++++++++ requirements_all.txt | 3 + requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/camera/test_init.py | 98 ++++++++++- tests/components/stream/__init__.py | 1 + tests/components/stream/common.py | 63 +++++++ tests/components/stream/test_hls.py | 117 +++++++++++++ virtualization/Docker/setup_docker_prereqs | 3 + 18 files changed, 993 insertions(+), 5 deletions(-) create mode 100644 homeassistant/components/stream/__init__.py create mode 100644 homeassistant/components/stream/const.py create mode 100644 homeassistant/components/stream/core.py create mode 100644 homeassistant/components/stream/hls.py create mode 100644 homeassistant/components/stream/worker.py create mode 100644 tests/components/stream/__init__.py create mode 100644 tests/components/stream/common.py create mode 100644 tests/components/stream/test_hls.py diff --git a/.travis.yml b/.travis.yml index be00f989290..0461d182232 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,8 +1,18 @@ sudo: false +dist: xenial addons: apt: + sources: + - sourceline: "ppa:jonathonf/ffmpeg-4" packages: - libudev-dev + - libavformat-dev + - libavcodec-dev + - libavdevice-dev + - libavutil-dev + - libswscale-dev + - libswresample-dev + - libavfilter-dev matrix: fast_finish: true include: @@ -19,15 +29,12 @@ matrix: env: TOXENV=py36 - 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: diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 474f9594610..48dd355ebd6 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -28,6 +28,12 @@ from homeassistant.helpers.entity_component import EntityComponent from homeassistant.helpers.config_validation import ( # noqa PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE) from homeassistant.components.http import HomeAssistantView, KEY_AUTHENTICATED +from homeassistant.components.media_player.const import ( + ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, + SERVICE_PLAY_MEDIA, DOMAIN as DOMAIN_MP) +from homeassistant.components.stream import request_stream +from homeassistant.components.stream.const import ( + OUTPUT_FORMATS, FORMAT_CONTENT_TYPE) from homeassistant.components import websocket_api import homeassistant.helpers.config_validation as cv @@ -39,11 +45,14 @@ _LOGGER = logging.getLogger(__name__) SERVICE_ENABLE_MOTION = 'enable_motion_detection' SERVICE_DISABLE_MOTION = 'disable_motion_detection' SERVICE_SNAPSHOT = 'snapshot' +SERVICE_PLAY_STREAM = 'play_stream' SCAN_INTERVAL = timedelta(seconds=30) ENTITY_ID_FORMAT = DOMAIN + '.{}' ATTR_FILENAME = 'filename' +ATTR_MEDIA_PLAYER = 'media_player' +ATTR_FORMAT = 'format' STATE_RECORDING = 'recording' STATE_STREAMING = 'streaming' @@ -69,6 +78,11 @@ CAMERA_SERVICE_SNAPSHOT = CAMERA_SERVICE_SCHEMA.extend({ vol.Required(ATTR_FILENAME): cv.template }) +CAMERA_SERVICE_PLAY_STREAM = CAMERA_SERVICE_SCHEMA.extend({ + vol.Required(ATTR_MEDIA_PLAYER): cv.entities_domain(DOMAIN_MP), + vol.Optional(ATTR_FORMAT, default='hls'): vol.In(OUTPUT_FORMATS), +}) + WS_TYPE_CAMERA_THUMBNAIL = 'camera_thumbnail' SCHEMA_WS_CAMERA_THUMBNAIL = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ vol.Required('type'): WS_TYPE_CAMERA_THUMBNAIL, @@ -176,6 +190,7 @@ async def async_setup(hass, config): WS_TYPE_CAMERA_THUMBNAIL, websocket_camera_thumbnail, SCHEMA_WS_CAMERA_THUMBNAIL ) + hass.components.websocket_api.async_register_command(ws_camera_stream) await component.async_setup(config) @@ -209,6 +224,10 @@ async def async_setup(hass, config): SERVICE_SNAPSHOT, CAMERA_SERVICE_SNAPSHOT, async_handle_snapshot_service ) + component.async_register_entity_service( + SERVICE_PLAY_STREAM, CAMERA_SERVICE_PLAY_STREAM, + async_handle_play_stream_service + ) return True @@ -273,6 +292,11 @@ class Camera(Entity): """Return the interval between frames of the mjpeg stream.""" return 0.5 + @property + def stream_source(self): + """Return the source of the stream.""" + return None + def camera_image(self): """Return bytes of camera image.""" raise NotImplementedError() @@ -473,6 +497,33 @@ async def websocket_camera_thumbnail(hass, connection, msg): msg['id'], 'image_fetch_failed', 'Unable to fetch image')) +@websocket_api.async_response +@websocket_api.websocket_command({ + vol.Required('type'): 'camera/stream', + vol.Required('entity_id'): cv.entity_id, + vol.Optional('format', default='hls'): vol.In(OUTPUT_FORMATS), +}) +async def ws_camera_stream(hass, connection, msg): + """Handle get camera stream websocket command. + + Async friendly. + """ + try: + camera = _get_camera_from_entity_id(hass, msg['entity_id']) + + if not camera.stream_source: + raise HomeAssistantError("{} does not support play stream service" + .format(camera.entity_id)) + + fmt = msg['format'] + url = request_stream(hass, camera.stream_source, fmt=fmt) + connection.send_result(msg['id'], {'url': url}) + except HomeAssistantError as ex: + _LOGGER.error(ex) + connection.send_error( + msg['id'], 'start_stream_failed', str(ex)) + + async def async_handle_snapshot_service(camera, service): """Handle snapshot services calls.""" hass = camera.hass @@ -500,3 +551,25 @@ async def async_handle_snapshot_service(camera, service): _write_image, snapshot_file, image) except OSError as err: _LOGGER.error("Can't write image to file: %s", err) + + +async def async_handle_play_stream_service(camera, service_call): + """Handle play stream services calls.""" + if not camera.stream_source: + raise HomeAssistantError("{} does not support play stream service" + .format(camera.entity_id)) + + hass = camera.hass + fmt = service_call.data[ATTR_FORMAT] + entity_ids = service_call.data[ATTR_MEDIA_PLAYER] + + url = request_stream(hass, camera.stream_source, fmt=fmt) + data = { + ATTR_ENTITY_ID: entity_ids, + ATTR_MEDIA_CONTENT_ID: url, + ATTR_MEDIA_CONTENT_TYPE: FORMAT_CONTENT_TYPE[fmt] + } + + await hass.services.async_call( + DOMAIN_MP, SERVICE_PLAY_MEDIA, data, + blocking=True, context=service_call.context) diff --git a/homeassistant/components/camera/ffmpeg.py b/homeassistant/components/camera/ffmpeg.py index db9e73f3e1b..83ffdd499e9 100644 --- a/homeassistant/components/camera/ffmpeg.py +++ b/homeassistant/components/camera/ffmpeg.py @@ -76,3 +76,8 @@ class FFmpegCamera(Camera): def name(self): """Return the name of this camera.""" return self._name + + @property + def stream_source(self): + """Return the source of the stream.""" + return self._input diff --git a/homeassistant/components/camera/onvif.py b/homeassistant/components/camera/onvif.py index da0bae7c50b..b0bd029a80c 100644 --- a/homeassistant/components/camera/onvif.py +++ b/homeassistant/components/camera/onvif.py @@ -230,3 +230,8 @@ class ONVIFHassCamera(Camera): def name(self): """Return the name of this camera.""" return self._name + + @property + def stream_source(self): + """Return the source of the stream.""" + return self._input diff --git a/homeassistant/components/camera/services.yaml b/homeassistant/components/camera/services.yaml index 1cae5baf1cf..ec00ce3ef5c 100644 --- a/homeassistant/components/camera/services.yaml +++ b/homeassistant/components/camera/services.yaml @@ -38,6 +38,22 @@ snapshot: description: Template of a Filename. Variable is entity_id. example: '/tmp/snapshot_{{ entity_id }}' +play_stream: + description: Play camera stream on supported media player. + fields: + entity_id: + description: Name(s) of entities to stream from. + example: 'camera.living_room_camera' + media_player: + description: Name(s) of media player to stream to. + example: 'media_player.living_room_tv' + format: + description: (Optional) Stream format supported by media player. + example: 'hls' + keepalive: + description: (Optional) Keep the stream worker alive for fast access. + example: 'true' + local_file_update_file_path: description: Update the file_path for a local_file camera. fields: diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py new file mode 100644 index 00000000000..5a4b1ade96d --- /dev/null +++ b/homeassistant/components/stream/__init__.py @@ -0,0 +1,153 @@ +""" +Provide functionality to stream video source. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/stream/ +""" +import logging +import threading + +import voluptuous as vol + +from homeassistant.auth.util import generate_secret +from homeassistant.const import EVENT_HOMEASSISTANT_STOP +from homeassistant.core import callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.loader import bind_hass + +from .const import DOMAIN, ATTR_STREAMS, ATTR_ENDPOINTS +from .core import PROVIDERS +from .worker import stream_worker +from .hls import async_setup_hls + +REQUIREMENTS = ['av==6.1.2'] + +_LOGGER = logging.getLogger(__name__) + +DEPENDENCIES = ['http'] + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({}), +}, extra=vol.ALLOW_EXTRA) + +# Set log level to error for libav +logging.getLogger('libav').setLevel(logging.ERROR) + + +@bind_hass +def request_stream(hass, stream_source, *, fmt='hls', + keepalive=False, options=None): + """Set up stream with token.""" + if DOMAIN not in hass.config.components: + raise HomeAssistantError("Stream component is not set up.") + + if options is None: + options = {} + + try: + streams = hass.data[DOMAIN][ATTR_STREAMS] + stream = streams.get(stream_source) + if not stream: + stream = Stream(hass, stream_source, + options=options, keepalive=keepalive) + streams[stream_source] = stream + + # Add provider + stream.add_provider(fmt) + + if not stream.access_token: + stream.access_token = generate_secret() + stream.start() + return hass.data[DOMAIN][ATTR_ENDPOINTS][fmt].format( + hass.config.api.base_url, stream.access_token) + except Exception: + raise HomeAssistantError('Unable to get stream') + + +async def async_setup(hass, config): + """Set up stream.""" + hass.data[DOMAIN] = {} + hass.data[DOMAIN][ATTR_ENDPOINTS] = {} + hass.data[DOMAIN][ATTR_STREAMS] = {} + + # Setup HLS + hls_endpoint = async_setup_hls(hass) + hass.data[DOMAIN][ATTR_ENDPOINTS]['hls'] = hls_endpoint + + @callback + def shutdown(event): + """Stop all stream workers.""" + for stream in hass.data[DOMAIN][ATTR_STREAMS].values(): + stream.keepalive = False + stream.stop() + _LOGGER.info("Stopped stream workers.") + + hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, shutdown) + + return True + + +class Stream: + """Represents a single stream.""" + + def __init__(self, hass, source, options=None, keepalive=False): + """Initialize a stream.""" + self.hass = hass + self.source = source + self.options = options + self.keepalive = keepalive + self.access_token = None + self._thread = None + self._thread_quit = None + self._outputs = {} + + if self.options is None: + self.options = {} + + @property + def outputs(self): + """Return stream outputs.""" + return self._outputs + + def add_provider(self, fmt): + """Add provider output stream.""" + provider = PROVIDERS[fmt](self) + if not self._outputs.get(provider.format): + self._outputs[provider.format] = provider + return self._outputs[provider.format] + + def remove_provider(self, provider): + """Remove provider output stream.""" + if provider.format in self._outputs: + del self._outputs[provider.format] + + if not self._outputs: + self.stop() + + def start(self): + """Start a stream.""" + if self._thread is None or not self._thread.isAlive(): + self._thread_quit = threading.Event() + self._thread = threading.Thread( + name='stream_worker', + target=stream_worker, + args=( + self.hass, self, self._thread_quit)) + self._thread.start() + _LOGGER.info("Started stream: %s", self.source) + + def stop(self): + """Remove outputs and access token.""" + self._outputs = {} + self.access_token = None + + if not self.keepalive: + self._stop() + + def _stop(self): + """Stop worker thread.""" + if self._thread is not None: + self._thread_quit.set() + self._thread.join() + self._thread = None + _LOGGER.info("Stopped stream: %s", self.source) diff --git a/homeassistant/components/stream/const.py b/homeassistant/components/stream/const.py new file mode 100644 index 00000000000..a87daaa9d40 --- /dev/null +++ b/homeassistant/components/stream/const.py @@ -0,0 +1,14 @@ +"""Constants for Stream component.""" +DOMAIN = 'stream' + +ATTR_ENDPOINTS = 'endpoints' +ATTR_STREAMS = 'streams' +ATTR_KEEPALIVE = 'keepalive' + +OUTPUT_FORMATS = ['hls'] + +FORMAT_CONTENT_TYPE = { + 'hls': 'application/vnd.apple.mpegurl' +} + +AUDIO_SAMPLE_RATE = 44100 diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py new file mode 100644 index 00000000000..3d6ffa0e20c --- /dev/null +++ b/homeassistant/components/stream/core.py @@ -0,0 +1,162 @@ +"""Provides core stream functionality.""" +import asyncio +from collections import deque +import io +from typing import List, Any + +import attr +from aiohttp import web + +from homeassistant.core import callback +from homeassistant.components.http import HomeAssistantView +from homeassistant.helpers.event import async_call_later +from homeassistant.util.decorator import Registry + +from .const import DOMAIN, ATTR_STREAMS + +PROVIDERS = Registry() + + +@attr.s +class StreamBuffer: + """Represent a segment.""" + + segment = attr.ib(type=io.BytesIO) + output = attr.ib() # type=av.OutputContainer + vstream = attr.ib() # type=av.VideoStream + astream = attr.ib(default=None) # type=av.AudioStream + + +@attr.s +class Segment: + """Represent a segment.""" + + sequence = attr.ib(type=int) + segment = attr.ib(type=io.BytesIO) + duration = attr.ib(type=float) + + +class StreamOutput: + """Represents a stream output.""" + + num_segments = 3 + + def __init__(self, stream) -> None: + """Initialize a stream output.""" + self._stream = stream + self._cursor = None + self._event = asyncio.Event() + self._segments = deque(maxlen=self.num_segments) + self._unsub = None + + @property + def format(self) -> str: + """Return container format.""" + return None + + @property + def audio_codec(self) -> str: + """Return desired audio codec.""" + return None + + @property + def video_codec(self) -> str: + """Return desired video codec.""" + return None + + @property + def segments(self) -> List[int]: + """Return current sequence from segments.""" + return [s.sequence for s in self._segments] + + @property + def target_duration(self) -> int: + """Return the average duration of the segments in seconds.""" + durations = [s.duration for s in self._segments] + return round(sum(durations) // len(self._segments)) or 1 + + def get_segment(self, sequence: int = None) -> Any: + """Retrieve a specific segment, or the whole list.""" + # Reset idle timeout + if self._unsub is not None: + self._unsub() + self._unsub = async_call_later(self._stream.hass, 300, self._cleanup) + + if not sequence: + return self._segments + + for segment in self._segments: + if segment.sequence == sequence: + return segment + return None + + async def recv(self) -> Segment: + """Wait for and retrieve the latest segment.""" + last_segment = max(self.segments, default=0) + if self._cursor is None or self._cursor <= last_segment: + await self._event.wait() + + if not self._segments: + return None + + segment = self.get_segment()[-1] + self._cursor = segment.sequence + return segment + + @callback + def put(self, segment: Segment) -> None: + """Store output.""" + # Start idle timeout when we start recieving data + if self._unsub is None: + self._unsub = async_call_later( + self._stream.hass, 300, self._cleanup) + + if segment is None: + self._event.set() + # Cleanup provider + if self._unsub is not None: + self._unsub() + self._cleanup() + return + + self._segments.append(segment) + self._event.set() + self._event.clear() + + @callback + def _cleanup(self, _now=None): + """Remove provider.""" + self._segments = [] + self._stream.remove_provider(self) + + +class StreamView(HomeAssistantView): + """ + Base StreamView. + + For implementation of a new stream format, define `url` and `name` + attributes, and implement `handle` method in a child class. + """ + + requires_auth = False + platform = None + + async def get(self, request, token, sequence=None): + """Start a GET request.""" + hass = request.app['hass'] + + stream = next(( + s for s in hass.data[DOMAIN][ATTR_STREAMS].values() + if s.access_token == token), None) + + if not stream: + raise web.HTTPNotFound() + + # Start worker if not already started + stream.start() + + return await self.handle(request, stream, sequence) + + async def handle(self, request, stream, sequence): + """Handle the stream request.""" + raise NotImplementedError() diff --git a/homeassistant/components/stream/hls.py b/homeassistant/components/stream/hls.py new file mode 100644 index 00000000000..285f752c033 --- /dev/null +++ b/homeassistant/components/stream/hls.py @@ -0,0 +1,126 @@ +""" +Provide functionality to stream HLS. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/stream/hls +""" +from aiohttp import web + +from homeassistant.core import callback +from homeassistant.util.dt import utcnow + +from .const import FORMAT_CONTENT_TYPE +from .core import StreamView, StreamOutput, PROVIDERS + + +@callback +def async_setup_hls(hass): + """Set up api endpoints.""" + hass.http.register_view(HlsPlaylistView()) + hass.http.register_view(HlsSegmentView()) + return '{}/api/hls/{}/playlist.m3u8' + + +class HlsPlaylistView(StreamView): + """Stream view to serve a M3U8 stream.""" + + url = r'/api/hls/{token:[a-f0-9]+}/playlist.m3u8' + name = 'api:stream:hls:playlist' + cors_allowed = True + + async def handle(self, request, stream, sequence): + """Return m3u8 playlist.""" + renderer = M3U8Renderer(stream) + track = stream.add_provider('hls') + stream.start() + # Wait for a segment to be ready + if not track.segments: + await track.recv() + headers = { + 'Content-Type': FORMAT_CONTENT_TYPE['hls'] + } + return web.Response(body=renderer.render( + track, utcnow()).encode("utf-8"), headers=headers) + + +class HlsSegmentView(StreamView): + """Stream view to serve a MPEG2TS segment.""" + + url = r'/api/hls/{token:[a-f0-9]+}/segment/{sequence:\d+}.ts' + name = 'api:stream:hls:segment' + cors_allowed = True + + async def handle(self, request, stream, sequence): + """Return mpegts segment.""" + track = stream.add_provider('hls') + segment = track.get_segment(int(sequence)) + if not segment: + return web.HTTPNotFound() + headers = { + 'Content-Type': 'video/mp2t' + } + return web.Response(body=segment.segment.getvalue(), headers=headers) + + +class M3U8Renderer: + """M3U8 Render Helper.""" + + def __init__(self, stream): + """Initialize renderer.""" + self.stream = stream + + @staticmethod + def render_preamble(track): + """Render preamble.""" + return [ + "#EXT-X-VERSION:3", + "#EXT-X-TARGETDURATION:{}".format(track.target_duration), + ] + + @staticmethod + def render_playlist(track, start_time): + """Render playlist.""" + segments = track.segments + + if not segments: + return [] + + playlist = ["#EXT-X-MEDIA-SEQUENCE:{}".format(segments[0])] + + for sequence in segments: + segment = track.get_segment(sequence) + playlist.extend([ + "#EXTINF:{:.04},".format(float(segment.duration)), + "./segment/{}.ts".format(segment.sequence), + ]) + + return playlist + + def render(self, track, start_time): + """Render M3U8 file.""" + lines = ( + ["#EXTM3U"] + + self.render_preamble(track) + + self.render_playlist(track, start_time) + ) + return "\n".join(lines) + "\n" + + +@PROVIDERS.register('hls') +class HlsStreamOutput(StreamOutput): + """Represents HLS Output formats.""" + + @property + def format(self) -> str: + """Return container format.""" + return 'mpegts' + + @property + def audio_codec(self) -> str: + """Return desired audio codec.""" + return 'aac' + + @property + def video_codec(self) -> str: + """Return desired video codec.""" + return 'h264' diff --git a/homeassistant/components/stream/worker.py b/homeassistant/components/stream/worker.py new file mode 100644 index 00000000000..3a3e19d9703 --- /dev/null +++ b/homeassistant/components/stream/worker.py @@ -0,0 +1,142 @@ +"""Provides the worker thread needed for processing streams.""" +from fractions import Fraction +import io +import logging + +from .const import AUDIO_SAMPLE_RATE +from .core import Segment, StreamBuffer + +_LOGGER = logging.getLogger(__name__) + + +def generate_audio_frame(): + """Generate a blank audio frame.""" + from av import AudioFrame + audio_frame = AudioFrame(format='dbl', layout='mono', samples=1024) + # audio_bytes = b''.join(b'\x00\x00\x00\x00\x00\x00\x00\x00' + # for i in range(0, 1024)) + audio_bytes = b'\x00\x00\x00\x00\x00\x00\x00\x00' * 1024 + audio_frame.planes[0].update(audio_bytes) + audio_frame.sample_rate = AUDIO_SAMPLE_RATE + audio_frame.time_base = Fraction(1, AUDIO_SAMPLE_RATE) + return audio_frame + + +def create_stream_buffer(stream_output, video_stream, audio_frame): + """Create a new StreamBuffer.""" + import av + a_packet = None + segment = io.BytesIO() + output = av.open( + segment, mode='w', format=stream_output.format) + vstream = output.add_stream( + stream_output.video_codec, video_stream.rate) + # Fix format + vstream.codec_context.format = \ + video_stream.codec_context.format + # Check if audio is requested + astream = None + if stream_output.audio_codec: + astream = output.add_stream( + stream_output.audio_codec, AUDIO_SAMPLE_RATE) + # Need to do it multiple times for some reason + while not a_packet: + a_packets = astream.encode(audio_frame) + if a_packets: + a_packet = a_packets[0] + return (a_packet, StreamBuffer(segment, output, vstream, astream)) + + +def stream_worker(hass, stream, quit_event): + """Handle consuming streams.""" + import av + container = av.open(stream.source, options=stream.options) + try: + video_stream = container.streams.video[0] + except (KeyError, IndexError): + _LOGGER.error("Stream has no video") + return + + audio_frame = generate_audio_frame() + + outputs = {} + first_packet = True + sequence = 1 + audio_packets = {} + + while not quit_event.is_set(): + try: + packet = next(container.demux(video_stream)) + if packet.dts is None: + # If we get a "flushing" packet, the stream is done + raise StopIteration + except (av.AVError, StopIteration) as ex: + # End of stream, clear listeners and stop thread + for fmt, _ in outputs.items(): + hass.loop.call_soon_threadsafe( + stream.outputs[fmt].put, None) + _LOGGER.error("Error demuxing stream: %s", ex) + break + + # Reset segment on every keyframe + if packet.is_keyframe: + # Save segment to outputs + segment_duration = (packet.pts * packet.time_base) / sequence + for fmt, buffer in outputs.items(): + buffer.output.close() + del audio_packets[buffer.astream] + if stream.outputs.get(fmt): + hass.loop.call_soon_threadsafe( + stream.outputs[fmt].put, Segment( + sequence, buffer.segment, segment_duration + )) + + # Clear outputs and increment sequence + outputs = {} + if not first_packet: + sequence += 1 + + # Initialize outputs + for stream_output in stream.outputs.values(): + if video_stream.name != stream_output.video_codec: + continue + + a_packet, buffer = create_stream_buffer( + stream_output, video_stream, audio_frame) + audio_packets[buffer.astream] = a_packet + outputs[stream_output.format] = buffer + + # First video packet tends to have a weird dts/pts + if first_packet: + packet.dts = 0 + packet.pts = 0 + first_packet = False + + # Store packets on each output + for buffer in outputs.values(): + # Check if the format requires audio + if audio_packets.get(buffer.astream): + a_packet = audio_packets[buffer.astream] + a_time_base = a_packet.time_base + + # Determine video start timestamp and duration + video_start = packet.pts * packet.time_base + video_duration = packet.duration * packet.time_base + + if packet.is_keyframe: + # Set first audio packet in sequence to equal video pts + a_packet.pts = int(video_start / a_time_base) + a_packet.dts = int(video_start / a_time_base) + + # Determine target end timestamp for audio + target_pts = int((video_start + video_duration) / a_time_base) + while a_packet.pts < target_pts: + # Mux audio packet and adjust points until target hit + buffer.output.mux(a_packet) + a_packet.pts += a_packet.duration + a_packet.dts += a_packet.duration + audio_packets[buffer.astream] = a_packet + + # Assign the video packet to the new stream & mux + packet.stream = buffer.vstream + buffer.output.mux(packet) diff --git a/requirements_all.txt b/requirements_all.txt index d34c3483eb1..08b4f1bb5df 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -179,6 +179,9 @@ asterisk_mbox==0.5.0 # homeassistant.components.media_player.dlna_dmr async-upnp-client==0.14.5 +# homeassistant.components.stream +av==6.1.2 + # homeassistant.components.light.avion # avion==0.10 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 2da5247417d..aa2f7e8fc6f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -53,6 +53,9 @@ aiounifi==4 # homeassistant.components.notify.apns apns2==0.3.0 +# homeassistant.components.stream +av==6.1.2 + # homeassistant.components.zha bellows-homeassistant==0.7.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 04c32ff2b26..bb242d1e7ba 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -45,6 +45,7 @@ TEST_REQUIREMENTS = ( 'aiohue', 'aiounifi', 'apns2', + 'av', 'caldav', 'coinmarketcap', 'defusedxml', diff --git a/tests/components/camera/test_init.py b/tests/components/camera/test_init.py index 6b98f378ef0..840e30161f3 100644 --- a/tests/components/camera/test_init.py +++ b/tests/components/camera/test_init.py @@ -1,12 +1,12 @@ """The tests for the camera component.""" import asyncio import base64 -from unittest.mock import patch, mock_open +from unittest.mock import patch, mock_open, PropertyMock import pytest from homeassistant.setup import setup_component, async_setup_component -from homeassistant.const import ATTR_ENTITY_PICTURE +from homeassistant.const import (ATTR_ENTITY_ID, ATTR_ENTITY_PICTURE) from homeassistant.components import camera, http from homeassistant.components.websocket_api.const import TYPE_RESULT from homeassistant.exceptions import HomeAssistantError @@ -16,6 +16,7 @@ from tests.common import ( get_test_home_assistant, get_test_instance_port, assert_setup_component, mock_coro) from tests.components.camera import common +from tests.components.stream.common import generate_h264_video @pytest.fixture @@ -32,6 +33,14 @@ def mock_camera(hass): yield +@pytest.fixture +def mock_stream(hass): + """Initialize a demo camera platform with streaming.""" + assert hass.loop.run_until_complete(async_setup_component(hass, 'stream', { + 'stream': {} + })) + + class TestSetupCamera: """Test class for setup camera.""" @@ -156,3 +165,88 @@ async def test_webocket_camera_thumbnail(hass, hass_ws_client, mock_camera): assert msg['result']['content_type'] == 'image/jpeg' assert msg['result']['content'] == \ base64.b64encode(b'Test').decode('utf-8') + + +async def test_webocket_stream_no_source(hass, hass_ws_client, + mock_camera, mock_stream): + """Test camera/stream websocket command.""" + await async_setup_component(hass, 'camera') + + with patch('homeassistant.components.camera.request_stream', + return_value='http://home.assistant/playlist.m3u8') \ + as mock_request_stream: + # Request playlist through WebSocket + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 6, + 'type': 'camera/stream', + 'entity_id': 'camera.demo_camera', + }) + msg = await client.receive_json() + + # Assert WebSocket response + assert not mock_request_stream.called + assert msg['id'] == 6 + assert msg['type'] == TYPE_RESULT + assert not msg['success'] + + +async def test_webocket_camera_stream(hass, hass_ws_client, hass_client, + mock_camera, mock_stream): + """Test camera/stream websocket command.""" + await async_setup_component(hass, 'camera') + + with patch('homeassistant.components.camera.request_stream', + return_value='http://home.assistant/playlist.m3u8' + ) as mock_request_stream, \ + patch('homeassistant.components.camera.demo.DemoCamera.stream_source', + new_callable=PropertyMock) as mock_stream_source: + mock_stream_source.return_value = generate_h264_video() + # Request playlist through WebSocket + client = await hass_ws_client(hass) + await client.send_json({ + 'id': 6, + 'type': 'camera/stream', + 'entity_id': 'camera.demo_camera', + }) + msg = await client.receive_json() + + # Assert WebSocket response + assert mock_request_stream.called + assert msg['id'] == 6 + assert msg['type'] == TYPE_RESULT + assert msg['success'] + assert msg['result']['url'][-13:] == 'playlist.m3u8' + + +async def test_play_stream_service_no_source(hass, mock_camera, mock_stream): + """Test camera play_stream service.""" + data = { + ATTR_ENTITY_ID: 'camera.demo_camera', + camera.ATTR_MEDIA_PLAYER: 'media_player.test' + } + with patch('homeassistant.components.camera.request_stream'), \ + pytest.raises(HomeAssistantError): + # Call service + await hass.services.async_call( + camera.DOMAIN, camera.SERVICE_PLAY_STREAM, data, blocking=True) + + +async def test_handle_play_stream_service(hass, mock_camera, mock_stream): + """Test camera play_stream service.""" + await async_setup_component(hass, 'media_player') + data = { + ATTR_ENTITY_ID: 'camera.demo_camera', + camera.ATTR_MEDIA_PLAYER: 'media_player.test' + } + with patch('homeassistant.components.camera.request_stream' + ) as mock_request_stream, \ + patch('homeassistant.components.camera.demo.DemoCamera.stream_source', + new_callable=PropertyMock) as mock_stream_source: + mock_stream_source.return_value = generate_h264_video() + # Call service + await hass.services.async_call( + camera.DOMAIN, camera.SERVICE_PLAY_STREAM, data, blocking=True) + # So long as we request the stream, the rest should be covered + # by the play_media service tests. + assert mock_request_stream.called diff --git a/tests/components/stream/__init__.py b/tests/components/stream/__init__.py new file mode 100644 index 00000000000..96247f0ee16 --- /dev/null +++ b/tests/components/stream/__init__.py @@ -0,0 +1 @@ +"""The tests for stream platforms.""" diff --git a/tests/components/stream/common.py b/tests/components/stream/common.py new file mode 100644 index 00000000000..7e8016cd43c --- /dev/null +++ b/tests/components/stream/common.py @@ -0,0 +1,63 @@ +"""Collection of test helpers.""" +import io + +from homeassistant.components.stream import Stream +from homeassistant.components.stream.const import ( + DOMAIN, ATTR_STREAMS) + + +def generate_h264_video(): + """ + Generate a test video. + + See: http://docs.mikeboers.com/pyav/develop/cookbook/numpy.html + """ + import numpy as np + import av + + duration = 5 + fps = 24 + total_frames = duration * fps + + output = io.BytesIO() + output.name = 'test.ts' + container = av.open(output, mode='w') + + stream = container.add_stream('libx264', rate=fps) + stream.width = 480 + stream.height = 320 + stream.pix_fmt = 'yuv420p' + + for frame_i in range(total_frames): + + img = np.empty((480, 320, 3)) + img[:, :, 0] = 0.5 + 0.5 * np.sin( + 2 * np.pi * (0 / 3 + frame_i / total_frames)) + img[:, :, 1] = 0.5 + 0.5 * np.sin( + 2 * np.pi * (1 / 3 + frame_i / total_frames)) + img[:, :, 2] = 0.5 + 0.5 * np.sin( + 2 * np.pi * (2 / 3 + frame_i / total_frames)) + + img = np.round(255 * img).astype(np.uint8) + img = np.clip(img, 0, 255) + + frame = av.VideoFrame.from_ndarray(img, format='rgb24') + for packet in stream.encode(frame): + container.mux(packet) + + # Flush stream + for packet in stream.encode(): + container.mux(packet) + + # Close the file + container.close() + output.seek(0) + + return output + + +def preload_stream(hass, stream_source): + """Preload a stream for use in tests.""" + stream = Stream(hass, stream_source) + hass.data[DOMAIN][ATTR_STREAMS][stream_source] = stream + return stream diff --git a/tests/components/stream/test_hls.py b/tests/components/stream/test_hls.py new file mode 100644 index 00000000000..a2c962ffb45 --- /dev/null +++ b/tests/components/stream/test_hls.py @@ -0,0 +1,117 @@ +"""The tests for hls streams.""" +from datetime import timedelta +from urllib.parse import urlparse + +from homeassistant.setup import async_setup_component +from homeassistant.components.stream import request_stream +import homeassistant.util.dt as dt_util + +from tests.common import async_fire_time_changed +from tests.components.stream.common import ( + generate_h264_video, preload_stream) + + +async def test_hls_stream(hass, hass_client): + """ + Test hls stream. + + Purposefully not mocking anything here to test full + integration with the stream component. + """ + await async_setup_component(hass, 'stream', { + 'stream': {} + }) + + # Setup demo HLS track + source = generate_h264_video() + stream = preload_stream(hass, source) + stream.add_provider('hls') + + # Request stream + url = request_stream(hass, source) + + http_client = await hass_client() + + # Fetch playlist + parsed_url = urlparse(url) + playlist_response = await http_client.get(parsed_url.path) + assert playlist_response.status == 200 + + # Fetch segment + playlist = await playlist_response.text() + playlist_url = '/'.join(parsed_url.path.split('/')[:-1]) + segment_url = playlist_url + playlist.splitlines()[-1][1:] + segment_response = await http_client.get(segment_url) + assert segment_response.status == 200 + + # Stop stream, if it hasn't quit already + stream.stop() + + # Ensure playlist not accessable after stream ends + fail_response = await http_client.get(parsed_url.path) + assert fail_response.status == 404 + + +async def test_stream_timeout(hass, hass_client): + """Test hls stream timeout.""" + await async_setup_component(hass, 'stream', { + 'stream': {} + }) + + # Setup demo HLS track + source = generate_h264_video() + stream = preload_stream(hass, source) + stream.add_provider('hls') + + # Request stream + url = request_stream(hass, source) + + http_client = await hass_client() + + # Fetch playlist + parsed_url = urlparse(url) + playlist_response = await http_client.get(parsed_url.path) + assert playlist_response.status == 200 + + # Wait a minute + future = dt_util.utcnow() + timedelta(minutes=1) + async_fire_time_changed(hass, future) + + # Fetch again to reset timer + playlist_response = await http_client.get(parsed_url.path) + assert playlist_response.status == 200 + + # Wait 5 minutes + future = dt_util.utcnow() + timedelta(minutes=5) + async_fire_time_changed(hass, future) + + # Ensure playlist not accessable + fail_response = await http_client.get(parsed_url.path) + assert fail_response.status == 404 + + +async def test_stream_ended(hass): + """Test hls stream packets ended.""" + await async_setup_component(hass, 'stream', { + 'stream': {} + }) + + # Setup demo HLS track + source = generate_h264_video() + stream = preload_stream(hass, source) + track = stream.add_provider('hls') + track.num_segments = 2 + + # Request stream + request_stream(hass, source) + + # Run it dead + segments = 0 + while await track.recv() is not None: + segments += 1 + + assert segments == 3 + assert not track.get_segment() + + # Stop stream, if it hasn't quit already + stream.stop() diff --git a/virtualization/Docker/setup_docker_prereqs b/virtualization/Docker/setup_docker_prereqs index 713bbfffba4..bbc513502fa 100755 --- a/virtualization/Docker/setup_docker_prereqs +++ b/virtualization/Docker/setup_docker_prereqs @@ -28,6 +28,9 @@ PACKAGES=( libmpc-dev libmpfr-dev libgmp-dev # homeassistant.components.ffmpeg ffmpeg + # homeassistant.components.stream + libavformat-dev libavcodec-dev libavdevice-dev + libavutil-dev libswscale-dev libswresample-dev libavfilter-dev # homeassistant.components.sensor.iperf3 iperf3 ) From 2225425ed2053309695b3d21f993fc05d24b078a Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Mon, 11 Mar 2019 20:42:13 -0700 Subject: [PATCH 200/291] Update lametric icon to be HA logo (#21957) --- homeassistant/components/lametric/notify.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/lametric/notify.py b/homeassistant/components/lametric/notify.py index e5e6a5bd522..9903676d9f9 100644 --- a/homeassistant/components/lametric/notify.py +++ b/homeassistant/components/lametric/notify.py @@ -24,7 +24,7 @@ CONF_PRIORITY = 'priority' DEPENDENCIES = ['lametric'] PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_ICON, default='i555'): cv.string, + vol.Optional(CONF_ICON, default='a7956'): 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'): From dd11f8d3fe05d9a24d0f4dcfe9810e336e0f7e4c Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Tue, 12 Mar 2019 14:39:55 +0100 Subject: [PATCH 201/291] Avoid playing queue pollution when restoring Sonos snapshots (#21963) Assume a snapshot state with three speakers in two groups, AB and C. They will be playing the A and C queues, respectively. The B queue exists but is hidden in this topology. Unjoin B and form a new group BC, playing the B queue (now with the C queue hidden). To restore the snapshot we would join B back to A. The BC group would now only contain the C speaker, still playing the B queue. The C queue has been lost :-( The problem is that unjoining a coordinator will elect a new coordinator that inherits the group queue and thus has its hidden queue overwritten. This commit avoids the situation by having restore unjoin all slaves. Above, C would be unjoined before joining B to A. This restores the C queue and since B is then alone, it can be joined to A without having to transfer its playing queue to remaining speakers. --- homeassistant/components/sonos/media_player.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index e0f881f723d..fbac67b0927 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -1037,8 +1037,13 @@ class SonosEntity(MediaPlayerDevice): if entity.state == STATE_PLAYING: entity.media_pause() - # Bring back the original group topology if with_group: + # Unjoin slaves that are not already in their target group + for entity in [e for e in entities if not e.is_coordinator]: + if entity._snapshot_group != entity._sonos_group: + entity.unjoin() + + # Bring back the original group topology for entity in (e for e in entities if e._snapshot_group): if entity._snapshot_group[0] == entity: entity.join(entity._snapshot_group) From 62df6cbd090e0ce81ee51aeae93aa4da065af871 Mon Sep 17 00:00:00 2001 From: kbickar Date: Tue, 12 Mar 2019 09:44:53 -0400 Subject: [PATCH 202/291] Update to sense component to fully be async (#21698) * Update to sense component to fully be async * Shortened lines * Whitespace --- homeassistant/components/sense/__init__.py | 18 +++++++++-------- .../components/sense/binary_sensor.py | 14 ++++++------- homeassistant/components/sense/sensor.py | 20 +++++++++---------- requirements_all.txt | 2 +- 4 files changed, 26 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/sense/__init__.py b/homeassistant/components/sense/__init__.py index 11c45991400..97771200bcd 100644 --- a/homeassistant/components/sense/__init__.py +++ b/homeassistant/components/sense/__init__.py @@ -5,9 +5,9 @@ import voluptuous as vol from homeassistant.const import CONF_EMAIL, CONF_PASSWORD, CONF_TIMEOUT import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.discovery import load_platform +from homeassistant.helpers.discovery import async_load_platform -REQUIREMENTS = ['sense_energy==0.6.0'] +REQUIREMENTS = ['sense_energy==0.7.0'] _LOGGER = logging.getLogger(__name__) @@ -27,22 +27,24 @@ CONFIG_SCHEMA = vol.Schema({ }, extra=vol.ALLOW_EXTRA) -def setup(hass, config): +async def async_setup(hass, config): """Set up the Sense sensor.""" - from sense_energy import Senseable, SenseAuthenticationException + from sense_energy import ASyncSenseable, SenseAuthenticationException username = config[DOMAIN][CONF_EMAIL] password = config[DOMAIN][CONF_PASSWORD] timeout = config[DOMAIN][CONF_TIMEOUT] try: - hass.data[SENSE_DATA] = Senseable( + hass.data[SENSE_DATA] = ASyncSenseable( api_timeout=timeout, wss_timeout=timeout) - hass.data[SENSE_DATA].authenticate(username, password) hass.data[SENSE_DATA].rate_limit = ACTIVE_UPDATE_RATE + await hass.data[SENSE_DATA].authenticate(username, password) except SenseAuthenticationException: _LOGGER.error("Could not authenticate with sense server") return False - load_platform(hass, 'sensor', DOMAIN, {}, config) - load_platform(hass, 'binary_sensor', DOMAIN, {}, config) + hass.async_create_task( + async_load_platform(hass, 'sensor', DOMAIN, None, config)) + hass.async_create_task( + async_load_platform(hass, 'binary_sensor', DOMAIN, None, config)) return True diff --git a/homeassistant/components/sense/binary_sensor.py b/homeassistant/components/sense/binary_sensor.py index 80fb8f2634d..545aaa8ae7b 100644 --- a/homeassistant/components/sense/binary_sensor.py +++ b/homeassistant/components/sense/binary_sensor.py @@ -49,17 +49,15 @@ MDI_ICONS = { } -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): """Set up the Sense binary sensor.""" - if discovery_info is None: - return - data = hass.data[SENSE_DATA] - sense_devices = data.get_discovered_device_data() + sense_devices = await data.get_discovered_device_data() devices = [SenseDevice(data, device) for device in sense_devices if device['tags']['DeviceListAllowed'] == 'true'] - add_entities(devices) + async_add_entities(devices) def sense_to_mdi(sense_icon): @@ -103,11 +101,11 @@ class SenseDevice(BinarySensorDevice): """Return the device class of the binary sensor.""" return BIN_SENSOR_CLASS - def update(self): + async def async_update(self): """Retrieve latest state.""" from sense_energy.sense_api import SenseAPITimeoutException try: - self._data.get_realtime() + await self._data.update_realtime() except SenseAPITimeoutException: _LOGGER.error("Timeout retrieving data") return diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py index 8fefea03506..c605fc32bc1 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -45,21 +45,19 @@ SENSOR_TYPES = { SENSOR_VARIANTS = [PRODUCTION_NAME.lower(), CONSUMPTION_NAME.lower()] -def setup_platform(hass, config, add_entities, discovery_info=None): +async def async_setup_platform(hass, config, async_add_entities, + discovery_info=None): """Set up the Sense sensor.""" - if discovery_info is None: - return - data = hass.data[SENSE_DATA] @Throttle(MIN_TIME_BETWEEN_DAILY_UPDATES) - def update_trends(): + async def update_trends(): """Update the daily power usage.""" - data.update_trend_data() + await data.update_trend_data() - def update_active(): + async def update_active(): """Update the active power usage.""" - data.get_realtime() + await data.update_realtime() devices = [] for typ in SENSOR_TYPES.values(): @@ -74,7 +72,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): devices.append(Sense( data, name, sensor_type, is_production, update_call)) - add_entities(devices) + async_add_entities(devices) class Sense(Entity): @@ -115,11 +113,11 @@ class Sense(Entity): """Icon to use in the frontend, if any.""" return ICON - def update(self): + async def async_update(self): """Get the latest data, update state.""" from sense_energy import SenseAPITimeoutException try: - self.update_sensor() + await self.update_sensor() except SenseAPITimeoutException: _LOGGER.error("Timeout retrieving data") return diff --git a/requirements_all.txt b/requirements_all.txt index 08b4f1bb5df..443cab1d2e3 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1566,7 +1566,7 @@ sendgrid==5.6.0 sense-hat==2.2.0 # homeassistant.components.sense -sense_energy==0.6.0 +sense_energy==0.7.0 # homeassistant.components.media_player.aquostv sharp_aquos_rc==0.3.2 From cc34ee55597f499652fd49abcdc3a01e8d549c35 Mon Sep 17 00:00:00 2001 From: Thom Troy Date: Tue, 12 Mar 2019 13:49:36 +0000 Subject: [PATCH 203/291] fix ephember doing http call from property (#21855) --- homeassistant/components/climate/ephember.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/climate/ephember.py b/homeassistant/components/climate/ephember.py index 9884d81a199..220c073ef80 100644 --- a/homeassistant/components/climate/ephember.py +++ b/homeassistant/components/climate/ephember.py @@ -117,7 +117,8 @@ class EphEmberThermostat(ClimateDevice): @property def current_operation(self): """Return current operation ie. heat, cool, idle.""" - mode = self._ember.get_zone_mode(self._zone_name) + from pyephember.pyephember import ZoneMode + mode = ZoneMode(self._zone['mode']) return self.map_mode_eph_hass(mode) @property From ac97cebe117b6804c77528afae2977d4cfff2961 Mon Sep 17 00:00:00 2001 From: Markus Jankowski Date: Tue, 12 Mar 2019 14:52:13 +0100 Subject: [PATCH 204/291] Add Weather Sensors to Homematic IP (#21887) * Add HmIP Weather Sensor Devices * Fix test and icons * fix test * Fix comments --- .../homematicip_cloud/binary_sensor.py | 85 ++++++++++++- .../components/homematicip_cloud/const.py | 1 + .../components/homematicip_cloud/sensor.py | 120 +++++++++++++++++- .../components/homematicip_cloud/weather.py | 93 ++++++++++++++ .../components/homematicip_cloud/test_hap.py | 6 +- 5 files changed, 290 insertions(+), 15 deletions(-) create mode 100644 homeassistant/components/homematicip_cloud/weather.py diff --git a/homeassistant/components/homematicip_cloud/binary_sensor.py b/homeassistant/components/homematicip_cloud/binary_sensor.py index d6ce4152001..9445d6521cc 100644 --- a/homeassistant/components/homematicip_cloud/binary_sensor.py +++ b/homeassistant/components/homematicip_cloud/binary_sensor.py @@ -18,6 +18,7 @@ ATTR_WINDOWSTATE = 'window state' ATTR_MOISTUREDETECTED = 'moisture detected' ATTR_WATERLEVELDETECTED = 'water level detected' ATTR_SMOKEDETECTORALARM = 'smoke detector alarm' +ATTR_TODAY_SUNSHINE_DURATION = 'today_sunshine_duration_in_minutes' async def async_setup_platform( @@ -31,7 +32,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): from homematicip.aio.device import ( AsyncShutterContact, AsyncMotionDetectorIndoor, AsyncSmokeDetector, AsyncWaterSensor, AsyncRotaryHandleSensor, - AsyncMotionDetectorPushButton) + AsyncMotionDetectorPushButton, AsyncWeatherSensor, + AsyncWeatherSensorPlus, AsyncWeatherSensorPro) from homematicip.aio.group import ( AsyncSecurityGroup, AsyncSecurityZoneGroup) @@ -41,13 +43,20 @@ async def async_setup_entry(hass, config_entry, async_add_entities): for device in home.devices: if isinstance(device, (AsyncShutterContact, AsyncRotaryHandleSensor)): devices.append(HomematicipShutterContact(home, device)) - elif isinstance(device, (AsyncMotionDetectorIndoor, - AsyncMotionDetectorPushButton)): + if isinstance(device, (AsyncMotionDetectorIndoor, + AsyncMotionDetectorPushButton)): devices.append(HomematicipMotionDetector(home, device)) - elif isinstance(device, AsyncSmokeDetector): + if isinstance(device, AsyncSmokeDetector): devices.append(HomematicipSmokeDetector(home, device)) - elif isinstance(device, AsyncWaterSensor): + if isinstance(device, AsyncWaterSensor): devices.append(HomematicipWaterDetector(home, device)) + if isinstance(device, (AsyncWeatherSensorPlus, + AsyncWeatherSensorPro)): + devices.append(HomematicipRainSensor(home, device)) + if isinstance(device, (AsyncWeatherSensor, AsyncWeatherSensorPlus, + AsyncWeatherSensorPro)): + devices.append(HomematicipStormSensor(home, device)) + devices.append(HomematicipSunshineSensor(home, device)) for group in home.groups: if isinstance(group, AsyncSecurityGroup): @@ -121,10 +130,74 @@ class HomematicipWaterDetector(HomematicipGenericDevice, BinarySensorDevice): @property def is_on(self): - """Return true if moisture or waterlevel is detected.""" + """Return true, if moisture or waterlevel is detected.""" return self._device.moistureDetected or self._device.waterlevelDetected +class HomematicipStormSensor(HomematicipGenericDevice, BinarySensorDevice): + """Representation of a HomematicIP Cloud storm sensor.""" + + def __init__(self, home, device): + """Initialize storm sensor.""" + super().__init__(home, device, "Storm") + + @property + def icon(self): + """Return the icon.""" + return 'mdi:weather-windy' if self.is_on else 'mdi:pinwheel-outline' + + @property + def is_on(self): + """Return true, if storm is detected.""" + return self._device.storm + + +class HomematicipRainSensor(HomematicipGenericDevice, BinarySensorDevice): + """Representation of a HomematicIP Cloud rain sensor.""" + + def __init__(self, home, device): + """Initialize rain sensor.""" + super().__init__(home, device, "Raining") + + @property + def device_class(self): + """Return the class of this sensor.""" + return 'moisture' + + @property + def is_on(self): + """Return true, if it is raining.""" + return self._device.raining + + +class HomematicipSunshineSensor(HomematicipGenericDevice, BinarySensorDevice): + """Representation of a HomematicIP Cloud sunshine sensor.""" + + def __init__(self, home, device): + """Initialize sunshine sensor.""" + super().__init__(home, device, 'Sunshine') + + @property + def device_class(self): + """Return the class of this sensor.""" + return 'light' + + @property + def is_on(self): + """Return true if sun is shining.""" + return self._device.sunshine + + @property + def device_state_attributes(self): + """Return the state attributes of the illuminance sensor.""" + attr = super().device_state_attributes + if hasattr(self._device, 'todaySunshineDuration') and \ + self._device.todaySunshineDuration: + attr[ATTR_TODAY_SUNSHINE_DURATION] = \ + self._device.todaySunshineDuration + return attr + + class HomematicipSecurityZoneSensorGroup(HomematicipGenericDevice, BinarySensorDevice): """Representation of a HomematicIP Cloud security zone group.""" diff --git a/homeassistant/components/homematicip_cloud/const.py b/homeassistant/components/homematicip_cloud/const.py index 06864d50ad1..fbda56f2805 100644 --- a/homeassistant/components/homematicip_cloud/const.py +++ b/homeassistant/components/homematicip_cloud/const.py @@ -13,6 +13,7 @@ COMPONENTS = [ 'light', 'sensor', 'switch', + 'weather', ] CONF_ACCESSPOINT = 'accesspoint' diff --git a/homeassistant/components/homematicip_cloud/sensor.py b/homeassistant/components/homematicip_cloud/sensor.py index 9ded8fe65d2..d6155998332 100644 --- a/homeassistant/components/homematicip_cloud/sensor.py +++ b/homeassistant/components/homematicip_cloud/sensor.py @@ -11,11 +11,11 @@ _LOGGER = logging.getLogger(__name__) DEPENDENCIES = ['homematicip_cloud'] +ATTR_TEMPERATURE_OFFSET = 'temperature_offset' ATTR_VALVE_STATE = 'valve_state' ATTR_VALVE_POSITION = 'valve_position' -ATTR_TEMPERATURE = 'temperature' -ATTR_TEMPERATURE_OFFSET = 'temperature_offset' -ATTR_HUMIDITY = 'humidity' +ATTR_WIND_DIRECTION = 'wind_direction' +ATTR_WIND_DIRECTION_VARIATION = 'wind_direction_variation_in_degree' async def async_setup_platform( @@ -33,7 +33,8 @@ async def async_setup_entry(hass, config_entry, async_add_entities): AsyncTemperatureHumiditySensorOutdoor, AsyncMotionDetectorPushButton, AsyncLightSensor, AsyncPlugableSwitchMeasuring, AsyncBrandSwitchMeasuring, - AsyncFullFlushSwitchMeasuring) + AsyncFullFlushSwitchMeasuring, AsyncWeatherSensor, + AsyncWeatherSensorPlus, AsyncWeatherSensorPro) home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home devices = [HomematicipAccesspointStatus(home)] @@ -43,11 +44,17 @@ async def async_setup_entry(hass, config_entry, async_add_entities): devices.append(HomematicipHeatingThermostat(home, device)) if isinstance(device, (AsyncTemperatureHumiditySensorDisplay, AsyncTemperatureHumiditySensorWithoutDisplay, - AsyncTemperatureHumiditySensorOutdoor)): + AsyncTemperatureHumiditySensorOutdoor, + AsyncWeatherSensor, + AsyncWeatherSensorPlus, + AsyncWeatherSensorPro)): devices.append(HomematicipTemperatureSensor(home, device)) devices.append(HomematicipHumiditySensor(home, device)) if isinstance(device, (AsyncMotionDetectorIndoor, - AsyncMotionDetectorPushButton)): + AsyncMotionDetectorPushButton, + AsyncWeatherSensor, + AsyncWeatherSensorPlus, + AsyncWeatherSensorPro)): devices.append(HomematicipIlluminanceSensor(home, device)) if isinstance(device, AsyncLightSensor): devices.append(HomematicipLightSensor(home, device)) @@ -55,6 +62,13 @@ async def async_setup_entry(hass, config_entry, async_add_entities): AsyncBrandSwitchMeasuring, AsyncFullFlushSwitchMeasuring)): devices.append(HomematicipPowerSensor(home, device)) + if isinstance(device, (AsyncWeatherSensor, + AsyncWeatherSensorPlus, + AsyncWeatherSensorPro)): + devices.append(HomematicipWindspeedSensor(home, device)) + if isinstance(device, (AsyncWeatherSensorPlus, + AsyncWeatherSensorPro)): + devices.append(HomematicipTodayRainSensor(home, device)) if devices: async_add_entities(devices) @@ -177,6 +191,15 @@ class HomematicipTemperatureSensor(HomematicipGenericDevice): """Return the unit this state is expressed in.""" return TEMP_CELSIUS + @property + def device_state_attributes(self): + """Return the state attributes of the windspeed sensor.""" + attr = super().device_state_attributes + if hasattr(self._device, 'temperatureOffset') and \ + self._device.temperatureOffset: + attr[ATTR_TEMPERATURE_OFFSET] = self._device.temperatureOffset + return attr + class HomematicipIlluminanceSensor(HomematicipGenericDevice): """Represenation of a HomematicIP Illuminance device.""" @@ -226,3 +249,88 @@ class HomematicipPowerSensor(HomematicipGenericDevice): def unit_of_measurement(self): """Return the unit this state is expressed in.""" return POWER_WATT + + +class HomematicipWindspeedSensor(HomematicipGenericDevice): + """Represenation of a HomematicIP wind speed sensor.""" + + def __init__(self, home, device): + """Initialize the device.""" + super().__init__(home, device, 'Windspeed') + + @property + def state(self): + """Represenation of the HomematicIP wind speed value.""" + return self._device.windSpeed + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return 'km/h' + + @property + def device_state_attributes(self): + """Return the state attributes of the wind speed sensor.""" + attr = super().device_state_attributes + if hasattr(self._device, 'windDirection') and \ + self._device.windDirection: + attr[ATTR_WIND_DIRECTION] = \ + _get_wind_direction(self._device.windDirection) + if hasattr(self._device, 'windDirectionVariation') and \ + self._device.windDirectionVariation: + attr[ATTR_WIND_DIRECTION_VARIATION] = \ + self._device.windDirectionVariation + return attr + + +class HomematicipTodayRainSensor(HomematicipGenericDevice): + """Represenation of a HomematicIP rain counter of a day sensor.""" + + def __init__(self, home, device): + """Initialize the device.""" + super().__init__(home, device, 'Today Rain') + + @property + def state(self): + """Represenation of the HomematicIP todays rain value.""" + return round(self._device.todayRainCounter, 2) + + @property + def unit_of_measurement(self): + """Return the unit this state is expressed in.""" + return 'mm' + + +def _get_wind_direction(wind_direction_degree): + """Convert wind direction degree to named direction.""" + if 11.25 <= wind_direction_degree < 33.75: + return 'NNE' + if 33.75 <= wind_direction_degree < 56.25: + return 'NE' + if 56.25 <= wind_direction_degree < 78.75: + return 'ENE' + if 78.75 <= wind_direction_degree < 101.25: + return 'E' + if 101.25 <= wind_direction_degree < 123.75: + return 'ESE' + if 123.75 <= wind_direction_degree < 146.25: + return 'SE' + if 146.25 <= wind_direction_degree < 168.75: + return 'SSE' + if 168.75 <= wind_direction_degree < 191.25: + return 'S' + if 191.25 <= wind_direction_degree < 213.75: + return 'SSW' + if 213.75 <= wind_direction_degree < 236.25: + return 'SW' + if 236.25 <= wind_direction_degree < 258.75: + return 'WSW' + if 258.75 <= wind_direction_degree < 281.25: + return 'W' + if 281.25 <= wind_direction_degree < 303.75: + return 'WNW' + if 303.75 <= wind_direction_degree < 326.25: + return 'NW' + if 326.25 <= wind_direction_degree < 348.75: + return 'NNW' + return 'N' diff --git a/homeassistant/components/homematicip_cloud/weather.py b/homeassistant/components/homematicip_cloud/weather.py new file mode 100644 index 00000000000..5a6261195da --- /dev/null +++ b/homeassistant/components/homematicip_cloud/weather.py @@ -0,0 +1,93 @@ + +"""Support for HomematicIP Cloud weather devices.""" +import logging + +from homeassistant.components.homematicip_cloud import ( + DOMAIN as HMIPC_DOMAIN, HMIPC_HAPID, HomematicipGenericDevice) +from homeassistant.components.weather import WeatherEntity + +DEPENDENCIES = ['homematicip_cloud'] + +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up the HomematicIP Cloud weather sensor.""" + pass + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up the HomematicIP weather sensor from a config entry.""" + from homematicip.aio.device import ( + AsyncWeatherSensor, AsyncWeatherSensorPlus, AsyncWeatherSensorPro, + ) + + home = hass.data[HMIPC_DOMAIN][config_entry.data[HMIPC_HAPID]].home + devices = [] + for device in home.devices: + if isinstance(device, AsyncWeatherSensorPro): + devices.append(HomematicipWeatherSensorPro(home, device)) + elif isinstance(device, (AsyncWeatherSensor, AsyncWeatherSensorPlus)): + devices.append(HomematicipWeatherSensor(home, device)) + + if devices: + async_add_entities(devices) + + +class HomematicipWeatherSensor(HomematicipGenericDevice, WeatherEntity): + """representation of a HomematicIP Cloud weather sensor plus & basic.""" + + def __init__(self, home, device): + """Initialize the weather sensor.""" + super().__init__(home, device) + + @property + def name(self): + """Return the name of the sensor.""" + return self._device.label + + @property + def temperature(self): + """Return the platform temperature.""" + return self._device.actualTemperature + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return self.hass.config.units.temperature_unit + + @property + def humidity(self): + """Return the humidity.""" + return self._device.humidity + + @property + def wind_speed(self): + """Return the wind speed.""" + return self._device.windSpeed + + @property + def attribution(self): + """Return the attribution.""" + return "Powered by Homematic IP" + + @property + def condition(self): + """Return the current condition.""" + if hasattr(self._device, "raining") and self._device.raining: + return 'rainy' + if self._device.storm: + return 'windy' + if self._device.sunshine: + return 'sunny' + return '' + + +class HomematicipWeatherSensorPro(HomematicipWeatherSensor): + """representation of a HomematicIP weather sensor pro.""" + + @property + def wind_bearing(self): + """Return the wind bearing.""" + return self._device.windDirection diff --git a/tests/components/homematicip_cloud/test_hap.py b/tests/components/homematicip_cloud/test_hap.py index 61ca3300d60..fd20360b8da 100644 --- a/tests/components/homematicip_cloud/test_hap.py +++ b/tests/components/homematicip_cloud/test_hap.py @@ -68,7 +68,7 @@ async def test_hap_setup_works(aioclient_mock): assert await hap.async_setup() is True assert hap.home is home - assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 7 + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 8 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] == \ @@ -111,10 +111,10 @@ async def test_hap_reset_unloads_entry_if_setup(): 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) == 7 + assert len(hass.config_entries.async_forward_entry_setup.mock_calls) == 8 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) == 7 + assert len(hass.config_entries.async_forward_entry_unload.mock_calls) == 8 From d3bab30dbeee0dd614169da8c621c78b37509c38 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 12 Mar 2019 07:54:04 -0700 Subject: [PATCH 205/291] Add cloud status (#21960) * Add cloud status * Expose certificate details * store & reset last state * Fix tests * update tests * update req * fix lint --- homeassistant/components/cloud/__init__.py | 4 +- homeassistant/components/cloud/client.py | 5 + homeassistant/components/cloud/const.py | 1 + homeassistant/components/cloud/http_api.py | 41 ++++++++ homeassistant/components/cloud/prefs.py | 13 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/cloud/test_http_api.py | 108 +++++++++++++-------- tests/components/cloud/test_init.py | 4 + 9 files changed, 133 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 55a6f1ac615..4d493ad6b92 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -23,7 +23,7 @@ from .const import ( CONF_USER_POOL_ID, DOMAIN, MODE_DEV, MODE_PROD) from .prefs import CloudPreferences -REQUIREMENTS = ['hass-nabucasa==0.3'] +REQUIREMENTS = ['hass-nabucasa==0.4'] DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) @@ -162,8 +162,10 @@ async def async_setup(hass, config): """Handle service for cloud.""" if service.service == SERVICE_REMOTE_CONNECT: await cloud.remote.connect() + await prefs.async_update(remote_enabled=True) elif service.service == SERVICE_REMOTE_DISCONNECT: await cloud.remote.disconnect() + await prefs.async_update(remote_enabled=False) hass.services.async_register( DOMAIN, SERVICE_REMOTE_CONNECT, _service_handler) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index c1165091e11..063a9daf00a 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -64,6 +64,11 @@ class CloudClient(Interface): """Return list of cloudhooks.""" return self._prefs.cloudhooks + @property + def remote_autostart(self) -> bool: + """Return true if we want start a remote connection.""" + return self._prefs.remote_enabled + @property def alexa_config(self) -> alexa_sh.Config: """Return Alexa config.""" diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 642672f537c..65e026389f0 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -4,6 +4,7 @@ REQUEST_TIMEOUT = 10 PREF_ENABLE_ALEXA = 'alexa_enabled' PREF_ENABLE_GOOGLE = 'google_enabled' +PREF_ENABLE_REMOTE = 'remote_enabled' PREF_GOOGLE_ALLOW_UNLOCK = 'google_allow_unlock' PREF_CLOUDHOOKS = 'cloudhooks' diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index dd8d740f234..0366675438a 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -3,6 +3,7 @@ import asyncio from functools import wraps import logging +import attr import aiohttp import async_timeout import voluptuous as vol @@ -82,6 +83,10 @@ async def async_setup(hass): WS_TYPE_HOOK_DELETE, websocket_hook_delete, SCHEMA_WS_HOOK_DELETE ) + hass.components.websocket_api.async_register_command( + websocket_remote_connect) + hass.components.websocket_api.async_register_command( + websocket_remote_disconnect) hass.http.register_view(GoogleActionsSyncView) hass.http.register_view(CloudLoginView) hass.http.register_view(CloudLogoutView) @@ -387,6 +392,13 @@ def _account_data(cloud): claims = cloud.claims client = cloud.client + remote = cloud.remote + + # Load remote certificate + if remote.certificate: + certificate = attr.asdict(remote.certificate) + else: + certificate = None return { 'logged_in': True, @@ -397,4 +409,33 @@ def _account_data(cloud): 'google_domains': list(google_sh.DOMAIN_TO_GOOGLE_TYPES), 'alexa_entities': client.alexa_config.should_expose.config, 'alexa_domains': list(alexa_sh.ENTITY_ADAPTERS), + 'remote_domain': remote.instance_domain, + 'remote_connected': remote.is_connected, + 'remote_certificate': certificate, } + + +@_require_cloud_login +@websocket_api.async_response +@websocket_api.websocket_command({ + 'type': 'cloud/remote/connect' +}) +async def websocket_remote_connect(hass, connection, msg): + """Handle request for connect remote.""" + cloud = hass.data[DOMAIN] + await cloud.remote.connect() + await cloud.client.prefs.async_update(remote_enabled=True) + connection.send_result(msg['id'], _account_data(cloud)) + + +@_require_cloud_login +@websocket_api.async_response +@websocket_api.websocket_command({ + 'type': 'cloud/remote/disconnect' +}) +async def websocket_remote_disconnect(hass, connection, msg): + """Handle request for disconnect remote.""" + cloud = hass.data[DOMAIN] + await cloud.remote.disconnect() + await cloud.client.prefs.async_update(remote_enabled=False) + connection.send_result(msg['id'], _account_data(cloud)) diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 32362df2fa9..263c17935cb 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -1,6 +1,6 @@ """Preference management for cloud.""" from .const import ( - DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, + DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE, PREF_GOOGLE_ALLOW_UNLOCK, PREF_CLOUDHOOKS) STORAGE_KEY = DOMAIN @@ -24,6 +24,7 @@ class CloudPreferences: prefs = { PREF_ENABLE_ALEXA: True, PREF_ENABLE_GOOGLE: True, + PREF_ENABLE_REMOTE: False, PREF_GOOGLE_ALLOW_UNLOCK: False, PREF_CLOUDHOOKS: {} } @@ -31,12 +32,13 @@ class CloudPreferences: self._prefs = prefs async def async_update(self, *, google_enabled=_UNDEF, - alexa_enabled=_UNDEF, google_allow_unlock=_UNDEF, - cloudhooks=_UNDEF): + alexa_enabled=_UNDEF, remote_enabled=_UNDEF, + google_allow_unlock=_UNDEF, cloudhooks=_UNDEF): """Update user preferences.""" for key, value in ( (PREF_ENABLE_GOOGLE, google_enabled), (PREF_ENABLE_ALEXA, alexa_enabled), + (PREF_ENABLE_REMOTE, remote_enabled), (PREF_GOOGLE_ALLOW_UNLOCK, google_allow_unlock), (PREF_CLOUDHOOKS, cloudhooks), ): @@ -49,6 +51,11 @@ class CloudPreferences: """Return dictionary version.""" return self._prefs + @property + def remote_enabled(self): + """Return if remote is enabled on start.""" + return self._prefs.get(PREF_ENABLE_REMOTE, False) + @property def alexa_enabled(self): """Return if Alexa is enabled.""" diff --git a/requirements_all.txt b/requirements_all.txt index 443cab1d2e3..31c6a710635 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -524,7 +524,7 @@ habitipy==0.2.0 hangups==0.4.6 # homeassistant.components.cloud -hass-nabucasa==0.3 +hass-nabucasa==0.4 # homeassistant.components.mqtt.server hbmqtt==0.9.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index aa2f7e8fc6f..42351115b61 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -114,7 +114,7 @@ ha-ffmpeg==1.11 hangups==0.4.6 # homeassistant.components.cloud -hass-nabucasa==0.3 +hass-nabucasa==0.4 # homeassistant.components.mqtt.server hbmqtt==0.9.4 diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 50b31dd780f..3ab4b1030fa 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -9,7 +9,6 @@ from hass_nabucasa.const import STATE_CONNECTED from homeassistant.components.cloud.const import ( PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_ALLOW_UNLOCK, DOMAIN) -from homeassistant.util import dt as dt_util from tests.common import mock_coro @@ -26,6 +25,15 @@ def mock_auth(): yield +@pytest.fixture() +def mock_cloud_login(hass, setup_api): + """Mock cloud is logged in.""" + hass.data[DOMAIN].id_token = jwt.encode({ + 'email': 'hello@home-assistant.io', + 'custom:sub-exp': '2018-01-03' + }, 'test') + + @pytest.fixture(autouse=True) def setup_api(hass, aioclient_mock): """Initialize HTTP API.""" @@ -319,12 +327,9 @@ async def test_resend_confirm_view_unknown_error(mock_cognito, cloud_client): assert req.status == 502 -async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture): +async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture, + mock_cloud_login): """Test querying the status.""" - hass.data[DOMAIN].id_token = jwt.encode({ - 'email': 'hello@home-assistant.io', - 'custom:sub-exp': '2018-01-03' - }, 'test') hass.data[DOMAIN].iot.state = STATE_CONNECTED client = await hass_ws_client(hass) @@ -357,6 +362,9 @@ async def test_websocket_status(hass, hass_ws_client, mock_cloud_fixture): 'exclude_entities': [], }, 'google_domains': ['light'], + 'remote_domain': None, + 'remote_connected': False, + 'remote_certificate': None, } @@ -375,13 +383,9 @@ async def test_websocket_status_not_logged_in(hass, hass_ws_client): async def test_websocket_subscription_reconnect( - hass, hass_ws_client, aioclient_mock, mock_auth): + hass, hass_ws_client, aioclient_mock, mock_auth, mock_cloud_login): """Test querying the status and connecting because valid account.""" aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={'provider': 'stripe'}) - hass.data[DOMAIN].id_token = jwt.encode({ - 'email': 'hello@home-assistant.io', - 'custom:sub-exp': dt_util.utcnow().date().isoformat() - }, 'test') client = await hass_ws_client(hass) with patch( @@ -403,14 +407,10 @@ async def test_websocket_subscription_reconnect( async def test_websocket_subscription_no_reconnect_if_connected( - hass, hass_ws_client, aioclient_mock, mock_auth): + hass, hass_ws_client, aioclient_mock, mock_auth, mock_cloud_login): """Test querying the status and not reconnecting because still expired.""" aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={'provider': 'stripe'}) hass.data[DOMAIN].iot.state = STATE_CONNECTED - hass.data[DOMAIN].id_token = jwt.encode({ - 'email': 'hello@home-assistant.io', - 'custom:sub-exp': dt_util.utcnow().date().isoformat() - }, 'test') client = await hass_ws_client(hass) with patch( @@ -432,13 +432,9 @@ async def test_websocket_subscription_no_reconnect_if_connected( async def test_websocket_subscription_no_reconnect_if_expired( - hass, hass_ws_client, aioclient_mock, mock_auth): + hass, hass_ws_client, aioclient_mock, mock_auth, mock_cloud_login): """Test querying the status and not reconnecting because still expired.""" aioclient_mock.get(SUBSCRIPTION_INFO_URL, json={'provider': 'stripe'}) - hass.data[DOMAIN].id_token = jwt.encode({ - 'email': 'hello@home-assistant.io', - 'custom:sub-exp': '2018-01-03' - }, 'test') client = await hass_ws_client(hass) with patch( @@ -460,13 +456,10 @@ async def test_websocket_subscription_no_reconnect_if_expired( async def test_websocket_subscription_fail(hass, hass_ws_client, - aioclient_mock, mock_auth): + aioclient_mock, mock_auth, + mock_cloud_login): """Test querying the status.""" aioclient_mock.get(SUBSCRIPTION_INFO_URL, status=500) - hass.data[DOMAIN].id_token = jwt.encode({ - 'email': 'hello@home-assistant.io', - 'custom:sub-exp': '2018-01-03' - }, 'test') client = await hass_ws_client(hass) await client.send_json({ 'id': 5, @@ -494,15 +487,12 @@ async def test_websocket_subscription_not_logged_in(hass, hass_ws_client): async def test_websocket_update_preferences(hass, hass_ws_client, - aioclient_mock, setup_api): + aioclient_mock, setup_api, + mock_cloud_login): """Test updating preference.""" assert setup_api[PREF_ENABLE_GOOGLE] assert setup_api[PREF_ENABLE_ALEXA] assert setup_api[PREF_GOOGLE_ALLOW_UNLOCK] - hass.data[DOMAIN].id_token = jwt.encode({ - 'email': 'hello@home-assistant.io', - 'custom:sub-exp': '2018-01-03' - }, 'test') client = await hass_ws_client(hass) await client.send_json({ 'id': 5, @@ -519,12 +509,9 @@ async def test_websocket_update_preferences(hass, hass_ws_client, assert not setup_api[PREF_GOOGLE_ALLOW_UNLOCK] -async def test_enabling_webhook(hass, hass_ws_client, setup_api): +async def test_enabling_webhook(hass, hass_ws_client, setup_api, + mock_cloud_login): """Test we call right code to enable webhooks.""" - hass.data[DOMAIN].id_token = jwt.encode({ - 'email': 'hello@home-assistant.io', - 'custom:sub-exp': '2018-01-03' - }, 'test') client = await hass_ws_client(hass) with patch( 'hass_nabucasa.cloudhooks.Cloudhooks.async_create', @@ -542,12 +529,9 @@ async def test_enabling_webhook(hass, hass_ws_client, setup_api): assert mock_enable.mock_calls[0][1][0] == 'mock-webhook-id' -async def test_disabling_webhook(hass, hass_ws_client, setup_api): +async def test_disabling_webhook(hass, hass_ws_client, setup_api, + mock_cloud_login): """Test we call right code to disable webhooks.""" - hass.data[DOMAIN].id_token = jwt.encode({ - 'email': 'hello@home-assistant.io', - 'custom:sub-exp': '2018-01-03' - }, 'test') client = await hass_ws_client(hass) with patch( 'hass_nabucasa.cloudhooks.Cloudhooks.async_delete', @@ -563,3 +547,45 @@ async def test_disabling_webhook(hass, hass_ws_client, setup_api): assert len(mock_disable.mock_calls) == 1 assert mock_disable.mock_calls[0][1][0] == 'mock-webhook-id' + + +async def test_enabling_remote(hass, hass_ws_client, setup_api, + mock_cloud_login): + """Test we call right code to enable remote UI.""" + client = await hass_ws_client(hass) + cloud = hass.data[DOMAIN] + + with patch( + 'hass_nabucasa.remote.RemoteUI.connect', + return_value=mock_coro() + ) as mock_connect: + await client.send_json({ + 'id': 5, + 'type': 'cloud/remote/connect', + }) + response = await client.receive_json() + assert response['success'] + assert cloud.client.remote_autostart + + assert len(mock_connect.mock_calls) == 1 + + +async def test_disabling_remote(hass, hass_ws_client, setup_api, + mock_cloud_login): + """Test we call right code to disable remote UI.""" + client = await hass_ws_client(hass) + cloud = hass.data[DOMAIN] + + with patch( + 'hass_nabucasa.remote.RemoteUI.disconnect', + return_value=mock_coro() + ) as mock_disconnect: + await client.send_json({ + 'id': 5, + 'type': 'cloud/remote/disconnect', + }) + response = await client.receive_json() + assert response['success'] + assert not cloud.client.remote_autostart + + assert len(mock_disconnect.mock_calls) == 1 diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index 818e67c9804..d3e2e50f3a7 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -39,6 +39,8 @@ async def test_constructor_loads_info_from_config(): async def test_remote_services(hass, mock_cloud_fixture): """Setup cloud component and test services.""" + cloud = hass.data[DOMAIN] + assert hass.services.has_service(DOMAIN, 'remote_connect') assert hass.services.has_service(DOMAIN, 'remote_disconnect') @@ -48,6 +50,7 @@ async def test_remote_services(hass, mock_cloud_fixture): await hass.services.async_call(DOMAIN, "remote_connect", blocking=True) assert mock_connect.called + assert cloud.client.remote_autostart with patch( "hass_nabucasa.remote.RemoteUI.disconnect", return_value=mock_coro() @@ -56,6 +59,7 @@ async def test_remote_services(hass, mock_cloud_fixture): DOMAIN, "remote_disconnect", blocking=True) assert mock_disconnect.called + assert not cloud.client.remote_autostart async def test_startup_shutdown_events(hass, mock_cloud_fixture): From 1444a684e02fab99648f6e5daea9f28b6cf45c10 Mon Sep 17 00:00:00 2001 From: Sidney Date: Tue, 12 Mar 2019 16:51:51 +0100 Subject: [PATCH 206/291] Fix MagicHome LEDs with flux_led component (#20733) * bug fixing for MagicHome LEDs with flux_led component. * corrections of the fixes for flux_led * now asyncio sleep and turn on with brigthness possible * indention fix with flux_led * async now works * houndci fixes * little fixes for flux_led * self._color fix for flux_led * Add docstring --- homeassistant/components/light/flux_led.py | 62 +++++++++++++--------- 1 file changed, 38 insertions(+), 24 deletions(-) diff --git a/homeassistant/components/light/flux_led.py b/homeassistant/components/light/flux_led.py index bfbb98ad57e..17c288da6c2 100644 --- a/homeassistant/components/light/flux_led.py +++ b/homeassistant/components/light/flux_led.py @@ -7,6 +7,8 @@ https://home-assistant.io/components/light.flux_led/ import logging import socket import random +from asyncio import sleep +from functools import partial import voluptuous as vol @@ -170,6 +172,8 @@ class FluxLight(Light): self._custom_effect = device[CONF_CUSTOM_EFFECT] self._bulb = None self._error_reported = False + self._color = (0, 0, 100) + self._white_value = 0 def _connect(self): """Connect to Flux light.""" @@ -210,14 +214,14 @@ class FluxLight(Light): def brightness(self): """Return the brightness of this light between 0..255.""" if self._mode == MODE_WHITE: - return self.white_value + return self._white_value - return self._bulb.brightness + return int(self._color[2] / 100 * 255) @property def hs_color(self): """Return the color property.""" - return color_util.color_RGB_to_hs(*self._bulb.getRgb()) + return self._color[0:2] @property def supported_features(self): @@ -233,7 +237,7 @@ class FluxLight(Light): @property def white_value(self): """Return the white value of this light between 0..255.""" - return self._bulb.getRgbw()[3] + return self._white_value @property def effect_list(self): @@ -257,24 +261,25 @@ class FluxLight(Light): return None - def turn_on(self, **kwargs): + async def async_turn_on(self, **kwargs): + """Turn the specified or all lights on and wait for state.""" + await self.hass.async_add_executor_job(partial(self._turn_on, + **kwargs)) + # The bulb needs a second to tell its new values, + # so we wait 2 seconds before updating + await sleep(2) + + def _turn_on(self, **kwargs): """Turn the specified or all lights on.""" - if not self.is_on: - self._bulb.turnOn() + self._bulb.turnOn() hs_color = kwargs.get(ATTR_HS_COLOR) - - if hs_color: - rgb = color_util.color_hs_to_RGB(*hs_color) - else: - rgb = None - brightness = kwargs.get(ATTR_BRIGHTNESS) effect = kwargs.get(ATTR_EFFECT) white = kwargs.get(ATTR_WHITE_VALUE) # Show warning if effect set with rgb, brightness, or white level - if effect and (brightness or white or rgb): + if effect and (brightness or white or hs_color): _LOGGER.warning("RGB, brightness and white level are ignored when" " an effect is specified for a flux bulb") @@ -302,12 +307,11 @@ class FluxLight(Light): if brightness is None: brightness = self.brightness - # 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 + if hs_color: + self._color = (hs_color[0], hs_color[1], brightness / 255 * 100) + elif brightness and (hs_color is None) and self._mode != MODE_WHITE: + self._color = (self._color[0], self._color[1], + brightness / 255 * 100) # handle W only mode (use brightness instead of white value) if self._mode == MODE_WHITE: @@ -315,11 +319,14 @@ class FluxLight(Light): # handle RGBW mode elif self._mode == MODE_RGBW: - self._bulb.setRgbw(*tuple(rgb), w=white, brightness=brightness) - + if white is None: + self._bulb.setRgbw(*color_util.color_hsv_to_RGB(*self._color)) + else: + self._bulb.setRgbw(w=white) # handle RGB mode else: - self._bulb.setRgb(*tuple(rgb), brightness=brightness) + self._bulb.setRgb(*color_util.color_hsv_to_RGB(*self._color)) + return def turn_off(self, **kwargs): """Turn the specified or all lights off.""" @@ -331,6 +338,10 @@ class FluxLight(Light): try: self._connect() self._error_reported = False + if self._bulb.getRgb() != (0, 0, 0): + color = self._bulb.getRgbw() + self._color = color_util.color_RGB_to_hsv(*color[0:3]) + self._white_value = color[3] except socket.error: self._disconnect() if not self._error_reported: @@ -338,5 +349,8 @@ class FluxLight(Light): self._ipaddr, self._name) self._error_reported = True return - self._bulb.update_state(retry=2) + if self._bulb.getRgb() != (0, 0, 0): + color = self._bulb.getRgbw() + self._color = color_util.color_RGB_to_hsv(*color[0:3]) + self._white_value = color[3] From 9178ac17ade7da3885c3d1c961fa6019f2c9de2b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 12 Mar 2019 11:30:50 -0700 Subject: [PATCH 207/291] Updated frontend to 20190312.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 3fa5791b7bc..e0bfbbf63c2 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -21,7 +21,7 @@ from homeassistant.loader import bind_hass from .storage import async_setup_frontend_storage -REQUIREMENTS = ['home-assistant-frontend==20190309.0'] +REQUIREMENTS = ['home-assistant-frontend==20190312.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 31c6a710635..b5c614d4576 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -554,7 +554,7 @@ hole==0.3.0 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190309.0 +home-assistant-frontend==20190312.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 42351115b61..30bd295dcd0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -126,7 +126,7 @@ hdate==0.8.7 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190309.0 +home-assistant-frontend==20190312.0 # homeassistant.components.homekit_controller homekit==0.12.2 From d635111e4fa44c514eb1f0c3cfecf44a6280b40e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 12 Mar 2019 11:31:17 -0700 Subject: [PATCH 208/291] Update translations --- .../ambient_station/.translations/th.json | 13 ++++++++ .../components/auth/.translations/th.json | 11 +++++++ .../components/daikin/.translations/th.json | 12 +++++++ .../components/deconz/.translations/nl.json | 4 +-- .../components/ebusd/.translations/nl.json | 6 ++++ .../components/ebusd/.translations/th.json | 5 +++ .../emulated_roku/.translations/th.json | 12 +++++++ .../components/esphome/.translations/it.json | 2 +- .../components/esphome/.translations/nl.json | 6 ++-- .../components/esphome/.translations/th.json | 15 +++++++++ .../components/hangouts/.translations/th.json | 12 +++++++ .../homekit_controller/.translations/en.json | 2 +- .../homekit_controller/.translations/ko.json | 4 +-- .../homekit_controller/.translations/lb.json | 3 ++ .../homekit_controller/.translations/nl.json | 29 ++++++++++++++++ .../homekit_controller/.translations/ru.json | 2 +- .../homekit_controller/.translations/th.json | 33 +++++++++++++++++++ .../homekit_controller/.translations/vi.json | 20 +++++++++++ .../components/ifttt/.translations/th.json | 5 +++ .../components/ipma/.translations/th.json | 15 +++++++++ .../components/mqtt/.translations/th.json | 16 +++++++++ .../components/ps4/.translations/nl.json | 15 +++++++++ .../components/ps4/.translations/th.json | 19 +++++++++++ .../rainmachine/.translations/th.json | 11 +++++++ .../simplisafe/.translations/th.json | 11 +++++++ .../smartthings/.translations/nl.json | 3 +- .../smartthings/.translations/th.json | 11 +++++++ .../components/smhi/.translations/th.json | 13 ++++++++ .../tellduslive/.translations/th.json | 11 +++++++ .../components/toon/.translations/nl.json | 4 +-- .../components/unifi/.translations/th.json | 12 +++++++ 31 files changed, 324 insertions(+), 13 deletions(-) create mode 100644 homeassistant/components/ambient_station/.translations/th.json create mode 100644 homeassistant/components/auth/.translations/th.json create mode 100644 homeassistant/components/daikin/.translations/th.json create mode 100644 homeassistant/components/ebusd/.translations/nl.json create mode 100644 homeassistant/components/ebusd/.translations/th.json create mode 100644 homeassistant/components/emulated_roku/.translations/th.json create mode 100644 homeassistant/components/esphome/.translations/th.json create mode 100644 homeassistant/components/hangouts/.translations/th.json create mode 100644 homeassistant/components/homekit_controller/.translations/nl.json create mode 100644 homeassistant/components/homekit_controller/.translations/th.json create mode 100644 homeassistant/components/homekit_controller/.translations/vi.json create mode 100644 homeassistant/components/ifttt/.translations/th.json create mode 100644 homeassistant/components/ipma/.translations/th.json create mode 100644 homeassistant/components/mqtt/.translations/th.json create mode 100644 homeassistant/components/ps4/.translations/th.json create mode 100644 homeassistant/components/rainmachine/.translations/th.json create mode 100644 homeassistant/components/simplisafe/.translations/th.json create mode 100644 homeassistant/components/smartthings/.translations/th.json create mode 100644 homeassistant/components/smhi/.translations/th.json create mode 100644 homeassistant/components/tellduslive/.translations/th.json create mode 100644 homeassistant/components/unifi/.translations/th.json diff --git a/homeassistant/components/ambient_station/.translations/th.json b/homeassistant/components/ambient_station/.translations/th.json new file mode 100644 index 00000000000..9f08ed5f1a2 --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/th.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "api_key": "\u0e04\u0e35\u0e22\u0e4c API", + "app_key": "\u0e23\u0e2b\u0e31\u0e2a\u0e41\u0e2d\u0e1b\u0e1e\u0e25\u0e34\u0e40\u0e04\u0e0a\u0e31\u0e19" + }, + "title": "\u0e01\u0e23\u0e2d\u0e01\u0e02\u0e49\u0e2d\u0e21\u0e39\u0e25\u0e02\u0e2d\u0e07\u0e04\u0e38\u0e13" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/auth/.translations/th.json b/homeassistant/components/auth/.translations/th.json new file mode 100644 index 00000000000..735b7e2fad5 --- /dev/null +++ b/homeassistant/components/auth/.translations/th.json @@ -0,0 +1,11 @@ +{ + "mfa_setup": { + "notify": { + "step": { + "setup": { + "title": "\u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a\u0e01\u0e32\u0e23\u0e15\u0e34\u0e14\u0e15\u0e31\u0e49\u0e07" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/daikin/.translations/th.json b/homeassistant/components/daikin/.translations/th.json new file mode 100644 index 00000000000..8f0fdda3711 --- /dev/null +++ b/homeassistant/components/daikin/.translations/th.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "\u0e42\u0e2e\u0e2a\u0e15\u0e4c" + } + } + }, + "title": "Daikin AC" + } +} \ No newline at end of file diff --git a/homeassistant/components/deconz/.translations/nl.json b/homeassistant/components/deconz/.translations/nl.json index 9084d22f4a3..d4b65f16552 100644 --- a/homeassistant/components/deconz/.translations/nl.json +++ b/homeassistant/components/deconz/.translations/nl.json @@ -12,12 +12,12 @@ "init": { "data": { "host": "Host", - "port": "Poort (standaard: '80')" + "port": "Poort" }, "title": "Definieer deCONZ gateway" }, "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\"", + "description": "Ontgrendel je deCONZ gateway om te registreren met Home Assistant.\n\n1. Ga naar deCONZ systeeminstellingen (Instellingen -> Gateway -> Geavanceerd)\n2. Druk op de knop \"Gateway ontgrendelen\"", "title": "Koppel met deCONZ" }, "options": { diff --git a/homeassistant/components/ebusd/.translations/nl.json b/homeassistant/components/ebusd/.translations/nl.json new file mode 100644 index 00000000000..db4627790fd --- /dev/null +++ b/homeassistant/components/ebusd/.translations/nl.json @@ -0,0 +1,6 @@ +{ + "state": { + "day": "Dag", + "night": "Nacht" + } +} \ No newline at end of file diff --git a/homeassistant/components/ebusd/.translations/th.json b/homeassistant/components/ebusd/.translations/th.json new file mode 100644 index 00000000000..92a8c7969a8 --- /dev/null +++ b/homeassistant/components/ebusd/.translations/th.json @@ -0,0 +1,5 @@ +{ + "state": { + "night": "\u0e01\u0e25\u0e32\u0e07\u0e04\u0e37\u0e19" + } +} \ No newline at end of file diff --git a/homeassistant/components/emulated_roku/.translations/th.json b/homeassistant/components/emulated_roku/.translations/th.json new file mode 100644 index 00000000000..c2570a457bc --- /dev/null +++ b/homeassistant/components/emulated_roku/.translations/th.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host_ip": "\u0e42\u0e2e\u0e2a\u0e15\u0e4c IP", + "name": "\u0e0a\u0e37\u0e48\u0e2d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/esphome/.translations/it.json b/homeassistant/components/esphome/.translations/it.json index d3c51f0497f..47047a95560 100644 --- a/homeassistant/components/esphome/.translations/it.json +++ b/homeassistant/components/esphome/.translations/it.json @@ -13,7 +13,7 @@ "data": { "password": "Password" }, - "description": "Inserisci la password che hai impostato nella tua configurazione.", + "description": "Inserisci la password per {name} che hai impostato nella tua configurazione.", "title": "Inserisci la password" }, "user": { diff --git a/homeassistant/components/esphome/.translations/nl.json b/homeassistant/components/esphome/.translations/nl.json index 56d77377808..aba738f4e0f 100644 --- a/homeassistant/components/esphome/.translations/nl.json +++ b/homeassistant/components/esphome/.translations/nl.json @@ -13,12 +13,12 @@ "data": { "password": "Wachtwoord" }, - "description": "Voer het wachtwoord in dat u in uw configuratie hebt ingesteld.", + "description": "Voer het wachtwoord in dat u in uw configuratie heeft ingesteld voor {name}.", "title": "Voer wachtwoord in" }, "discovery_confirm": { - "description": "Wil je de ESPHome-node `{name}` toevoegen aan Home Assistant?", - "title": "Ontdekte ESPHome node" + "description": "Wil je de ESPHome-node `{name}` toevoegen aan de Home Assistant?", + "title": "ESPHome node ontdekt" }, "user": { "data": { diff --git a/homeassistant/components/esphome/.translations/th.json b/homeassistant/components/esphome/.translations/th.json new file mode 100644 index 00000000000..ceab9b6e11b --- /dev/null +++ b/homeassistant/components/esphome/.translations/th.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "invalid_password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19\u0e44\u0e21\u0e48\u0e16\u0e39\u0e01\u0e15\u0e49\u0e2d\u0e07!" + }, + "step": { + "authenticate": { + "data": { + "password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19" + }, + "title": "\u0e43\u0e2a\u0e48\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/hangouts/.translations/th.json b/homeassistant/components/hangouts/.translations/th.json new file mode 100644 index 00000000000..ae7fc861b77 --- /dev/null +++ b/homeassistant/components/hangouts/.translations/th.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19" + }, + "description": "\u0e27\u0e48\u0e32\u0e07\u0e40\u0e1b\u0e25\u0e48\u0e32" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/en.json b/homeassistant/components/homekit_controller/.translations/en.json index 857fca58e43..591e035ed18 100644 --- a/homeassistant/components/homekit_controller/.translations/en.json +++ b/homeassistant/components/homekit_controller/.translations/en.json @@ -18,7 +18,7 @@ "pairing_code": "Pairing Code" }, "description": "Enter your HomeKit pairing code to use this accessory", - "title": "Pair with {{ model }}" + "title": "Pair with HomeKit Accessory" }, "user": { "data": { diff --git a/homeassistant/components/homekit_controller/.translations/ko.json b/homeassistant/components/homekit_controller/.translations/ko.json index 5617fc85746..525604cd96b 100644 --- a/homeassistant/components/homekit_controller/.translations/ko.json +++ b/homeassistant/components/homekit_controller/.translations/ko.json @@ -18,14 +18,14 @@ "pairing_code": "\ud398\uc5b4\ub9c1 \ucf54\ub4dc" }, "description": "\uc774 \uc561\uc138\uc11c\ub9ac\ub97c \uc0ac\uc6a9\ud558\ub824\uba74 HomeKit \ud398\uc5b4\ub9c1 \ucf54\ub4dc\ub97c \uc785\ub825\ud574\uc8fc\uc138\uc694", - "title": "{{ model }} \ud398\uc5b4\ub9c1" + "title": "HomeKit \uc561\uc138\uc11c\ub9ac \ud398\uc5b4\ub9c1" }, "user": { "data": { "device": "\uc7a5\uce58" }, "description": "\ud398\uc5b4\ub9c1 \ud560 \uc7a5\uce58\ub97c \uc120\ud0dd\ud574\uc8fc\uc138\uc694", - "title": "HomeKit \uc561\uc138\uc11c\ub9ac\uc640 \ud398\uc5b4\ub9c1" + "title": "HomeKit \uc561\uc138\uc11c\ub9ac \ud398\uc5b4\ub9c1" } }, "title": "HomeKit \uc561\uc138\uc11c\ub9ac" diff --git a/homeassistant/components/homekit_controller/.translations/lb.json b/homeassistant/components/homekit_controller/.translations/lb.json index 56680552161..6d338689d1f 100644 --- a/homeassistant/components/homekit_controller/.translations/lb.json +++ b/homeassistant/components/homekit_controller/.translations/lb.json @@ -2,6 +2,9 @@ "config": { "abort": { "already_configured": "Accessoire ass schon mat d\u00ebsem Kontroller konfigur\u00e9iert.", + "already_paired": "D\u00ebsen Accessoire ass schonn mat engem aneren Apparat verbonnen. S\u00ebtzt den Apparat op Wierksastellungen zer\u00e9ck an prob\u00e9iert nach emol w.e.g.", + "ignored_model": "HomeKit Support fir d\u00ebse Modell ass block\u00e9iert well eng m\u00e9i komplett nativ Integratioun disponibel ass.", + "invalid_config_entry": "D\u00ebsen Apparat mellt sech prett fir ze verbanne mee et g\u00ebtt schonn eng Entr\u00e9e am Home Assistant d\u00e9i ee Konflikt duerstellt welch fir d'\u00e9ischt muss erausgeholl ginn.", "no_devices": "Keng net verbonnen Apparater fonnt" }, "error": { diff --git a/homeassistant/components/homekit_controller/.translations/nl.json b/homeassistant/components/homekit_controller/.translations/nl.json new file mode 100644 index 00000000000..fcabd40d3be --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/nl.json @@ -0,0 +1,29 @@ +{ + "config": { + "abort": { + "invalid_config_entry": "Dit apparaat geeft aan dat het gereed is om te koppelen, maar er is al een conflicterend configuratie-item voor in de Home Assistant dat eerst moet worden verwijderd.", + "no_devices": "Er zijn geen gekoppelde apparaten gevonden" + }, + "error": { + "authentication_error": "Onjuiste HomeKit-code. Controleer het en probeer het opnieuw.", + "unable_to_pair": "Kan niet koppelen, probeer het opnieuw.", + "unknown_error": "Apparaat meldde een onbekende fout. Koppelen mislukt." + }, + "step": { + "pair": { + "data": { + "pairing_code": "Koppelingscode" + }, + "title": "Koppel met {{model}}" + }, + "user": { + "data": { + "device": "Apparaat" + }, + "description": "Selecteer het apparaat waarmee u wilt koppelen", + "title": "Koppel met HomeKit accessoire" + } + }, + "title": "HomeKit Accessoires" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/ru.json b/homeassistant/components/homekit_controller/.translations/ru.json index 21617f0f70d..983afda5e9d 100644 --- a/homeassistant/components/homekit_controller/.translations/ru.json +++ b/homeassistant/components/homekit_controller/.translations/ru.json @@ -18,7 +18,7 @@ "pairing_code": "\u041a\u043e\u0434 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f" }, "description": "\u0412\u0432\u0435\u0434\u0438\u0442\u0435 \u043a\u043e\u0434 \u0441\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u044f HomeKit, \u0447\u0442\u043e\u0431\u044b \u0438\u0441\u043f\u043e\u043b\u044c\u0437\u043e\u0432\u0430\u0442\u044c \u044d\u0442\u043e\u0442 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440", - "title": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0441 {{model}}" + "title": "\u0421\u043e\u043f\u0440\u044f\u0436\u0435\u043d\u0438\u0435 \u0441 \u0430\u043a\u0441\u0435\u0441\u0441\u0443\u0430\u0440\u043e\u043c HomeKit" }, "user": { "data": { diff --git a/homeassistant/components/homekit_controller/.translations/th.json b/homeassistant/components/homekit_controller/.translations/th.json new file mode 100644 index 00000000000..a67945c8135 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/th.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21\u0e19\u0e35\u0e49\u0e44\u0e14\u0e49\u0e23\u0e31\u0e1a\u0e01\u0e32\u0e23\u0e01\u0e33\u0e2b\u0e19\u0e14\u0e04\u0e48\u0e32\u0e14\u0e49\u0e27\u0e22\u0e15\u0e31\u0e27\u0e04\u0e27\u0e1a\u0e04\u0e38\u0e21\u0e19\u0e35\u0e49\u0e41\u0e25\u0e49\u0e27", + "already_paired": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21\u0e19\u0e35\u0e49\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e01\u0e31\u0e1a\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e2d\u0e37\u0e48\u0e19\u0e41\u0e25\u0e49\u0e27 \u0e42\u0e1b\u0e23\u0e14\u0e23\u0e35\u0e40\u0e0b\u0e47\u0e15\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21\u0e41\u0e25\u0e49\u0e27\u0e25\u0e2d\u0e07\u0e2d\u0e35\u0e01\u0e04\u0e23\u0e31\u0e49\u0e07", + "ignored_model": "\u0e01\u0e32\u0e23\u0e2a\u0e19\u0e31\u0e1a\u0e2a\u0e19\u0e38\u0e19\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c HomeKit \u0e23\u0e38\u0e48\u0e19\u0e19\u0e35\u0e49\u0e16\u0e39\u0e01\u0e1b\u0e34\u0e14\u0e01\u0e31\u0e49\u0e19\u0e44\u0e27\u0e49 \u0e41\u0e15\u0e48\u0e01\u0e47\u0e21\u0e35\u0e01\u0e32\u0e23\u0e17\u0e33\u0e07\u0e32\u0e19\u0e1a\u0e32\u0e07\u0e2d\u0e22\u0e48\u0e32\u0e07\u0e17\u0e35\u0e48\u0e43\u0e0a\u0e49\u0e07\u0e32\u0e19\u0e44\u0e14\u0e49", + "invalid_config_entry": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e19\u0e35\u0e49\u0e1a\u0e2d\u0e01\u0e27\u0e48\u0e32\u0e01\u0e33\u0e25\u0e31\u0e07\u0e1e\u0e23\u0e49\u0e2d\u0e21\u0e17\u0e35\u0e48\u0e08\u0e30\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48 \u0e41\u0e15\u0e48\u0e21\u0e31\u0e19\u0e21\u0e35\u0e01\u0e32\u0e23\u0e01\u0e33\u0e2b\u0e19\u0e14\u0e04\u0e48\u0e32\u0e17\u0e35\u0e48\u0e02\u0e31\u0e14\u0e41\u0e22\u0e49\u0e07\u0e01\u0e31\u0e19\u0e2d\u0e22\u0e39\u0e48 Home Assistant \u0e40\u0e25\u0e22\u0e17\u0e33\u0e01\u0e32\u0e23\u0e25\u0e1a\u0e17\u0e34\u0e49\u0e07", + "no_devices": "\u0e44\u0e21\u0e48\u0e1e\u0e1a\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e17\u0e35\u0e48\u0e08\u0e30\u0e43\u0e0a\u0e49\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e43\u0e14\u0e46 \u0e40\u0e25\u0e22" + }, + "error": { + "authentication_error": "\u0e23\u0e2b\u0e31\u0e2a\u0e01\u0e32\u0e23\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48 HomeKit \u0e44\u0e21\u0e48\u0e16\u0e39\u0e01\u0e15\u0e49\u0e2d\u0e07 \u0e01\u0e23\u0e38\u0e13\u0e32\u0e15\u0e23\u0e27\u0e08\u0e2a\u0e2d\u0e1a\u0e41\u0e25\u0e30\u0e25\u0e2d\u0e07\u0e2d\u0e35\u0e01\u0e04\u0e23\u0e31\u0e49\u0e07", + "unable_to_pair": "\u0e44\u0e21\u0e48\u0e2a\u0e32\u0e21\u0e32\u0e23\u0e16\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e44\u0e14\u0e49 \u0e42\u0e1b\u0e23\u0e14\u0e25\u0e2d\u0e07\u0e2d\u0e35\u0e01\u0e04\u0e23\u0e31\u0e49\u0e07", + "unknown_error": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e23\u0e32\u0e22\u0e07\u0e32\u0e19\u0e02\u0e49\u0e2d\u0e1c\u0e34\u0e14\u0e1e\u0e25\u0e32\u0e14\u0e17\u0e35\u0e48\u0e44\u0e21\u0e48\u0e23\u0e39\u0e49\u0e08\u0e31\u0e01 \u0e01\u0e32\u0e23\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e25\u0e49\u0e21\u0e40\u0e2b\u0e25\u0e27" + }, + "step": { + "pair": { + "data": { + "pairing_code": "\u0e23\u0e2b\u0e31\u0e2a\u0e01\u0e32\u0e23\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48" + }, + "description": "\u0e1b\u0e49\u0e2d\u0e19\u0e23\u0e2b\u0e31\u0e2a\u0e01\u0e32\u0e23\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48 HomeKit \u0e02\u0e2d\u0e07\u0e04\u0e38\u0e13\u0e40\u0e1e\u0e37\u0e48\u0e2d\u0e43\u0e0a\u0e49\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21\u0e19\u0e35\u0e49", + "title": "\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e01\u0e31\u0e1a {{ model }}" + }, + "user": { + "data": { + "device": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c" + }, + "description": "\u0e40\u0e25\u0e37\u0e2d\u0e01\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e17\u0e35\u0e48\u0e04\u0e38\u0e13\u0e15\u0e49\u0e2d\u0e07\u0e01\u0e32\u0e23\u0e08\u0e30\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48", + "title": "\u0e08\u0e31\u0e1a\u0e04\u0e39\u0e48\u0e01\u0e31\u0e1a\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21 HomeKit" + } + }, + "title": "\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c\u0e40\u0e2a\u0e23\u0e34\u0e21 HomeKit" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/vi.json b/homeassistant/components/homekit_controller/.translations/vi.json new file mode 100644 index 00000000000..cc16ebc70c4 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/vi.json @@ -0,0 +1,20 @@ +{ + "config": { + "step": { + "pair": { + "data": { + "pairing_code": "M\u00e3 k\u1ebft n\u1ed1i" + }, + "title": "K\u1ebft n\u1ed1i v\u1edbi Ph\u1ee5 ki\u1ec7n HomeKit" + }, + "user": { + "data": { + "device": "Thi\u1ebft b\u1ecb" + }, + "description": "Ch\u1ecdn thi\u1ebft b\u1ecb b\u1ea1n mu\u1ed1n k\u1ebft n\u1ed1i", + "title": "K\u1ebft n\u1ed1i v\u1edbi Ph\u1ee5 ki\u1ec7n HomeKit" + } + }, + "title": "Ph\u1ee5 ki\u1ec7n HomeKit" + } +} \ No newline at end of file diff --git a/homeassistant/components/ifttt/.translations/th.json b/homeassistant/components/ifttt/.translations/th.json new file mode 100644 index 00000000000..077956287b3 --- /dev/null +++ b/homeassistant/components/ifttt/.translations/th.json @@ -0,0 +1,5 @@ +{ + "config": { + "title": "IFTTT" + } +} \ No newline at end of file diff --git a/homeassistant/components/ipma/.translations/th.json b/homeassistant/components/ipma/.translations/th.json new file mode 100644 index 00000000000..0be7c037231 --- /dev/null +++ b/homeassistant/components/ipma/.translations/th.json @@ -0,0 +1,15 @@ +{ + "config": { + "error": { + "name_exists": "\u0e21\u0e35\u0e0a\u0e37\u0e48\u0e2d\u0e19\u0e35\u0e49\u0e2d\u0e22\u0e39\u0e48\u0e41\u0e25\u0e49\u0e27" + }, + "step": { + "user": { + "data": { + "name": "\u0e0a\u0e37\u0e48\u0e2d" + }, + "title": "\u0e15\u0e33\u0e41\u0e2b\u0e19\u0e48\u0e07" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/mqtt/.translations/th.json b/homeassistant/components/mqtt/.translations/th.json new file mode 100644 index 00000000000..7ea8785af32 --- /dev/null +++ b/homeassistant/components/mqtt/.translations/th.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "broker": { + "data": { + "discovery": "\u0e40\u0e1b\u0e34\u0e14\u0e43\u0e0a\u0e49\u0e01\u0e32\u0e23\u0e04\u0e49\u0e19\u0e2b\u0e32\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c" + } + }, + "hassio_confirm": { + "data": { + "discovery": "\u0e40\u0e1b\u0e34\u0e14\u0e43\u0e0a\u0e49\u0e01\u0e32\u0e23\u0e04\u0e49\u0e19\u0e2b\u0e32\u0e2d\u0e38\u0e1b\u0e01\u0e23\u0e13\u0e4c" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/ps4/.translations/nl.json b/homeassistant/components/ps4/.translations/nl.json index 5023cd87191..3dcadef20eb 100644 --- a/homeassistant/components/ps4/.translations/nl.json +++ b/homeassistant/components/ps4/.translations/nl.json @@ -1,6 +1,21 @@ { "config": { + "abort": { + "credential_error": "Fout bij ophalen van inloggegevens.", + "devices_configured": "Alle gevonden apparaten zijn al geconfigureerd.", + "no_devices_found": "Geen PlayStation 4 apparaten gevonden op het netwerk.", + "port_987_bind_error": "Kan niet binden aan poort 987.", + "port_997_bind_error": "Kan niet binden aan poort 997." + }, + "error": { + "login_failed": "Kan niet koppelen met PlayStation 4. Controleer of de pincode juist is.", + "not_ready": "PlayStation 4 staat niet aan of is niet verbonden met een netwerk." + }, "step": { + "creds": { + "description": "Aanmeldingsgegevens zijn nodig. Druk op 'Verzenden' en vervolgens in de PS4-app voor het 2e scherm, vernieuw apparaten en selecteer Home Assistant om door te gaan.", + "title": "PlayStation 4" + }, "link": { "data": { "code": "PIN", diff --git a/homeassistant/components/ps4/.translations/th.json b/homeassistant/components/ps4/.translations/th.json new file mode 100644 index 00000000000..a48089bfdd6 --- /dev/null +++ b/homeassistant/components/ps4/.translations/th.json @@ -0,0 +1,19 @@ +{ + "config": { + "step": { + "creds": { + "title": "PlayStation 4" + }, + "link": { + "data": { + "code": "PIN", + "ip_address": "\u0e17\u0e35\u0e48\u0e2d\u0e22\u0e39\u0e48 IP", + "name": "\u0e0a\u0e37\u0e48\u0e2d", + "region": "\u0e20\u0e39\u0e21\u0e34\u0e20\u0e32\u0e04" + }, + "title": "PlayStation 4" + } + }, + "title": "PlayStation 4" + } +} \ No newline at end of file diff --git a/homeassistant/components/rainmachine/.translations/th.json b/homeassistant/components/rainmachine/.translations/th.json new file mode 100644 index 00000000000..4b250fbc134 --- /dev/null +++ b/homeassistant/components/rainmachine/.translations/th.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/simplisafe/.translations/th.json b/homeassistant/components/simplisafe/.translations/th.json new file mode 100644 index 00000000000..84fcb89add1 --- /dev/null +++ b/homeassistant/components/simplisafe/.translations/th.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "username": "\u0e17\u0e35\u0e48\u0e2d\u0e22\u0e39\u0e48\u0e2d\u0e35\u0e40\u0e21\u0e25" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/smartthings/.translations/nl.json b/homeassistant/components/smartthings/.translations/nl.json index 93150b2ae7d..2b5b646c458 100644 --- a/homeassistant/components/smartthings/.translations/nl.json +++ b/homeassistant/components/smartthings/.translations/nl.json @@ -7,7 +7,8 @@ "token_already_setup": "Het token is al ingesteld.", "token_forbidden": "Het token heeft niet de vereiste OAuth-scopes.", "token_invalid_format": "Het token moet de UID/GUID-indeling hebben", - "token_unauthorized": "Het token is ongeldig of niet langer geautoriseerd." + "token_unauthorized": "Het token is ongeldig of niet langer geautoriseerd.", + "webhook_error": "SmartThings kon het in 'base_url` geconfigureerde endpoint niet goedkeuren. Lees de componentvereisten door." }, "step": { "user": { diff --git a/homeassistant/components/smartthings/.translations/th.json b/homeassistant/components/smartthings/.translations/th.json new file mode 100644 index 00000000000..c871679860e --- /dev/null +++ b/homeassistant/components/smartthings/.translations/th.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "wait_install": { + "description": "\u0e42\u0e1b\u0e23\u0e14\u0e15\u0e34\u0e14\u0e15\u0e31\u0e49\u0e07 Home Assistant SmartApp \u0e43\u0e19\u0e15\u0e33\u0e41\u0e2b\u0e19\u0e48\u0e07\u0e2d\u0e22\u0e48\u0e32\u0e07\u0e19\u0e49\u0e2d\u0e22\u0e2b\u0e19\u0e36\u0e48\u0e07\u0e41\u0e2b\u0e48\u0e07\u0e41\u0e25\u0e49\u0e27\u0e04\u0e25\u0e34\u0e01\u0e2a\u0e48\u0e07", + "title": "\u0e15\u0e34\u0e14\u0e15\u0e31\u0e49\u0e07 SmartApp" + } + }, + "title": "SmartThings" + } +} \ No newline at end of file diff --git a/homeassistant/components/smhi/.translations/th.json b/homeassistant/components/smhi/.translations/th.json new file mode 100644 index 00000000000..0c08363fca6 --- /dev/null +++ b/homeassistant/components/smhi/.translations/th.json @@ -0,0 +1,13 @@ +{ + "config": { + "step": { + "user": { + "data": { + "latitude": "\u0e25\u0e30\u0e15\u0e34\u0e08\u0e39\u0e14", + "longitude": "\u0e25\u0e2d\u0e07\u0e08\u0e34\u0e08\u0e39\u0e14", + "name": "\u0e0a\u0e37\u0e48\u0e2d" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/tellduslive/.translations/th.json b/homeassistant/components/tellduslive/.translations/th.json new file mode 100644 index 00000000000..4d01bb3e14c --- /dev/null +++ b/homeassistant/components/tellduslive/.translations/th.json @@ -0,0 +1,11 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "\u0e42\u0e2e\u0e2a\u0e15\u0e4c" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/.translations/nl.json b/homeassistant/components/toon/.translations/nl.json index 131a7b6cf7c..2ca887b1766 100644 --- a/homeassistant/components/toon/.translations/nl.json +++ b/homeassistant/components/toon/.translations/nl.json @@ -3,7 +3,7 @@ "abort": { "client_id": "De client ID uit de configuratie is ongeldig.", "client_secret": "De client secret uit de configuratie is ongeldig.", - "no_agreements": "Dit account heeft geen Toon-schermen.", + "no_agreements": "Dit account heeft geen Toon schermen.", "no_app": "Je moet Toon configureren voordat je ermee kunt aanmelden. [Lees de instructies](https://www.home-assistant.io/components/toon/).", "unknown_auth_fail": "Onverwachte fout tijdens het verifi\u00ebren." }, @@ -18,7 +18,7 @@ "tenant": "Huurder", "username": "Gebruikersnaam" }, - "description": "Verifieer met je Eneco Toon-account (niet het developer-account).", + "description": "Verifieer met je Eneco Toon account (niet het ontwikkelaars account).", "title": "Link je Toon-account" }, "display": { diff --git a/homeassistant/components/unifi/.translations/th.json b/homeassistant/components/unifi/.translations/th.json new file mode 100644 index 00000000000..178d052c722 --- /dev/null +++ b/homeassistant/components/unifi/.translations/th.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "\u0e42\u0e2e\u0e2a\u0e15\u0e4c", + "password": "\u0e23\u0e2b\u0e31\u0e2a\u0e1c\u0e48\u0e32\u0e19" + } + } + } + } +} \ No newline at end of file From 4a4bb434220c15c1a2cf31e47b73b90146f82ffa Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 12 Mar 2019 11:46:20 -0700 Subject: [PATCH 209/291] Stream: Only add base url when needed (#21979) --- homeassistant/components/camera/__init__.py | 2 +- homeassistant/components/stream/__init__.py | 2 +- homeassistant/components/stream/hls.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/camera/__init__.py b/homeassistant/components/camera/__init__.py index 48dd355ebd6..95d6dba50c3 100644 --- a/homeassistant/components/camera/__init__.py +++ b/homeassistant/components/camera/__init__.py @@ -566,7 +566,7 @@ async def async_handle_play_stream_service(camera, service_call): url = request_stream(hass, camera.stream_source, fmt=fmt) data = { ATTR_ENTITY_ID: entity_ids, - ATTR_MEDIA_CONTENT_ID: url, + ATTR_MEDIA_CONTENT_ID: "{}{}".format(hass.config.api.base_url, url), ATTR_MEDIA_CONTENT_TYPE: FORMAT_CONTENT_TYPE[fmt] } diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 5a4b1ade96d..1d04791a11a 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -59,7 +59,7 @@ def request_stream(hass, stream_source, *, fmt='hls', stream.access_token = generate_secret() stream.start() return hass.data[DOMAIN][ATTR_ENDPOINTS][fmt].format( - hass.config.api.base_url, stream.access_token) + stream.access_token) except Exception: raise HomeAssistantError('Unable to get stream') diff --git a/homeassistant/components/stream/hls.py b/homeassistant/components/stream/hls.py index 285f752c033..8f5dd6c1884 100644 --- a/homeassistant/components/stream/hls.py +++ b/homeassistant/components/stream/hls.py @@ -18,7 +18,7 @@ def async_setup_hls(hass): """Set up api endpoints.""" hass.http.register_view(HlsPlaylistView()) hass.http.register_view(HlsSegmentView()) - return '{}/api/hls/{}/playlist.m3u8' + return '/api/hls/{}/playlist.m3u8' class HlsPlaylistView(StreamView): From 9416af5b56831845347e43211650cb1f7492b31d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 12 Mar 2019 11:49:28 -0700 Subject: [PATCH 210/291] Allow changing password without being admin (#21978) --- homeassistant/components/config/auth_provider_homeassistant.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/config/auth_provider_homeassistant.py b/homeassistant/components/config/auth_provider_homeassistant.py index 5455277aa78..f6fc4bc8cef 100644 --- a/homeassistant/components/config/auth_provider_homeassistant.py +++ b/homeassistant/components/config/auth_provider_homeassistant.py @@ -122,7 +122,6 @@ async def websocket_delete(hass, connection, msg): websocket_api.result_message(msg['id'])) -@websocket_api.require_admin @websocket_api.async_response async def websocket_change_password(hass, connection, msg): """Change user password.""" From d3960bf745f46eba22ed29470f0a81024bd075dd Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 12 Mar 2019 11:49:46 -0700 Subject: [PATCH 211/291] Fix some cloud things (#21977) --- homeassistant/components/cloud/__init__.py | 2 +- homeassistant/components/cloud/http_api.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 4d493ad6b92..cc6eeeb8192 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -104,7 +104,7 @@ async def async_create_cloudhook(hass, webhook_id: str) -> str: if not async_is_logged_in(hass): raise CloudNotAvailable - hook = await hass.data[DOMAIN].cloudhooks.async_create(webhook_id) + hook = await hass.data[DOMAIN].cloudhooks.async_create(webhook_id, True) return hook['cloudhook_url'] diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 0366675438a..61b3b8576ec 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -367,7 +367,7 @@ async def websocket_update_prefs(hass, connection, msg): async def websocket_hook_create(hass, connection, msg): """Handle request for account info.""" cloud = hass.data[DOMAIN] - hook = await cloud.cloudhooks.async_create(msg['webhook_id']) + hook = await cloud.cloudhooks.async_create(msg['webhook_id'], False) connection.send_message(websocket_api.result_message(msg['id'], hook)) From 2b1b47bfdd81840f5e33849fc0d9c6146f8f5742 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Tue, 12 Mar 2019 20:54:08 +0000 Subject: [PATCH 212/291] homekit_controller: Bump homekit to 0.13.0 (#21965) * Bump homekit to 0.13.0 * Update gen_requirements_all.py * Escape values used in TESTS_REQUIREMENTS --- .../components/homekit_controller/__init__.py | 6 +++--- .../components/homekit_controller/config_flow.py | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- script/gen_requirements_all.py | 6 +++--- .../homekit_controller/test_config_flow.py | 16 ++++++++++++---- 6 files changed, 22 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 072e323ecd1..4b73aa26455 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -15,7 +15,7 @@ from .const import ( ) -REQUIREMENTS = ['homekit==0.12.2'] +REQUIREMENTS = ['homekit[IP]==0.13.0'] HOMEKIT_DIR = '.homekit' @@ -314,7 +314,7 @@ def setup(hass, config): """Set up for Homekit devices.""" # pylint: disable=import-error import homekit - from homekit.controller import Pairing + from homekit.controller.ip_implementation import IpPairing hass.data[CONTROLLER] = controller = homekit.Controller() @@ -335,7 +335,7 @@ def setup(hass, config): continue with open(os.path.join(data_dir, device)) as pairing_data_fp: pairing_data = json.load(pairing_data_fp) - controller.pairings[alias] = Pairing(pairing_data) + controller.pairings[alias] = IpPairing(pairing_data) controller.save_data(pairing_file) def discovery_dispatch(service, discovery_info): diff --git a/homeassistant/components/homekit_controller/config_flow.py b/homeassistant/components/homekit_controller/config_flow.py index 52768097aec..1cd66896fe2 100644 --- a/homeassistant/components/homekit_controller/config_flow.py +++ b/homeassistant/components/homekit_controller/config_flow.py @@ -189,7 +189,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): async def async_import_legacy_pairing(self, discovery_props, pairing_data): """Migrate a legacy pairing to config entries.""" - from homekit.controller import Pairing + from homekit.controller.ip_implementation import IpPairing hkid = discovery_props['id'] @@ -204,7 +204,7 @@ class HomekitControllerFlowHandler(config_entries.ConfigFlow): ("Legacy configuration %s for homekit" "accessory migrated to config entries"), hkid) - pairing = Pairing(pairing_data) + pairing = IpPairing(pairing_data) return await self._entry_from_accessory(pairing) diff --git a/requirements_all.txt b/requirements_all.txt index b5c614d4576..3af20b32975 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -560,7 +560,7 @@ home-assistant-frontend==20190312.0 homeassistant-pyozw==0.1.2 # homeassistant.components.homekit_controller -homekit==0.12.2 +homekit[IP]==0.13.0 # homeassistant.components.homematicip_cloud homematicip==0.10.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 30bd295dcd0..c2018eb92a9 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -129,7 +129,7 @@ holidays==0.9.9 home-assistant-frontend==20190312.0 # homeassistant.components.homekit_controller -homekit==0.12.2 +homekit[IP]==0.13.0 # homeassistant.components.homematicip_cloud homematicip==0.10.6 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index bb242d1e7ba..ada84d2bbcd 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -69,7 +69,7 @@ TEST_REQUIREMENTS = ( 'hdate', 'holidays', 'home-assistant-frontend', - 'homekit', + 'homekit[IP]', 'homematicip', 'influxdb', 'jsonpath', @@ -106,7 +106,7 @@ TEST_REQUIREMENTS = ( 'python-forecastio', 'python-nest', 'python_awair', - 'pytradfri\\[async\\]', + 'pytradfri[async]', 'pyunifi', 'pyupnp-async', 'pywebpush', @@ -291,7 +291,7 @@ def requirements_test_output(reqs): output.append('\n') filtered = {key: value for key, value in reqs.items() if any( - re.search(r'(^|#){}($|[=><])'.format(ign), + re.search(r'(^|#){}($|[=><])'.format(re.escape(ign)), key) is not None for ign in TEST_REQUIREMENTS)} output.append(generate_requirements_list(filtered)) diff --git a/tests/components/homekit_controller/test_config_flow.py b/tests/components/homekit_controller/test_config_flow.py index cf4da597b12..62c741b4eaa 100644 --- a/tests/components/homekit_controller/test_config_flow.py +++ b/tests/components/homekit_controller/test_config_flow.py @@ -471,7 +471,9 @@ async def test_import_works(hass): flow = config_flow.HomekitControllerFlowHandler() flow.hass = hass - with mock.patch('homekit.controller.Pairing') as pairing_cls: + pairing_cls_imp = "homekit.controller.ip_implementation.IpPairing" + + with mock.patch(pairing_cls_imp) as pairing_cls: pairing_cls.return_value = pairing result = await flow.async_import_legacy_pairing( discovery_info['properties'], import_info) @@ -649,7 +651,9 @@ async def test_parse_new_homekit_json(hass): flow = config_flow.HomekitControllerFlowHandler() flow.hass = hass - with mock.patch('homekit.controller.Pairing') as pairing_cls: + pairing_cls_imp = "homekit.controller.ip_implementation.IpPairing" + + with mock.patch(pairing_cls_imp) as pairing_cls: pairing_cls.return_value = pairing with mock.patch('builtins.open', mock_open): with mock.patch('os.path', mock_path): @@ -703,7 +707,9 @@ async def test_parse_old_homekit_json(hass): flow = config_flow.HomekitControllerFlowHandler() flow.hass = hass - with mock.patch('homekit.controller.Pairing') as pairing_cls: + pairing_cls_imp = "homekit.controller.ip_implementation.IpPairing" + + with mock.patch(pairing_cls_imp) as pairing_cls: pairing_cls.return_value = pairing with mock.patch('builtins.open', mock_open): with mock.patch('os.path', mock_path): @@ -769,7 +775,9 @@ async def test_parse_overlapping_homekit_json(hass): flow = config_flow.HomekitControllerFlowHandler() flow.hass = hass - with mock.patch('homekit.controller.Pairing') as pairing_cls: + pairing_cls_imp = "homekit.controller.ip_implementation.IpPairing" + + with mock.patch(pairing_cls_imp) as pairing_cls: pairing_cls.return_value = pairing with mock.patch('builtins.open', side_effect=side_effects): with mock.patch('os.path', mock_path): From f8921f84d7315511f73ae90fd4235fbc489f54a8 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 12 Mar 2019 14:19:11 -0700 Subject: [PATCH 213/291] skip flaky test (#21981) --- tests/components/sensor/test_statistics.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/tests/components/sensor/test_statistics.py b/tests/components/sensor/test_statistics.py index 9b4e53dbab9..1bd3ee73616 100644 --- a/tests/components/sensor/test_statistics.py +++ b/tests/components/sensor/test_statistics.py @@ -2,6 +2,8 @@ import unittest import statistics +import pytest + from homeassistant.setup import setup_component from homeassistant.components.sensor.statistics import StatisticsSensor from homeassistant.const import ( @@ -228,6 +230,7 @@ class TestStatisticsSensor(unittest.TestCase): state.attributes.get('max_age') assert self.change_rate == state.attributes.get('change_rate') + @pytest.mark.skip def test_initialize_from_database(self): """Test initializing the statistics from the database.""" # enable the recorder From ce1fe06193cd6d8c57447a2092b1eec891689710 Mon Sep 17 00:00:00 2001 From: emontnemery Date: Tue, 12 Mar 2019 22:46:48 +0100 Subject: [PATCH 214/291] Write state directly in all MQTT platforms (#21971) --- homeassistant/components/mqtt/__init__.py | 4 +- .../components/mqtt/alarm_control_panel.py | 4 +- homeassistant/components/mqtt/camera.py | 2 +- homeassistant/components/mqtt/climate.py | 37 ++++++++++--------- homeassistant/components/mqtt/cover.py | 18 ++++----- homeassistant/components/mqtt/fan.py | 12 +++--- .../components/mqtt/light/schema_basic.py | 22 +++++------ .../components/mqtt/light/schema_json.py | 8 ++-- .../components/mqtt/light/schema_template.py | 8 ++-- homeassistant/components/mqtt/lock.py | 12 +++--- homeassistant/components/mqtt/sensor.py | 6 +-- homeassistant/components/mqtt/switch.py | 8 ++-- homeassistant/components/mqtt/vacuum.py | 22 +++++------ tests/components/mqtt/test_fan.py | 1 + tests/components/mqtt/test_light_json.py | 1 + 15 files changed, 84 insertions(+), 81 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index d603b6637b0..ed671a2f8ce 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -872,7 +872,7 @@ class MqttAttributes(Entity): json_dict = json.loads(payload) if isinstance(json_dict, dict): self._attributes = json_dict - self.async_schedule_update_ha_state() + self.async_write_ha_state() else: _LOGGER.warning("JSON result was not a dictionary") self._attributes = None @@ -936,7 +936,7 @@ class MqttAvailability(Entity): elif payload == self._avail_config[CONF_PAYLOAD_NOT_AVAILABLE]: self._available = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() self._availability_sub_state = await async_subscribe_topics( self.hass, self._availability_sub_state, diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 4d96e73fb23..a03716676cd 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -121,7 +121,7 @@ class MqttAlarm(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, await self.availability_discovery_update(config) await self.device_info_discovery_update(config) await self._subscribe_topics() - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def _subscribe_topics(self): """(Re)Subscribe to topics.""" @@ -136,7 +136,7 @@ class MqttAlarm(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, _LOGGER.warning("Received unexpected payload: %s", payload) return self._state = payload - self.async_schedule_update_ha_state() + self.async_write_ha_state() self._sub_state = await subscription.async_subscribe_topics( self.hass, self._sub_state, diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index 569d69a9ad8..b9cdb5bef02 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -97,7 +97,7 @@ class MqttCamera(MqttDiscoveryUpdate, Camera): config = PLATFORM_SCHEMA(discovery_payload) self._config = config await self._subscribe_topics() - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def _subscribe_topics(self): """(Re)Subscribe to topics.""" diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 7be47185322..25f5aa68571 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -231,7 +231,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, await self.availability_discovery_update(config) await self.device_info_discovery_update(config) await self._subscribe_topics() - self.async_schedule_update_ha_state() + self.async_write_ha_state() def _setup_from_config(self, config): """(Re)Setup the entity.""" @@ -297,7 +297,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, try: self._current_temperature = float(payload) - self.async_schedule_update_ha_state() + self.async_write_ha_state() except ValueError: _LOGGER.error("Could not parse temperature from %s", payload) @@ -318,7 +318,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, _LOGGER.error("Invalid mode: %s", payload) else: self._current_operation = payload - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_MODE_STATE_TOPIC] is not None: topics[CONF_MODE_STATE_TOPIC] = { @@ -336,7 +336,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, try: self._target_temperature = float(payload) - self.async_schedule_update_ha_state() + self.async_write_ha_state() except ValueError: _LOGGER.error("Could not parse temperature from %s", payload) @@ -358,7 +358,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, _LOGGER.error("Invalid fan mode: %s", payload) else: self._current_fan_mode = payload - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_FAN_MODE_STATE_TOPIC] is not None: topics[CONF_FAN_MODE_STATE_TOPIC] = { @@ -378,7 +378,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, _LOGGER.error("Invalid swing mode: %s", payload) else: self._current_swing_mode = payload - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_SWING_MODE_STATE_TOPIC] is not None: topics[CONF_SWING_MODE_STATE_TOPIC] = { @@ -407,7 +407,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, else: _LOGGER.error("Invalid away mode: %s", payload) - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is not None: topics[CONF_AWAY_MODE_STATE_TOPIC] = { @@ -435,7 +435,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, else: _LOGGER.error("Invalid aux mode: %s", payload) - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_AUX_STATE_TOPIC] is not None: topics[CONF_AUX_STATE_TOPIC] = { @@ -451,7 +451,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, async_render_with_possible_json_value(payload) self._hold = payload - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_HOLD_STATE_TOPIC] is not None: topics[CONF_HOLD_STATE_TOPIC] = { @@ -558,7 +558,8 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, kwargs.get(ATTR_TEMPERATURE), self._config.get(CONF_QOS), self._config.get(CONF_RETAIN)) - self.async_schedule_update_ha_state() + # Always optimistic? + self.async_write_ha_state() async def async_set_swing_mode(self, swing_mode): """Set new swing mode.""" @@ -571,7 +572,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._topic[CONF_SWING_MODE_STATE_TOPIC] is None: self._current_swing_mode = swing_mode - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_set_fan_mode(self, fan_mode): """Set new target temperature.""" @@ -584,7 +585,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._topic[CONF_FAN_MODE_STATE_TOPIC] is None: self._current_fan_mode = fan_mode - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_set_operation_mode(self, operation_mode) -> None: """Set new operation mode.""" @@ -609,7 +610,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._topic[CONF_MODE_STATE_TOPIC] is None: self._current_operation = operation_mode - self.async_schedule_update_ha_state() + self.async_write_ha_state() @property def current_swing_mode(self): @@ -632,7 +633,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is None: self._away = True - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_turn_away_mode_off(self): """Turn away mode off.""" @@ -645,7 +646,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._topic[CONF_AWAY_MODE_STATE_TOPIC] is None: self._away = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_set_hold_mode(self, hold_mode): """Update hold mode on.""" @@ -657,7 +658,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._topic[CONF_HOLD_STATE_TOPIC] is None: self._hold = hold_mode - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_turn_aux_heat_on(self): """Turn auxiliary heater on.""" @@ -669,7 +670,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._topic[CONF_AUX_STATE_TOPIC] is None: self._aux = True - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_turn_aux_heat_off(self): """Turn auxiliary heater off.""" @@ -681,7 +682,7 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._topic[CONF_AUX_STATE_TOPIC] is None: self._aux = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() @property def supported_features(self): diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index 829be266b09..f4f73c76863 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -195,7 +195,7 @@ class MqttCover(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, await self.availability_discovery_update(config) await self.device_info_discovery_update(config) await self._subscribe_topics() - self.async_schedule_update_ha_state() + self.async_write_ha_state() def _setup_from_config(self, config): self._config = config @@ -224,7 +224,7 @@ class MqttCover(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, level = self.find_percentage_in_range(float(payload)) self._tilt_value = level - self.async_schedule_update_ha_state() + self.async_write_ha_state() @callback def state_message_received(topic, payload, qos): @@ -240,7 +240,7 @@ class MqttCover(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, else: _LOGGER.warning("Payload is not True or False: %s", payload) return - self.async_schedule_update_ha_state() + self.async_write_ha_state() @callback def position_message_received(topic, payload, qos): @@ -259,7 +259,7 @@ class MqttCover(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, "Payload is not integer within range: %s", payload) return - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._config.get(CONF_GET_POSITION_TOPIC): topics['get_position_topic'] = { @@ -364,7 +364,7 @@ class MqttCover(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._config.get(CONF_GET_POSITION_TOPIC): self._position = self.find_percentage_in_range( self._config.get(CONF_POSITION_OPEN), COVER_PAYLOAD) - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_close_cover(self, **kwargs): """Move the cover down. @@ -381,7 +381,7 @@ class MqttCover(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._config.get(CONF_GET_POSITION_TOPIC): self._position = self.find_percentage_in_range( self._config.get(CONF_POSITION_CLOSED), COVER_PAYLOAD) - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_stop_cover(self, **kwargs): """Stop the device. @@ -402,7 +402,7 @@ class MqttCover(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._config.get(CONF_RETAIN)) if self._tilt_optimistic: self._tilt_value = self._config.get(CONF_TILT_OPEN_POSITION) - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_close_cover_tilt(self, **kwargs): """Tilt the cover closed.""" @@ -413,7 +413,7 @@ class MqttCover(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._config.get(CONF_RETAIN)) if self._tilt_optimistic: self._tilt_value = self._config.get(CONF_TILT_CLOSED_POSITION) - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_set_cover_tilt_position(self, **kwargs): """Move the cover tilt to a specific position.""" @@ -458,7 +458,7 @@ class MqttCover(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._state = percentage_position == \ self._config.get(CONF_POSITION_CLOSED) self._position = percentage_position - self.async_schedule_update_ha_state() + self.async_write_ha_state() def find_percentage_in_range(self, position, range_type=TILT_PAYLOAD): """Find the 0-100% value within the specified range.""" diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index d15b236038e..eb1e6e84101 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -158,7 +158,7 @@ class MqttFan(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, await self.availability_discovery_update(config) await self.device_info_discovery_update(config) await self._subscribe_topics() - self.async_schedule_update_ha_state() + self.async_write_ha_state() def _setup_from_config(self, config): """(Re)Setup the entity.""" @@ -219,7 +219,7 @@ class MqttFan(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._state = True elif payload == self._payload[STATE_OFF]: self._state = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_STATE_TOPIC] is not None: topics[CONF_STATE_TOPIC] = { @@ -237,7 +237,7 @@ class MqttFan(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._speed = SPEED_MEDIUM elif payload == self._payload[SPEED_HIGH]: self._speed = SPEED_HIGH - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_SPEED_STATE_TOPIC] is not None: topics[CONF_SPEED_STATE_TOPIC] = { @@ -254,7 +254,7 @@ class MqttFan(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._oscillation = True elif payload == self._payload[OSCILLATE_OFF_PAYLOAD]: self._oscillation = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_OSCILLATION_STATE_TOPIC] is not None: topics[CONF_OSCILLATION_STATE_TOPIC] = { @@ -360,7 +360,7 @@ class MqttFan(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._optimistic_speed: self._speed = speed - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_oscillate(self, oscillating: bool) -> None: """Set oscillation. @@ -381,7 +381,7 @@ class MqttFan(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._optimistic_oscillation: self._oscillation = oscillating - self.async_schedule_update_ha_state() + self.async_write_ha_state() @property def unique_id(self): diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 4aee026a2f6..256e0f46d85 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -174,7 +174,7 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, await self.availability_discovery_update(config) await self.device_info_discovery_update(config) await self._subscribe_topics() - self.async_schedule_update_ha_state() + self.async_write_ha_state() def _setup_from_config(self, config): """(Re)Setup the entity.""" @@ -265,7 +265,7 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._state = True elif payload == self._payload['off']: self._state = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_STATE_TOPIC] is not None: topics[CONF_STATE_TOPIC] = { @@ -288,7 +288,7 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, percent_bright = \ device_value / self._config.get(CONF_BRIGHTNESS_SCALE) self._brightness = percent_bright * 255 - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_BRIGHTNESS_STATE_TOPIC] is not None: topics[CONF_BRIGHTNESS_STATE_TOPIC] = { @@ -318,7 +318,7 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, percent_bright = \ float(color_util.color_RGB_to_hsv(*rgb)[2]) / 100.0 self._brightness = percent_bright * 255 - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_RGB_STATE_TOPIC] is not None: topics[CONF_RGB_STATE_TOPIC] = { @@ -342,7 +342,7 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, return self._color_temp = int(payload) - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_COLOR_TEMP_STATE_TOPIC] is not None: topics[CONF_COLOR_TEMP_STATE_TOPIC] = { @@ -367,7 +367,7 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, return self._effect = payload - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_EFFECT_STATE_TOPIC] is not None: topics[CONF_EFFECT_STATE_TOPIC] = { @@ -394,7 +394,7 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, try: hs_color = [float(val) for val in payload.split(',', 2)] self._hs = hs_color - self.async_schedule_update_ha_state() + self.async_write_ha_state() except ValueError: _LOGGER.debug("Failed to parse hs state update: '%s'", payload) @@ -424,7 +424,7 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, percent_white = \ device_value / self._config.get(CONF_WHITE_VALUE_SCALE) self._white_value = percent_white * 255 - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_WHITE_VALUE_STATE_TOPIC] is not None: topics[CONF_WHITE_VALUE_STATE_TOPIC] = { @@ -451,7 +451,7 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, xy_color = [float(val) for val in payload.split(',')] self._hs = color_util.color_xy_to_hs(*xy_color) - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_XY_STATE_TOPIC] is not None: topics[CONF_XY_STATE_TOPIC] = { @@ -742,7 +742,7 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, should_update = True if should_update: - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn the device off. @@ -756,4 +756,4 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._optimistic: # Optimistically assume that the light has changed state. self._state = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 4a97eeea520..df3aa7fe89e 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -136,7 +136,7 @@ class MqttLightJson(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, await self.availability_discovery_update(config) await self.device_info_discovery_update(config) await self._subscribe_topics() - self.async_schedule_update_ha_state() + self.async_write_ha_state() def _setup_from_config(self, config): """(Re)Setup the entity.""" @@ -276,7 +276,7 @@ class MqttLightJson(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, except ValueError: _LOGGER.warning("Invalid white value received") - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topic[CONF_STATE_TOPIC] is not None: self._sub_state = await subscription.async_subscribe_topics( @@ -456,7 +456,7 @@ class MqttLightJson(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, should_update = True if should_update: - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn the device off. @@ -475,4 +475,4 @@ class MqttLightJson(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._optimistic: # Optimistically assume that the light has changed state. self._state = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 4d086fd73e1..0773a0cf05d 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -124,7 +124,7 @@ class MqttTemplate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, await self.availability_discovery_update(config) await self.device_info_discovery_update(config) await self._subscribe_topics() - self.async_schedule_update_ha_state() + self.async_write_ha_state() def _setup_from_config(self, config): """(Re)Setup the entity.""" @@ -250,7 +250,7 @@ class MqttTemplate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, else: _LOGGER.warning("Unsupported effect value received") - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._topics[CONF_STATE_TOPIC] is not None: self._sub_state = await subscription.async_subscribe_topics( @@ -400,7 +400,7 @@ class MqttTemplate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, ) if self._optimistic: - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn the entity off. @@ -421,7 +421,7 @@ class MqttTemplate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, ) if self._optimistic: - self.async_schedule_update_ha_state() + self.async_write_ha_state() @property def supported_features(self): diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index 82462b8171f..c8f1bedeeff 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -111,7 +111,7 @@ class MqttLock(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, await self.availability_discovery_update(config) await self.device_info_discovery_update(config) await self._subscribe_topics() - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def _subscribe_topics(self): """(Re)Subscribe to topics.""" @@ -130,7 +130,7 @@ class MqttLock(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, elif payload == self._config[CONF_PAYLOAD_UNLOCK]: self._state = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. @@ -185,9 +185,9 @@ class MqttLock(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._config[CONF_QOS], self._config[CONF_RETAIN]) if self._optimistic: - # Optimistically assume that switch has changed state. + # Optimistically assume that the lock has changed state. self._state = True - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_unlock(self, **kwargs): """Unlock the device. @@ -200,6 +200,6 @@ class MqttLock(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._config[CONF_QOS], self._config[CONF_RETAIN]) if self._optimistic: - # Optimistically assume that switch has changed state. + # Optimistically assume that the lock has changed state. self._state = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 02a4de9cad4..0a507b1bc4f 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -124,7 +124,7 @@ class MqttSensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, await self.availability_discovery_update(config) await self.device_info_discovery_update(config) await self._subscribe_topics() - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def _subscribe_topics(self): """(Re)Subscribe to topics.""" @@ -169,7 +169,7 @@ class MqttSensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, payload = template.async_render_with_possible_json_value( payload, self._state) self._state = payload - self.async_schedule_update_ha_state() + self.async_write_ha_state() self._sub_state = await subscription.async_subscribe_topics( self.hass, self._sub_state, @@ -189,7 +189,7 @@ class MqttSensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, """Triggered when value is expired.""" self._expiration_trigger = None self._state = None - self.async_schedule_update_ha_state() + self.async_write_ha_state() @property def should_poll(self): diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index c9f8c880573..50243274bfb 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -121,7 +121,7 @@ class MqttSwitch(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, await self.availability_discovery_update(config) await self.device_info_discovery_update(config) await self._subscribe_topics() - self.async_schedule_update_ha_state() + self.async_write_ha_state() def _setup_from_config(self, config): """(Re)Setup the entity.""" @@ -153,7 +153,7 @@ class MqttSwitch(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, elif payload == self._state_off: self._state = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() if self._config.get(CONF_STATE_TOPIC) is None: # Force into optimistic mode. @@ -222,7 +222,7 @@ class MqttSwitch(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._optimistic: # Optimistically assume that switch has changed state. self._state = True - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn the device off. @@ -238,4 +238,4 @@ class MqttSwitch(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if self._optimistic: # Optimistically assume that switch has changed state. self._state = False - self.async_schedule_update_ha_state() + self.async_write_ha_state() diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index 3d53f32c6f6..081bf5fc583 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -264,7 +264,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, await self.availability_discovery_update(config) await self.device_info_discovery_update(config) await self._subscribe_topics() - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_added_to_hass(self): """Subscribe MQTT events.""" @@ -346,7 +346,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, if fan_speed is not None: self._fan_speed = fan_speed - self.async_schedule_update_ha_state() + self.async_write_ha_state() topics_list = {topic for topic in self._state_topics.values() if topic} self._sub_state = await subscription.async_subscribe_topics( @@ -434,7 +434,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._payloads[CONF_PAYLOAD_TURN_ON], self._qos, self._retain) self._status = 'Cleaning' - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_turn_off(self, **kwargs): """Turn the vacuum off.""" @@ -445,7 +445,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._payloads[CONF_PAYLOAD_TURN_OFF], self._qos, self._retain) self._status = 'Turning Off' - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_stop(self, **kwargs): """Stop the vacuum.""" @@ -456,7 +456,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._payloads[CONF_PAYLOAD_STOP], self._qos, self._retain) self._status = 'Stopping the current task' - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_clean_spot(self, **kwargs): """Perform a spot clean-up.""" @@ -467,7 +467,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._payloads[CONF_PAYLOAD_CLEAN_SPOT], self._qos, self._retain) self._status = "Cleaning spot" - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_locate(self, **kwargs): """Locate the vacuum (usually by playing a song).""" @@ -478,7 +478,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._payloads[CONF_PAYLOAD_LOCATE], self._qos, self._retain) self._status = "Hi, I'm over here!" - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_start_pause(self, **kwargs): """Start, pause or resume the cleaning task.""" @@ -489,7 +489,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._payloads[CONF_PAYLOAD_START_PAUSE], self._qos, self._retain) self._status = 'Pausing/Resuming cleaning...' - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_return_to_base(self, **kwargs): """Tell the vacuum to return to its dock.""" @@ -500,7 +500,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._payloads[CONF_PAYLOAD_RETURN_TO_BASE], self._qos, self._retain) self._status = 'Returning home...' - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_set_fan_speed(self, fan_speed, **kwargs): """Set fan speed.""" @@ -512,7 +512,7 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, mqtt.async_publish(self.hass, self._set_fan_speed_topic, fan_speed, self._qos, self._retain) self._status = "Setting fan to {}...".format(fan_speed) - self.async_schedule_update_ha_state() + self.async_write_ha_state() async def async_send_command(self, command, params=None, **kwargs): """Send a command to a vacuum cleaner.""" @@ -522,4 +522,4 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, mqtt.async_publish(self.hass, self._send_command_topic, command, self._qos, self._retain) self._status = "Sending command {}...".format(command) - self.async_schedule_update_ha_state() + self.async_write_ha_state() diff --git a/tests/components/mqtt/test_fan.py b/tests/components/mqtt/test_fan.py index 38b38ff7648..b7f8b8338a0 100644 --- a/tests/components/mqtt/test_fan.py +++ b/tests/components/mqtt/test_fan.py @@ -105,6 +105,7 @@ async def test_custom_availability_payload(hass, mqtt_mock): async_fire_mqtt_message(hass, 'availability_topic', 'good') await hass.async_block_till_done() + await hass.async_block_till_done() state = hass.states.get('fan.test') assert state.state is not STATE_UNAVAILABLE diff --git a/tests/components/mqtt/test_light_json.py b/tests/components/mqtt/test_light_json.py index a0ae0ddb2fb..172e6dbd8cf 100644 --- a/tests/components/mqtt/test_light_json.py +++ b/tests/components/mqtt/test_light_json.py @@ -235,6 +235,7 @@ async def test_controlling_state_via_topic(hass, mqtt_mock): async_fire_mqtt_message(hass, 'test_light_rgb', '{"state":"ON", "color":{"x":0.135,"y":0.135}}') await hass.async_block_till_done() + await hass.async_block_till_done() light_state = hass.states.get('light.test') assert (0.141, 0.14) == \ From 9428ed7690ba68fa9f60ed6d0925144a2ff62bc6 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Tue, 12 Mar 2019 18:00:18 -0700 Subject: [PATCH 215/291] Use .get to ensure we dont get KeyError (#21993) --- homeassistant/components/mobile_app/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 30f83f343c3..0d95bfe6832 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -23,8 +23,9 @@ async def async_setup(hass: HomeAssistantType, config: ConfigType): if hass.data.get(DOMAIN) is None: hass.data[DOMAIN] = {DATA_DELETED_IDS: [], DATA_REGISTRATIONS: {}} - hass.data[DOMAIN][DATA_DELETED_IDS] = app_config[DATA_DELETED_IDS] - hass.data[DOMAIN][DATA_REGISTRATIONS] = app_config[DATA_REGISTRATIONS] + hass.data[DOMAIN][DATA_DELETED_IDS] = app_config.get(DATA_DELETED_IDS, []) + hass.data[DOMAIN][DATA_REGISTRATIONS] = app_config.get(DATA_REGISTRATIONS, + {}) hass.data[DOMAIN][DATA_STORE] = store for registration in app_config[DATA_REGISTRATIONS].values(): From e618e2f34845f4938e83bb922a1b8e88f2f0bf7b Mon Sep 17 00:00:00 2001 From: Jc2k Date: Wed, 13 Mar 2019 01:37:33 +0000 Subject: [PATCH 216/291] Fix error introduced by #21933 (#21988) --- homeassistant/components/homekit_controller/cover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/homekit_controller/cover.py b/homeassistant/components/homekit_controller/cover.py index 1426112094e..ccb1939e141 100644 --- a/homeassistant/components/homekit_controller/cover.py +++ b/homeassistant/components/homekit_controller/cover.py @@ -236,7 +236,7 @@ class HomeKitWindowCover(HomeKitEntity, CoverDevice): """Send open command.""" await self.async_set_cover_position(position=100) - async def close_cover(self, **kwargs): + async def async_close_cover(self, **kwargs): """Send close command.""" await self.async_set_cover_position(position=0) From d66cc9befa78c1aaa13e1ac1b9db61d59a7246b0 Mon Sep 17 00:00:00 2001 From: Daniel Shokouhi Date: Tue, 12 Mar 2019 18:40:24 -0700 Subject: [PATCH 217/291] Add stream source for amcrest component (#21983) --- homeassistant/components/amcrest/camera.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/homeassistant/components/amcrest/camera.py b/homeassistant/components/amcrest/camera.py index 2793fbca958..f6c507e73f4 100644 --- a/homeassistant/components/amcrest/camera.py +++ b/homeassistant/components/amcrest/camera.py @@ -97,3 +97,8 @@ class AmcrestCam(Camera): def name(self): """Return the name of this camera.""" return self._name + + @property + def stream_source(self): + """Return the source of the stream.""" + return self._camera.rtsp_url(typeno=self._resolution) From c0b859d8da76f5d992860eb0edb04800ec28ccf3 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Wed, 13 Mar 2019 01:45:34 +0000 Subject: [PATCH 218/291] Set homekit controller entity as unavailable if new connections fail (#21901) * Set entity as unavailable if new connections fail * Fix docstring --- .../components/homekit_controller/__init__.py | 17 +++++++++--- tests/components/homekit_controller/common.py | 6 ++++- .../homekit_controller/test_light.py | 26 +++++++++++++++++++ 3 files changed, 45 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homekit_controller/__init__.py b/homeassistant/components/homekit_controller/__init__.py index 4b73aa26455..ec38cf881d6 100644 --- a/homeassistant/components/homekit_controller/__init__.py +++ b/homeassistant/components/homekit_controller/__init__.py @@ -203,6 +203,7 @@ class HomeKitEntity(Entity): def __init__(self, accessory, devinfo): """Initialise a generic HomeKit device.""" + self._available = True self._name = accessory.model self._accessory = accessory self._aid = devinfo['aid'] @@ -270,14 +271,24 @@ class HomeKitEntity(Entity): async def async_update(self): """Obtain a HomeKit device's state.""" # pylint: disable=import-error - from homekit.exceptions import AccessoryDisconnectedError + from homekit.exceptions import ( + AccessoryDisconnectedError, AccessoryNotFoundError) try: new_values_dict = await self._accessory.get_characteristics( self._chars_to_poll ) - except AccessoryDisconnectedError: + except AccessoryNotFoundError: + # Not only did the connection fail, but also the accessory is not + # visible on the network. + self._available = False return + except AccessoryDisconnectedError: + # Temporary connection failure. Device is still available but our + # connection was dropped. + return + + self._available = True for (_, iid), result in new_values_dict.items(): if 'value' not in result: @@ -303,7 +314,7 @@ class HomeKitEntity(Entity): @property def available(self) -> bool: """Return True if entity is available.""" - return self._accessory.pairing is not None + return self._available def get_characteristic_types(self): """Define the homekit characteristics the entity cares about.""" diff --git a/tests/components/homekit_controller/common.py b/tests/components/homekit_controller/common.py index 29e7f4e986e..0447de97929 100644 --- a/tests/components/homekit_controller/common.py +++ b/tests/components/homekit_controller/common.py @@ -6,7 +6,7 @@ from homekit.model.services import AbstractService, ServicesTypes from homekit.model.characteristics import ( AbstractCharacteristic, CharacteristicPermissions, CharacteristicsTypes) from homekit.model import Accessory, get_id - +from homekit.exceptions import AccessoryNotFoundError from homeassistant.components.homekit_controller import ( DOMAIN, HOMEKIT_ACCESSORY_DISPATCH, SERVICE_HOMEKIT) from homeassistant.setup import async_setup_component @@ -26,6 +26,7 @@ class FakePairing: """Create a fake pairing from an accessory model.""" self.accessories = accessories self.pairing_data = {} + self.available = True def list_accessories_and_characteristics(self): """Fake implementation of list_accessories_and_characteristics.""" @@ -38,6 +39,9 @@ class FakePairing: def get_characteristics(self, characteristics): """Fake implementation of get_characteristics.""" + if not self.available: + raise AccessoryNotFoundError('Accessory not found') + results = {} for aid, cid in characteristics: for accessory in self.accessories: diff --git a/tests/components/homekit_controller/test_light.py b/tests/components/homekit_controller/test_light.py index 0509d70c0b9..59363f72146 100644 --- a/tests/components/homekit_controller/test_light.py +++ b/tests/components/homekit_controller/test_light.py @@ -126,3 +126,29 @@ async def test_switch_read_light_state_color_temp(hass, utcnow): assert state.state == 'on' assert state.attributes['brightness'] == 255 assert state.attributes['color_temp'] == 400 + + +async def test_light_becomes_unavailable_but_recovers(hass, utcnow): + """Test transition to and from unavailable state.""" + bulb = create_lightbulb_service_with_color_temp() + helper = await setup_test_component(hass, [bulb]) + + # Initial state is that the light is off + state = await helper.poll_and_get_state() + assert state.state == 'off' + + # Test device goes offline + helper.pairing.available = False + state = await helper.poll_and_get_state() + assert state.state == 'unavailable' + + # Simulate that someone switched on the device in the real world not via HA + helper.characteristics[LIGHT_ON].set_value(True) + helper.characteristics[LIGHT_BRIGHTNESS].value = 100 + helper.characteristics[LIGHT_COLOR_TEMP].value = 400 + helper.pairing.available = True + + state = await helper.poll_and_get_state() + assert state.state == 'on' + assert state.attributes['brightness'] == 255 + assert state.attributes['color_temp'] == 400 From a99d83390e53473f31ddacfc7ee08e9b6bd5dc64 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Wed, 13 Mar 2019 01:46:41 +0000 Subject: [PATCH 219/291] Centrally define Energy Units (kWh and Wh) (#21719) * centralize energy units kWh and Wh * lint --- homeassistant/components/ebusd/const.py | 12 ++++++---- homeassistant/components/fritzbox/switch.py | 5 ++-- homeassistant/components/homematic/sensor.py | 4 ++-- homeassistant/components/juicenet/sensor.py | 4 ++-- homeassistant/components/mysensors/sensor.py | 5 ++-- homeassistant/components/sense/sensor.py | 4 ++-- homeassistant/components/sensor/efergy.py | 5 ++-- .../components/sensor/hydroquebec.py | 4 ++-- homeassistant/components/sensor/linky.py | 5 ++-- .../components/sensor/neurio_energy.py | 5 ++-- homeassistant/components/sensor/openevse.py | 9 ++++---- homeassistant/components/sensor/solaredge.py | 23 ++++++++++--------- homeassistant/components/sensor/srp_energy.py | 4 ++-- .../components/sensor/volkszaehler.py | 5 ++-- homeassistant/components/smappee/sensor.py | 11 +++++---- .../components/smartthings/sensor.py | 4 ++-- homeassistant/components/switch/fritzdect.py | 4 ++-- homeassistant/components/toon/const.py | 4 +++- homeassistant/const.py | 4 ++++ 19 files changed, 70 insertions(+), 51 deletions(-) diff --git a/homeassistant/components/ebusd/const.py b/homeassistant/components/ebusd/const.py index c36981c5278..3821bd8ce15 100644 --- a/homeassistant/components/ebusd/const.py +++ b/homeassistant/components/ebusd/const.py @@ -1,4 +1,6 @@ """Constants for ebus component.""" +from homeassistant.const import ENERGY_KILO_WATT_HOUR + DOMAIN = 'ebusd' # SensorTypes: @@ -67,9 +69,9 @@ SENSOR_TYPES = { 'ContinuosHeating': ['ContinuosHeating', '°C', 'mdi:weather-snowy', 0], 'PowerEnergyConsumptionLastMonth': - ['PrEnergySumHcLastMonth', 'kWh', 'mdi:flash', 0], + ['PrEnergySumHcLastMonth', ENERGY_KILO_WATT_HOUR, 'mdi:flash', 0], 'PowerEnergyConsumptionThisMonth': - ['PrEnergySumHcThisMonth', 'kWh', 'mdi:flash', 0] + ['PrEnergySumHcThisMonth', ENERGY_KILO_WATT_HOUR, 'mdi:flash', 0] }, 'ehp': { 'HWTemperature': @@ -89,12 +91,12 @@ SENSOR_TYPES = { 'Flame': ['Flame', None, 'mdi:toggle-switch', 2], 'PowerEnergyConsumptionHeatingCircuit': - ['PrEnergySumHc1', 'kWh', 'mdi:flash', 0], + ['PrEnergySumHc1', ENERGY_KILO_WATT_HOUR, 'mdi:flash', 0], 'PowerEnergyConsumptionHotWaterCircuit': - ['PrEnergySumHwc1', 'kWh', 'mdi:flash', 0], + ['PrEnergySumHwc1', ENERGY_KILO_WATT_HOUR, 'mdi:flash', 0], 'RoomThermostat': ['DCRoomthermostat', None, 'mdi:toggle-switch', 2], 'HeatingPartLoad': - ['PartloadHcKW', 'kWh', 'mdi:flash', 0] + ['PartloadHcKW', ENERGY_KILO_WATT_HOUR, 'mdi:flash', 0] } } diff --git a/homeassistant/components/fritzbox/switch.py b/homeassistant/components/fritzbox/switch.py index be9212793ab..617c4902068 100644 --- a/homeassistant/components/fritzbox/switch.py +++ b/homeassistant/components/fritzbox/switch.py @@ -7,7 +7,8 @@ from homeassistant.components.fritzbox import DOMAIN as FRITZBOX_DOMAIN from homeassistant.components.fritzbox import ( ATTR_STATE_DEVICE_LOCKED, ATTR_STATE_LOCKED) from homeassistant.components.switch import SwitchDevice -from homeassistant.const import ATTR_TEMPERATURE, TEMP_CELSIUS +from homeassistant.const import (ATTR_TEMPERATURE, TEMP_CELSIUS, + ENERGY_KILO_WATT_HOUR) DEPENDENCIES = ['fritzbox'] @@ -15,7 +16,7 @@ _LOGGER = logging.getLogger(__name__) ATTR_TOTAL_CONSUMPTION = 'total_consumption' ATTR_TOTAL_CONSUMPTION_UNIT = 'total_consumption_unit' -ATTR_TOTAL_CONSUMPTION_UNIT_VALUE = 'kWh' +ATTR_TOTAL_CONSUMPTION_UNIT_VALUE = ENERGY_KILO_WATT_HOUR ATTR_TEMPERATURE_UNIT = 'temperature_unit' diff --git a/homeassistant/components/homematic/sensor.py b/homeassistant/components/homematic/sensor.py index 1bdd2323108..8e3e55e1f7f 100644 --- a/homeassistant/components/homematic/sensor.py +++ b/homeassistant/components/homematic/sensor.py @@ -2,7 +2,7 @@ import logging from homeassistant.components.homematic import ATTR_DISCOVER_DEVICES, HMDevice -from homeassistant.const import STATE_UNKNOWN, POWER_WATT +from homeassistant.const import STATE_UNKNOWN, POWER_WATT, ENERGY_WATT_HOUR _LOGGER = logging.getLogger(__name__) @@ -27,7 +27,7 @@ HM_UNIT_HA_CAST = { 'POWER': POWER_WATT, 'CURRENT': 'mA', 'VOLTAGE': 'V', - 'ENERGY_COUNTER': 'Wh', + 'ENERGY_COUNTER': ENERGY_WATT_HOUR, 'GAS_POWER': 'm3', 'GAS_ENERGY_COUNTER': 'm3', 'LUX': 'lx', diff --git a/homeassistant/components/juicenet/sensor.py b/homeassistant/components/juicenet/sensor.py index 35cf5628d6f..00b183fca46 100644 --- a/homeassistant/components/juicenet/sensor.py +++ b/homeassistant/components/juicenet/sensor.py @@ -1,7 +1,7 @@ """Support for monitoring juicenet/juicepoint/juicebox based EVSE sensors.""" import logging -from homeassistant.const import TEMP_CELSIUS, POWER_WATT +from homeassistant.const import TEMP_CELSIUS, POWER_WATT, ENERGY_WATT_HOUR from homeassistant.helpers.entity import Entity from homeassistant.components.juicenet import JuicenetDevice, DOMAIN @@ -16,7 +16,7 @@ SENSOR_TYPES = { 'amps': ['Amps', 'A'], 'watts': ['Watts', POWER_WATT], 'charge_time': ['Charge time', 's'], - 'energy_added': ['Energy added', 'Wh'] + 'energy_added': ['Energy added', ENERGY_WATT_HOUR] } diff --git a/homeassistant/components/mysensors/sensor.py b/homeassistant/components/mysensors/sensor.py index 7aa19e2157c..9acd47b6238 100644 --- a/homeassistant/components/mysensors/sensor.py +++ b/homeassistant/components/mysensors/sensor.py @@ -1,7 +1,8 @@ """Support for MySensors sensors.""" from homeassistant.components import mysensors from homeassistant.components.sensor import DOMAIN -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT, POWER_WATT +from homeassistant.const import (TEMP_CELSIUS, TEMP_FAHRENHEIT, POWER_WATT, + ENERGY_KILO_WATT_HOUR) SENSORS = { 'V_TEMP': [None, 'mdi:thermometer'], @@ -13,7 +14,7 @@ SENSORS = { 'V_DISTANCE': ['m', 'mdi:ruler'], 'V_IMPEDANCE': ['ohm', None], 'V_WATT': [POWER_WATT, None], - 'V_KWH': ['kWh', None], + 'V_KWH': [ENERGY_KILO_WATT_HOUR, None], 'V_FLOW': ['m', None], 'V_VOLUME': ['m³', None], 'V_VOLTAGE': ['V', 'mdi:flash'], diff --git a/homeassistant/components/sense/sensor.py b/homeassistant/components/sense/sensor.py index c605fc32bc1..4810ebf1958 100644 --- a/homeassistant/components/sense/sensor.py +++ b/homeassistant/components/sense/sensor.py @@ -3,7 +3,7 @@ from datetime import timedelta import logging from homeassistant.components.sense import SENSE_DATA -from homeassistant.const import POWER_WATT +from homeassistant.const import POWER_WATT, ENERGY_KILO_WATT_HOUR from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -91,7 +91,7 @@ class Sense(Entity): if sensor_type == ACTIVE_TYPE: self._unit_of_measurement = POWER_WATT else: - self._unit_of_measurement = 'kWh' + self._unit_of_measurement = ENERGY_KILO_WATT_HOUR @property def name(self): diff --git a/homeassistant/components/sensor/efergy.py b/homeassistant/components/sensor/efergy.py index 18a8fa2e02d..b3c40b4fa25 100644 --- a/homeassistant/components/sensor/efergy.py +++ b/homeassistant/components/sensor/efergy.py @@ -10,7 +10,8 @@ import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_CURRENCY, POWER_WATT +from homeassistant.const import (CONF_CURRENCY, POWER_WATT, + ENERGY_KILO_WATT_HOUR) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -35,7 +36,7 @@ DEFAULT_UTC_OFFSET = '0' SENSOR_TYPES = { CONF_INSTANT: ['Energy Usage', POWER_WATT], - CONF_AMOUNT: ['Energy Consumed', 'kWh'], + CONF_AMOUNT: ['Energy Consumed', ENERGY_KILO_WATT_HOUR], CONF_BUDGET: ['Energy Budget', None], CONF_COST: ['Energy Cost', None], CONF_CURRENT_VALUES: ['Per-Device Usage', POWER_WATT] diff --git a/homeassistant/components/sensor/hydroquebec.py b/homeassistant/components/sensor/hydroquebec.py index cb75e69b919..5f0fd9e01ad 100644 --- a/homeassistant/components/sensor/hydroquebec.py +++ b/homeassistant/components/sensor/hydroquebec.py @@ -14,7 +14,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_USERNAME, CONF_PASSWORD, + CONF_USERNAME, CONF_PASSWORD, ENERGY_KILO_WATT_HOUR, CONF_NAME, CONF_MONITORED_VARIABLES, TEMP_CELSIUS) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -24,7 +24,7 @@ REQUIREMENTS = ['pyhydroquebec==2.2.2'] _LOGGER = logging.getLogger(__name__) -KILOWATT_HOUR = 'kWh' # type: str +KILOWATT_HOUR = ENERGY_KILO_WATT_HOUR PRICE = 'CAD' # type: str DAYS = 'days' # type: str CONF_CONTRACT = 'contract' # type: str diff --git a/homeassistant/components/sensor/linky.py b/homeassistant/components/sensor/linky.py index 8130961bfc0..46e7ed92f45 100644 --- a/homeassistant/components/sensor/linky.py +++ b/homeassistant/components/sensor/linky.py @@ -10,7 +10,8 @@ from datetime import timedelta import voluptuous as vol -from homeassistant.const import CONF_USERNAME, CONF_PASSWORD, CONF_TIMEOUT +from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, CONF_TIMEOUT, + ENERGY_KILO_WATT_HOUR) from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -71,7 +72,7 @@ class LinkySensor(Entity): @property def unit_of_measurement(self): """Return the unit of measurement.""" - return 'kWh' + return ENERGY_KILO_WATT_HOUR @Throttle(SCAN_INTERVAL) def update(self): diff --git a/homeassistant/components/sensor/neurio_energy.py b/homeassistant/components/sensor/neurio_energy.py index a9fbc316751..673cd8da724 100644 --- a/homeassistant/components/sensor/neurio_energy.py +++ b/homeassistant/components/sensor/neurio_energy.py @@ -11,7 +11,8 @@ import requests.exceptions import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import (CONF_API_KEY, POWER_WATT) +from homeassistant.const import (CONF_API_KEY, POWER_WATT, + ENERGY_KILO_WATT_HOUR) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv @@ -150,7 +151,7 @@ class NeurioEnergy(Entity): if sensor_type == ACTIVE_TYPE: self._unit_of_measurement = POWER_WATT elif sensor_type == DAILY_TYPE: - self._unit_of_measurement = 'kWh' + self._unit_of_measurement = ENERGY_KILO_WATT_HOUR @property def name(self): diff --git a/homeassistant/components/sensor/openevse.py b/homeassistant/components/sensor/openevse.py index eabf1739c4f..cf41f87718d 100644 --- a/homeassistant/components/sensor/openevse.py +++ b/homeassistant/components/sensor/openevse.py @@ -11,8 +11,9 @@ import voluptuous as vol import homeassistant.helpers.config_validation as cv from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import TEMP_CELSIUS, CONF_HOST -from homeassistant.const import CONF_MONITORED_VARIABLES +from homeassistant.const import ( + TEMP_CELSIUS, CONF_HOST, ENERGY_KILO_WATT_HOUR, + CONF_MONITORED_VARIABLES) from homeassistant.helpers.entity import Entity REQUIREMENTS = ['openevsewifi==0.4'] @@ -25,8 +26,8 @@ SENSOR_TYPES = { 'ambient_temp': ['Ambient Temperature', TEMP_CELSIUS], 'ir_temp': ['IR Temperature', TEMP_CELSIUS], 'rtc_temp': ['RTC Temperature', TEMP_CELSIUS], - 'usage_session': ['Usage this Session', 'kWh'], - 'usage_total': ['Total Usage', 'kWh'] + 'usage_session': ['Usage this Session', ENERGY_KILO_WATT_HOUR], + 'usage_total': ['Total Usage', ENERGY_KILO_WATT_HOUR] } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ diff --git a/homeassistant/components/sensor/solaredge.py b/homeassistant/components/sensor/solaredge.py index be21316948a..a0d76c564c1 100644 --- a/homeassistant/components/sensor/solaredge.py +++ b/homeassistant/components/sensor/solaredge.py @@ -12,7 +12,8 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_API_KEY, CONF_MONITORED_CONDITIONS, CONF_NAME, POWER_WATT) + CONF_API_KEY, CONF_MONITORED_CONDITIONS, CONF_NAME, POWER_WATT, + ENERGY_WATT_HOUR) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -28,16 +29,16 @@ SCAN_INTERVAL = timedelta(minutes=10) # Supported sensor types: # Key: ['json_key', 'name', unit, icon] SENSOR_TYPES = { - 'lifetime_energy': ['lifeTimeData', "Lifetime energy", 'Wh', - 'mdi:solar-power'], - 'energy_this_year': ['lastYearData', "Energy this year", 'Wh', - 'mdi:solar-power'], - 'energy_this_month': ['lastMonthData', "Energy this month", 'Wh', - 'mdi:solar-power'], - 'energy_today': ['lastDayData', "Energy today", 'Wh', - 'mdi:solar-power'], - 'current_power': ['currentPower', "Current Power", POWER_WATT, - 'mdi:solar-power'] + 'lifetime_energy': ['lifeTimeData', "Lifetime energy", + ENERGY_WATT_HOUR, 'mdi:solar-power'], + 'energy_this_year': ['lastYearData', "Energy this year", + ENERGY_WATT_HOUR, 'mdi:solar-power'], + 'energy_this_month': ['lastMonthData', "Energy this month", + ENERGY_WATT_HOUR, 'mdi:solar-power'], + 'energy_today': ['lastDayData', "Energy today", + ENERGY_WATT_HOUR, 'mdi:solar-power'], + 'current_power': ['currentPower', "Current Power", + POWER_WATT, 'mdi:solar-power'] } PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ diff --git a/homeassistant/components/sensor/srp_energy.py b/homeassistant/components/sensor/srp_energy.py index 8e1de24a2c5..a8466bd8721 100644 --- a/homeassistant/components/sensor/srp_energy.py +++ b/homeassistant/components/sensor/srp_energy.py @@ -12,7 +12,7 @@ from requests.exceptions import ( import voluptuous as vol from homeassistant.const import ( - CONF_NAME, CONF_PASSWORD, + CONF_NAME, CONF_PASSWORD, ENERGY_KILO_WATT_HOUR, CONF_USERNAME, CONF_ID) import homeassistant.helpers.config_validation as cv from homeassistant.util import Throttle @@ -27,7 +27,7 @@ ATTRIBUTION = "Powered by SRP Energy" DEFAULT_NAME = 'SRP Energy' MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=1440) -ENERGY_KWH = 'kWh' +ENERGY_KWH = ENERGY_KILO_WATT_HOUR ATTR_READING_COST = "reading_cost" ATTR_READING_TIME = 'datetime' diff --git a/homeassistant/components/sensor/volkszaehler.py b/homeassistant/components/sensor/volkszaehler.py index d81400d300e..e67d9d6424a 100644 --- a/homeassistant/components/sensor/volkszaehler.py +++ b/homeassistant/components/sensor/volkszaehler.py @@ -11,7 +11,8 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( - CONF_HOST, CONF_NAME, CONF_PORT, CONF_MONITORED_CONDITIONS, POWER_WATT) + CONF_HOST, CONF_NAME, CONF_PORT, CONF_MONITORED_CONDITIONS, POWER_WATT, + ENERGY_WATT_HOUR) from homeassistant.exceptions import PlatformNotReady from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv @@ -32,7 +33,7 @@ MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=1) SENSOR_TYPES = { 'average': ['Average', POWER_WATT, 'mdi:power-off'], - 'consumption': ['Consumption', 'Wh', 'mdi:power-plug'], + 'consumption': ['Consumption', ENERGY_WATT_HOUR, 'mdi:power-plug'], 'max': ['Max', POWER_WATT, 'mdi:arrow-up'], 'min': ['Min', POWER_WATT, 'mdi:arrow-down'], } diff --git a/homeassistant/components/smappee/sensor.py b/homeassistant/components/smappee/sensor.py index afe06d83f47..09584851c6a 100644 --- a/homeassistant/components/smappee/sensor.py +++ b/homeassistant/components/smappee/sensor.py @@ -4,7 +4,7 @@ from datetime import timedelta from homeassistant.components.smappee import DATA_SMAPPEE from homeassistant.helpers.entity import Entity -from homeassistant.const import POWER_WATT +from homeassistant.const import POWER_WATT, ENERGY_KILO_WATT_HOUR DEPENDENCIES = ['smappee'] @@ -24,11 +24,14 @@ SENSOR_TYPES = { 'active_cosfi': ['Power Factor', 'mdi:gauge', 'local', '%', 'active_cosfi'], 'alwayson_today': - ['Always On Today', 'mdi:gauge', 'remote', 'kWh', 'alwaysOn'], + ['Always On Today', 'mdi:gauge', 'remote', ENERGY_KILO_WATT_HOUR, + 'alwaysOn'], 'solar_today': - ['Solar Today', 'mdi:white-balance-sunny', 'remote', 'kWh', 'solar'], + ['Solar Today', 'mdi:white-balance-sunny', 'remote', + ENERGY_KILO_WATT_HOUR, 'solar'], 'power_today': - ['Power Today', 'mdi:power-plug', 'remote', 'kWh', 'consumption'], + ['Power Today', 'mdi:power-plug', 'remote', ENERGY_KILO_WATT_HOUR, + 'consumption'], 'water_sensor_1': ['Water Sensor 1', 'mdi:water', 'water', 'm3', 'value1'], 'water_sensor_2': diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 4b78e8f40ff..58426df7d21 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -5,7 +5,7 @@ from typing import Optional, Sequence from homeassistant.const import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, DEVICE_CLASS_TEMPERATURE, DEVICE_CLASS_TIMESTAMP, MASS_KILOGRAMS, - POWER_WATT, TEMP_CELSIUS, TEMP_FAHRENHEIT) + ENERGY_KILO_WATT_HOUR, POWER_WATT, TEMP_CELSIUS, TEMP_FAHRENHEIT) from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN @@ -54,7 +54,7 @@ CAPABILITY_TO_SENSORS = { Map('fineDustLevel', "Fine Dust Level", None, None), Map('dustLevel', "Dust Level", None, None)], 'energyMeter': [ - Map('energy', "Energy Meter", 'kWh', None)], + Map('energy', "Energy Meter", ENERGY_KILO_WATT_HOUR, None)], 'equivalentCarbonDioxideMeasurement': [ Map('equivalentCarbonDioxideMeasurement', 'Equivalent Carbon Dioxide Measurement', 'ppm', None)], diff --git a/homeassistant/components/switch/fritzdect.py b/homeassistant/components/switch/fritzdect.py index 2aefcec9670..0d9008552a1 100644 --- a/homeassistant/components/switch/fritzdect.py +++ b/homeassistant/components/switch/fritzdect.py @@ -12,7 +12,7 @@ import voluptuous as vol from homeassistant.components.switch import (SwitchDevice, PLATFORM_SCHEMA) from homeassistant.const import ( - CONF_HOST, CONF_PASSWORD, CONF_USERNAME, POWER_WATT) + CONF_HOST, CONF_PASSWORD, CONF_USERNAME, POWER_WATT, ENERGY_KILO_WATT_HOUR) import homeassistant.helpers.config_validation as cv from homeassistant.const import TEMP_CELSIUS, ATTR_TEMPERATURE @@ -29,7 +29,7 @@ ATTR_CURRENT_CONSUMPTION_UNIT_VALUE = POWER_WATT ATTR_TOTAL_CONSUMPTION = 'total_consumption' ATTR_TOTAL_CONSUMPTION_UNIT = 'total_consumption_unit' -ATTR_TOTAL_CONSUMPTION_UNIT_VALUE = 'kWh' +ATTR_TOTAL_CONSUMPTION_UNIT_VALUE = ENERGY_KILO_WATT_HOUR ATTR_TEMPERATURE_UNIT = 'temperature_unit' diff --git a/homeassistant/components/toon/const.py b/homeassistant/components/toon/const.py index 29b58fbfff9..4d8ccd70e12 100644 --- a/homeassistant/components/toon/const.py +++ b/homeassistant/components/toon/const.py @@ -1,4 +1,6 @@ """Constants for the Toon integration.""" +from homeassistant.const import ENERGY_KILO_WATT_HOUR + DOMAIN = 'toon' DATA_TOON = 'toon' @@ -15,7 +17,7 @@ DEFAULT_MIN_TEMP = 6.0 CURRENCY_EUR = 'EUR' POWER_WATT = 'W' -POWER_KWH = 'kWh' +POWER_KWH = ENERGY_KILO_WATT_HOUR RATIO_PERCENT = '%' VOLUME_CM3 = 'CM3' VOLUME_M3 = 'M3' diff --git a/homeassistant/const.py b/homeassistant/const.py index f24fbcc97ac..85097dcb652 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -325,6 +325,10 @@ ATTR_TEMPERATURE = 'temperature' # Power units POWER_WATT = 'W' +# Energy units +ENERGY_KILO_WATT_HOUR = 'kWh' +ENERGY_WATT_HOUR = 'Wh' + # Temperature units TEMP_CELSIUS = '°C' TEMP_FAHRENHEIT = '°F' From bf839687ad68f5dd1f72111d1876062868a06461 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Tue, 12 Mar 2019 22:04:27 -0700 Subject: [PATCH 220/291] Mobile App: Registration schema improvements (#21850) * Update registration schema to add os_name (required) and make app_name required * Ensure that a provided app_component is valid and available * Ensure that component DEPENDENCIES declares mobile_app * Update homeassistant/helpers/config_validation.py * Standardize error responses * Dont generalize REGISTER_BAD_COMPONENT * Fix tests after merge --- homeassistant/components/mobile_app/const.py | 9 ++++++- .../components/mobile_app/helpers.py | 14 +++++++++- .../components/mobile_app/http_api.py | 27 +++++++++++++++---- .../components/mobile_app/webhook.py | 14 +++++----- tests/components/mobile_app/const.py | 2 ++ tests/components/mobile_app/test_http_api.py | 25 +++++++++++++++++ 6 files changed, 76 insertions(+), 15 deletions(-) diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index d2f32b8877c..60b4cde4708 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -26,6 +26,7 @@ ATTR_APP_VERSION = 'app_version' ATTR_DEVICE_NAME = 'device_name' ATTR_MANUFACTURER = 'manufacturer' ATTR_MODEL = 'model' +ATTR_OS_NAME = 'os_name' ATTR_OS_VERSION = 'os_version' ATTR_SUPPORTS_ENCRYPTION = 'supports_encryption' @@ -40,6 +41,10 @@ ATTR_WEBHOOK_ENCRYPTED = 'encrypted' ATTR_WEBHOOK_ENCRYPTED_DATA = 'encrypted_data' ATTR_WEBHOOK_TYPE = 'type' +ERR_INVALID_COMPONENT = 'invalid_component' +ERR_RENDER_FAILURE = 'render_failure' +ERR_SAVE_FAILURE = 'save_failure' + WEBHOOK_TYPE_CALL_SERVICE = 'call_service' WEBHOOK_TYPE_FIRE_EVENT = 'fire_event' WEBHOOK_TYPE_RENDER_TEMPLATE = 'render_template' @@ -50,15 +55,17 @@ WEBHOOK_TYPES = [WEBHOOK_TYPE_CALL_SERVICE, WEBHOOK_TYPE_FIRE_EVENT, WEBHOOK_TYPE_RENDER_TEMPLATE, WEBHOOK_TYPE_UPDATE_LOCATION, WEBHOOK_TYPE_UPDATE_REGISTRATION] + REGISTRATION_SCHEMA = vol.Schema({ vol.Optional(ATTR_APP_COMPONENT): cv.string, vol.Optional(ATTR_APP_DATA, default={}): dict, vol.Required(ATTR_APP_ID): cv.string, - vol.Optional(ATTR_APP_NAME): cv.string, + vol.Required(ATTR_APP_NAME): cv.string, vol.Required(ATTR_APP_VERSION): cv.string, vol.Required(ATTR_DEVICE_NAME): cv.string, vol.Required(ATTR_MANUFACTURER): cv.string, vol.Required(ATTR_MODEL): cv.string, + vol.Required(ATTR_OS_NAME): cv.string, vol.Optional(ATTR_OS_VERSION): cv.string, vol.Required(ATTR_SUPPORTS_ENCRYPTION, default=False): cv.boolean, }) diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py index 28d8a797a32..1f67170a72c 100644 --- a/homeassistant/components/mobile_app/helpers.py +++ b/homeassistant/components/mobile_app/helpers.py @@ -3,7 +3,7 @@ import logging import json from typing import Callable, Dict, Tuple -from aiohttp.web import Response +from aiohttp.web import json_response, Response from homeassistant.core import Context from homeassistant.helpers.typing import HomeAssistantType @@ -84,6 +84,18 @@ def empty_okay_response(headers: Dict = None, status: int = 200) -> Response: headers=headers) +def error_response(code: str, message: str, status: int = 400, + headers: dict = None) -> Response: + """Return an error Response.""" + return json_response({ + 'success': False, + 'error': { + 'code': code, + 'message': message + } + }, status=status, headers=headers) + + def supports_encryption() -> bool: """Test if we support encryption.""" try: diff --git a/homeassistant/components/mobile_app/http_api.py b/homeassistant/components/mobile_app/http_api.py index 15e1385359e..30083cc86b1 100644 --- a/homeassistant/components/mobile_app/http_api.py +++ b/homeassistant/components/mobile_app/http_api.py @@ -13,12 +13,14 @@ from homeassistant.const import (HTTP_CREATED, HTTP_INTERNAL_SERVER_ERROR, from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.loader import get_component -from .const import (DATA_REGISTRATIONS, ATTR_SUPPORTS_ENCRYPTION, +from .const import (ATTR_APP_COMPONENT, ATTR_SUPPORTS_ENCRYPTION, CONF_CLOUDHOOK_URL, CONF_SECRET, CONF_USER_ID, - DOMAIN, REGISTRATION_SCHEMA) + DATA_REGISTRATIONS, DOMAIN, ERR_INVALID_COMPONENT, + ERR_SAVE_FAILURE, REGISTRATION_SCHEMA) -from .helpers import supports_encryption, savable_state +from .helpers import error_response, supports_encryption, savable_state from .webhook import setup_registration @@ -44,6 +46,20 @@ class RegistrationsView(HomeAssistantView): """Handle the POST request for registration.""" hass = request.app['hass'] + if ATTR_APP_COMPONENT in data: + component = get_component(hass, data[ATTR_APP_COMPONENT]) + if component is None: + fmt_str = "{} is not a valid component." + msg = fmt_str.format(data[ATTR_APP_COMPONENT]) + return error_response(ERR_INVALID_COMPONENT, msg) + + if (hasattr(component, 'DEPENDENCIES') is False or + (hasattr(component, 'DEPENDENCIES') and + DOMAIN not in component.DEPENDENCIES)): + fmt_str = "{} is not compatible with mobile_app." + msg = fmt_str.format(data[ATTR_APP_COMPONENT]) + return error_response(ERR_INVALID_COMPONENT, msg) + webhook_id = generate_secret() if hass.components.cloud.async_active_subscription(): @@ -64,8 +80,9 @@ class RegistrationsView(HomeAssistantView): try: await self._store.async_save(savable_state(hass)) except HomeAssistantError: - return self.json_message("Error saving registration.", - HTTP_INTERNAL_SERVER_ERROR) + return error_response(ERR_SAVE_FAILURE, + "Error saving registration", + status=HTTP_INTERNAL_SERVER_ERROR) setup_registration(hass, self._store, data) diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index a14354e4ae3..61188b50e1b 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -25,13 +25,13 @@ from .const import (ATTR_APP_COMPONENT, DATA_DELETED_IDS, DATA_REGISTRATIONS, ATTR_TEMPLATE, ATTR_TEMPLATE_VARIABLES, ATTR_WEBHOOK_DATA, ATTR_WEBHOOK_ENCRYPTED, ATTR_WEBHOOK_ENCRYPTED_DATA, ATTR_WEBHOOK_TYPE, - CONF_SECRET, DOMAIN, WEBHOOK_PAYLOAD_SCHEMA, - WEBHOOK_SCHEMAS, WEBHOOK_TYPE_CALL_SERVICE, - WEBHOOK_TYPE_FIRE_EVENT, WEBHOOK_TYPE_RENDER_TEMPLATE, - WEBHOOK_TYPE_UPDATE_LOCATION, + CONF_SECRET, DOMAIN, ERR_RENDER_FAILURE, + WEBHOOK_PAYLOAD_SCHEMA, WEBHOOK_SCHEMAS, + WEBHOOK_TYPE_CALL_SERVICE, WEBHOOK_TYPE_FIRE_EVENT, + WEBHOOK_TYPE_RENDER_TEMPLATE, WEBHOOK_TYPE_UPDATE_LOCATION, WEBHOOK_TYPE_UPDATE_REGISTRATION) -from .helpers import (_decrypt_payload, empty_okay_response, +from .helpers import (_decrypt_payload, empty_okay_response, error_response, registration_context, safe_registration, savable_state, webhook_response) @@ -135,9 +135,7 @@ async def handle_webhook(store: Store, hass: HomeAssistantType, _LOGGER.error("Error when rendering template during mobile_app " "webhook (device name: %s): %s", registration[ATTR_DEVICE_NAME], ex) - return webhook_response(({"error": str(ex)}), - status=HTTP_BAD_REQUEST, - registration=registration, headers=headers) + return error_response(ERR_RENDER_FAILURE, str(ex), headers=headers) if webhook_type == WEBHOOK_TYPE_UPDATE_LOCATION: try: diff --git a/tests/components/mobile_app/const.py b/tests/components/mobile_app/const.py index 63b37932104..919a2a6e1fb 100644 --- a/tests/components/mobile_app/const.py +++ b/tests/components/mobile_app/const.py @@ -28,6 +28,7 @@ REGISTER = { 'device_name': 'Test 1', 'manufacturer': 'mobile_app', 'model': 'Test', + 'os_name': 'Linux', 'os_version': '1.0', 'supports_encryption': True } @@ -40,6 +41,7 @@ REGISTER_CLEARTEXT = { 'device_name': 'Test 1', 'manufacturer': 'mobile_app', 'model': 'Test', + 'os_name': 'Linux', 'os_version': '1.0', 'supports_encryption': False } diff --git a/tests/components/mobile_app/test_http_api.py b/tests/components/mobile_app/test_http_api.py index 3ff93bdfa75..195d33e15b2 100644 --- a/tests/components/mobile_app/test_http_api.py +++ b/tests/components/mobile_app/test_http_api.py @@ -63,3 +63,28 @@ async def test_registration(hass_client, authed_api_client): # noqa: F811 decrypted_data = decrypted_data.decode("utf-8") assert json.loads(decrypted_data) == {'rendered': 'Hello world'} + + +async def test_register_invalid_component(authed_api_client): # noqa: F811 + """Test that registration with invalid component fails.""" + resp = await authed_api_client.post( + '/api/mobile_app/registrations', json={ + 'app_component': 'will_never_be_valid', + 'app_data': {'foo': 'bar'}, + 'app_id': 'io.homeassistant.mobile_app_test', + 'app_name': 'Mobile App Tests', + 'app_version': '1.0.0', + 'device_name': 'Test 1', + 'manufacturer': 'mobile_app', + 'model': 'Test', + 'os_name': 'Linux', + 'os_version': '1.0', + 'supports_encryption': True + } + ) + + assert resp.status == 400 + register_json = await resp.json() + assert 'error' in register_json + assert register_json['success'] is False + assert register_json['error']['code'] == 'invalid_component' From c15f433c3e1e2eafb1d983c1deb20bfa3e7c70ff Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 12 Mar 2019 22:09:50 -0700 Subject: [PATCH 221/291] Add a service require_admin wrapper (#21953) * Add a service require_admin wrapper * Allow it to be used as a decorator * Lint * Add comment * Add docstring * Update syntax --- homeassistant/helpers/service.py | 26 +++++++++++++++++++++- tests/helpers/test_service.py | 38 ++++++++++++++++++++++++++++++-- 2 files changed, 61 insertions(+), 3 deletions(-) diff --git a/homeassistant/helpers/service.py b/homeassistant/helpers/service.py index b685e0d67c7..43b8318abc5 100644 --- a/homeassistant/helpers/service.py +++ b/homeassistant/helpers/service.py @@ -1,7 +1,9 @@ """Service calling related helpers.""" import asyncio +from functools import wraps import logging from os import path +from typing import Callable import voluptuous as vol @@ -10,7 +12,7 @@ from homeassistant.const import ( ATTR_ENTITY_ID, ENTITY_MATCH_ALL, ATTR_AREA_ID) import homeassistant.core as ha from homeassistant.exceptions import TemplateError, Unauthorized, UnknownUser -from homeassistant.helpers import template +from homeassistant.helpers import template, typing from homeassistant.loader import get_component, bind_hass from homeassistant.util.yaml import load_yaml import homeassistant.helpers.config_validation as cv @@ -335,3 +337,25 @@ async def _handle_service_platform_call(func, data, entities, context): assert not pending for future in done: future.result() # pop exception if have + + +@bind_hass +@ha.callback +def async_register_admin_service(hass: typing.HomeAssistantType, domain: str, + service: str, service_func: Callable, + schema: vol.Schema) -> None: + """Register a service that requires admin access.""" + @wraps(service_func) + async def admin_handler(call): + if call.context.user_id: + user = await hass.auth.async_get_user(call.context.user_id) + if user is None: + raise UnknownUser(context=call.context) + if not user.is_admin: + raise Unauthorized(context=call.context) + + await hass.async_add_job(service_func, call) + + hass.services.async_register( + domain, service, admin_handler, schema + ) diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index 854ee9c74f6..a36785b6ba0 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -5,18 +5,18 @@ from copy import deepcopy import unittest from unittest.mock import Mock, patch +import voluptuous as vol import pytest # To prevent circular import when running just this file import homeassistant.components # noqa from homeassistant import core as ha, loader, exceptions from homeassistant.const import STATE_ON, STATE_OFF, ATTR_ENTITY_ID -from homeassistant.helpers import service, template from homeassistant.setup import async_setup_component import homeassistant.helpers.config_validation as cv from homeassistant.auth.permissions import PolicyPermissions from homeassistant.helpers import ( - device_registry as dev_reg, entity_registry as ent_reg) + service, template, device_registry as dev_reg, entity_registry as ent_reg) from tests.common import ( get_test_home_assistant, mock_service, mock_coro, mock_registry, mock_device_registry) @@ -395,3 +395,37 @@ async def test_call_with_omit_entity_id(hass, mock_service_platform_call, mock_entities['light.kitchen'], mock_entities['light.living_room']] assert ('Not passing an entity ID to a service to target ' 'all entities is deprecated') in caplog.text + + +async def test_register_admin_service(hass, hass_read_only_user, + hass_admin_user): + """Test the register admin service.""" + calls = [] + + async def mock_service(call): + calls.append(call) + + hass.helpers.service.async_register_admin_service( + 'test', 'test', mock_service, vol.Schema({}) + ) + + with pytest.raises(exceptions.UnknownUser): + await hass.services.async_call( + 'test', 'test', {}, blocking=True, context=ha.Context( + user_id='non-existing' + )) + assert len(calls) == 0 + + with pytest.raises(exceptions.Unauthorized): + await hass.services.async_call( + 'test', 'test', {}, blocking=True, context=ha.Context( + user_id=hass_read_only_user.id + )) + assert len(calls) == 0 + + await hass.services.async_call( + 'test', 'test', {}, blocking=True, context=ha.Context( + user_id=hass_admin_user.id + )) + assert len(calls) == 1 + assert calls[0].context.user_id == hass_admin_user.id From 0162e2abe55659d3eb3a4e974466abb3d1678dd5 Mon Sep 17 00:00:00 2001 From: Daniel Perna Date: Wed, 13 Mar 2019 08:56:59 +0100 Subject: [PATCH 222/291] Update pyhomematic to 0.1.58 (#21989) --- homeassistant/components/homematic/__init__.py | 4 ++-- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/homematic/__init__.py b/homeassistant/components/homematic/__init__.py index 1ee50fa16a4..a8109af5ed8 100644 --- a/homeassistant/components/homematic/__init__.py +++ b/homeassistant/components/homematic/__init__.py @@ -14,7 +14,7 @@ from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['pyhomematic==0.1.57'] +REQUIREMENTS = ['pyhomematic==0.1.58'] _LOGGER = logging.getLogger(__name__) @@ -85,7 +85,7 @@ HM_DEVICE_TYPES = { 'IPShutterContact', 'HMWIOSwitch', 'MaxShutterContact', 'Rain', 'WiredSensor', 'PresenceIP', 'IPWeatherSensor', 'IPPassageSensor', 'SmartwareMotion', 'IPWeatherSensorPlus', 'MotionIPV2', 'WaterIP', - 'IPMultiIO', 'TiltIP'], + 'IPMultiIO', 'TiltIP', 'IPShutterContactSabotage'], DISCOVER_COVER: ['Blind', 'KeyBlind', 'IPKeyBlind', 'IPKeyBlindTilt'], DISCOVER_LOCKS: ['KeyMatic'] } diff --git a/requirements_all.txt b/requirements_all.txt index 3af20b32975..1af8f373c6f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1083,7 +1083,7 @@ pyhik==0.2.2 pyhiveapi==0.2.17 # homeassistant.components.homematic -pyhomematic==0.1.57 +pyhomematic==0.1.58 # homeassistant.components.homeworks pyhomeworks==0.0.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c2018eb92a9..cf104aff669 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -203,7 +203,7 @@ pydeconz==52 pydispatcher==2.0.5 # homeassistant.components.homematic -pyhomematic==0.1.57 +pyhomematic==0.1.58 # homeassistant.components.litejet pylitejet==0.1 From c8692fe70c004217ab97d3ddae3f10572375bbea Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Wed, 13 Mar 2019 10:17:09 +0100 Subject: [PATCH 223/291] Use asyncio lock (#21985) --- .../components/sonos/media_player.py | 284 ++++++++++-------- tests/components/sonos/test_media_player.py | 9 +- 2 files changed, 174 insertions(+), 119 deletions(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index fbac67b0927..c07057bfb63 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -3,7 +3,7 @@ import datetime import functools as ft import logging import socket -import threading +import asyncio import urllib import requests @@ -111,11 +111,11 @@ SONOS_SET_OPTION_SCHEMA = SONOS_SCHEMA.extend({ class SonosData: """Storage class for platform global data.""" - def __init__(self): + def __init__(self, hass): """Initialize the data.""" self.uids = set() self.entities = [] - self.topology_lock = threading.Lock() + self.topology_lock = asyncio.Lock(loop=hass.loop) def setup_platform(hass, config, add_entities, discovery_info=None): @@ -143,7 +143,7 @@ def _setup_platform(hass, config, add_entities, discovery_info): import pysonos if DATA_SONOS not in hass.data: - hass.data[DATA_SONOS] = SonosData() + hass.data[DATA_SONOS] = SonosData(hass) advertise_addr = config.get(CONF_ADVERTISE_ADDR) if advertise_addr: @@ -187,57 +187,62 @@ def _setup_platform(hass, config, add_entities, discovery_info): add_entities(SonosEntity(p) for p in players) _LOGGER.debug("Added %s Sonos speakers", len(players)) - def service_handle(service): - """Handle for services.""" + def _service_to_entities(service): + """Extract and return entities from service call.""" entity_ids = service.data.get('entity_id') entities = hass.data[DATA_SONOS].entities if entity_ids: entities = [e for e in entities if e.entity_id in entity_ids] - with hass.data[DATA_SONOS].topology_lock: - if service.service == SERVICE_SNAPSHOT: - SonosEntity.snapshot_multi( - entities, service.data[ATTR_WITH_GROUP]) - elif service.service == SERVICE_RESTORE: - SonosEntity.restore_multi( - entities, service.data[ATTR_WITH_GROUP]) - elif service.service == SERVICE_JOIN: - master = [e for e in hass.data[DATA_SONOS].entities - if e.entity_id == service.data[ATTR_MASTER]] - if master: - master[0].join(entities) - else: - for entity in entities: - if service.service == SERVICE_UNJOIN: - entity.unjoin() - elif service.service == SERVICE_SET_TIMER: - entity.set_sleep_timer(service.data[ATTR_SLEEP_TIME]) - elif service.service == SERVICE_CLEAR_TIMER: - entity.clear_sleep_timer() - elif service.service == SERVICE_UPDATE_ALARM: - entity.set_alarm(**service.data) - elif service.service == SERVICE_SET_OPTION: - entity.set_option(**service.data) + return entities - entity.schedule_update_ha_state(True) + async def async_service_handle(service): + """Handle async services.""" + entities = _service_to_entities(service) + + if service.service == SERVICE_JOIN: + master = [e for e in hass.data[DATA_SONOS].entities + if e.entity_id == service.data[ATTR_MASTER]] + if master: + await SonosEntity.join_multi(hass, master[0], entities) + elif service.service == SERVICE_UNJOIN: + await SonosEntity.unjoin_multi(hass, entities) + elif service.service == SERVICE_SNAPSHOT: + await SonosEntity.snapshot_multi( + hass, entities, service.data[ATTR_WITH_GROUP]) + elif service.service == SERVICE_RESTORE: + await SonosEntity.restore_multi( + hass, entities, service.data[ATTR_WITH_GROUP]) hass.services.register( - DOMAIN, SERVICE_JOIN, service_handle, + DOMAIN, SERVICE_JOIN, async_service_handle, schema=SONOS_JOIN_SCHEMA) hass.services.register( - DOMAIN, SERVICE_UNJOIN, service_handle, + DOMAIN, SERVICE_UNJOIN, async_service_handle, schema=SONOS_SCHEMA) hass.services.register( - DOMAIN, SERVICE_SNAPSHOT, service_handle, + DOMAIN, SERVICE_SNAPSHOT, async_service_handle, schema=SONOS_STATES_SCHEMA) hass.services.register( - DOMAIN, SERVICE_RESTORE, service_handle, + DOMAIN, SERVICE_RESTORE, async_service_handle, schema=SONOS_STATES_SCHEMA) + def service_handle(service): + """Handle sync services.""" + for entity in _service_to_entities(service): + if service.service == SERVICE_SET_TIMER: + entity.set_sleep_timer(service.data[ATTR_SLEEP_TIME]) + elif service.service == SERVICE_CLEAR_TIMER: + entity.clear_sleep_timer() + elif service.service == SERVICE_UPDATE_ALARM: + entity.set_alarm(**service.data) + elif service.service == SERVICE_SET_OPTION: + entity.set_option(**service.data) + hass.services.register( DOMAIN, SERVICE_SET_TIMER, service_handle, schema=SONOS_SET_TIMER_SCHEMA) @@ -701,52 +706,68 @@ class SonosEntity(MediaPlayerDevice): self._speech_enhance = self.soco.dialog_mode def update_groups(self, event=None): - """Process a zone group topology event coming from a player.""" + """Handle callback for topology change event.""" + def _get_soco_group(): + """Ask SoCo cache for existing topology.""" + coordinator_uid = self.unique_id + slave_uids = [] + + try: + if self.soco.group and self.soco.group.coordinator: + coordinator_uid = self.soco.group.coordinator.uid + slave_uids = [p.uid for p in self.soco.group.members + if p.uid != coordinator_uid] + except requests.exceptions.RequestException: + pass + + return [coordinator_uid] + slave_uids + + async def _async_extract_group(event): + """Extract group layout from a topology event.""" + group = event and event.zone_player_uui_ds_in_group + if group: + return group.split(',') + + return await self.hass.async_add_executor_job(_get_soco_group) + + def _async_regroup(group): + """Rebuild internal group layout.""" + sonos_group = [] + for uid in group: + entity = _get_entity_from_soco_uid(self.hass, uid) + if entity: + sonos_group.append(entity) + + self._coordinator = None + self._sonos_group = sonos_group + self.async_schedule_update_ha_state() + + for slave_uid in group[1:]: + slave = _get_entity_from_soco_uid(self.hass, slave_uid) + if slave: + # pylint: disable=protected-access + slave._coordinator = self + slave._sonos_group = sonos_group + slave.async_schedule_update_ha_state() + + async def _async_handle_group_event(event): + """Get async lock and handle event.""" + async with self.hass.data[DATA_SONOS].topology_lock: + group = await _async_extract_group(event) + + if self.unique_id == group[0]: + if self._restore_pending: + await self.hass.async_add_executor_job(self.restore) + + _async_regroup(group) + if event: self._receives_events = True if not hasattr(event, 'zone_player_uui_ds_in_group'): return - with self.hass.data[DATA_SONOS].topology_lock: - group = event and event.zone_player_uui_ds_in_group - if group: - # New group information is pushed - coordinator_uid, *slave_uids = group.split(',') - else: - coordinator_uid = self.unique_id - slave_uids = [] - - # Try SoCo cache for existing topology - try: - if self.soco.group and self.soco.group.coordinator: - coordinator_uid = self.soco.group.coordinator.uid - slave_uids = [p.uid for p in self.soco.group.members - if p.uid != coordinator_uid] - except requests.exceptions.RequestException: - pass - - if self.unique_id == coordinator_uid: - if self._restore_pending: - self.restore() - - sonos_group = [] - for uid in (coordinator_uid, *slave_uids): - entity = _get_entity_from_soco_uid(self.hass, uid) - if entity: - sonos_group.append(entity) - - self._coordinator = None - self._sonos_group = sonos_group - self.schedule_update_ha_state() - - for slave_uid in slave_uids: - slave = _get_entity_from_soco_uid(self.hass, slave_uid) - if slave: - # pylint: disable=protected-access - slave._coordinator = self - slave._sonos_group = sonos_group - slave.schedule_update_ha_state() + self.hass.add_job(_async_handle_group_event(event)) def update_content(self, event=None): """Update information about available content.""" @@ -974,12 +995,29 @@ class SonosEntity(MediaPlayerDevice): # pylint: disable=protected-access slave._coordinator = self + @staticmethod + async def join_multi(hass, master, entities): + """Form a group with other players.""" + async with hass.data[DATA_SONOS].topology_lock: + await hass.async_add_executor_job(master.join, entities) + @soco_error() def unjoin(self): """Unjoin the player from a group.""" self.soco.unjoin() self._coordinator = None + @staticmethod + async def unjoin_multi(hass, entities): + """Unjoin several players from their group.""" + def _unjoin_all(entities): + """Sync helper.""" + for entity in entities: + entity.unjoin() + + async with hass.data[DATA_SONOS].topology_lock: + await hass.async_add_executor_job(_unjoin_all, entities) + @soco_error() def snapshot(self, with_group): """Snapshot the state of a player.""" @@ -992,6 +1030,25 @@ class SonosEntity(MediaPlayerDevice): else: self._snapshot_group = None + @staticmethod + async def snapshot_multi(hass, entities, with_group): + """Snapshot all the entities and optionally their groups.""" + # pylint: disable=protected-access + + def _snapshot_all(entities): + """Sync helper.""" + for entity in entities: + entity.snapshot(with_group) + + # Find all affected players + entities = set(entities) + if with_group: + for entity in list(entities): + entities.update(entity._sonos_group) + + async with hass.data[DATA_SONOS].topology_lock: + await hass.async_add_executor_job(_snapshot_all, entities) + @soco_error() def restore(self): """Restore a snapshotted state to a player.""" @@ -1010,56 +1067,49 @@ class SonosEntity(MediaPlayerDevice): self._restore_pending = False @staticmethod - def snapshot_multi(entities, with_group): - """Snapshot all the entities and optionally their groups.""" - # pylint: disable=protected-access - # Find all affected players - entities = set(entities) - if with_group: - for entity in list(entities): - entities.update(entity._sonos_group) - - for entity in entities: - entity.snapshot(with_group) - - @staticmethod - def restore_multi(entities, with_group): + async def restore_multi(hass, entities, with_group): """Restore snapshots for all the entities.""" # pylint: disable=protected-access + + def _restore_all(entities): + """Sync helper.""" + # Pause all current coordinators + for entity in (e for e in entities if e.is_coordinator): + if entity.state == STATE_PLAYING: + entity.media_pause() + + if with_group: + # Unjoin slaves that are not already in their target group + for entity in [e for e in entities if not e.is_coordinator]: + if entity._snapshot_group != entity._sonos_group: + entity.unjoin() + + # Bring back the original group topology + for entity in (e for e in entities if e._snapshot_group): + if entity._snapshot_group[0] == entity: + entity.join(entity._snapshot_group) + + # Restore slaves + for entity in (e for e in entities if not e.is_coordinator): + entity.restore() + + # Restore coordinators (or delay if moving from slave) + for entity in (e for e in entities if e.is_coordinator): + if entity._sonos_group[0] == entity: + # Was already coordinator + entity.restore() + else: + # Await coordinator role + entity._restore_pending = True + # Find all affected players entities = set(e for e in entities if e._soco_snapshot) if with_group: for entity in [e for e in entities if e._snapshot_group]: entities.update(entity._snapshot_group) - # Pause all current coordinators - for entity in (e for e in entities if e.is_coordinator): - if entity.state == STATE_PLAYING: - entity.media_pause() - - if with_group: - # Unjoin slaves that are not already in their target group - for entity in [e for e in entities if not e.is_coordinator]: - if entity._snapshot_group != entity._sonos_group: - entity.unjoin() - - # Bring back the original group topology - for entity in (e for e in entities if e._snapshot_group): - if entity._snapshot_group[0] == entity: - entity.join(entity._snapshot_group) - - # Restore slaves - for entity in (e for e in entities if not e.is_coordinator): - entity.restore() - - # Restore coordinators (or delay if moving from slave) - for entity in (e for e in entities if e.is_coordinator): - if entity._sonos_group[0] == entity: - # Was already coordinator - entity.restore() - else: - # Await coordinator role - entity._restore_pending = True + async with hass.data[DATA_SONOS].topology_lock: + await hass.async_add_executor_job(_restore_all, entities) @soco_error() @soco_coordinator diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index 798c92eddad..4cb4a291b16 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -12,6 +12,7 @@ from homeassistant.components.sonos import media_player as sonos from homeassistant.components.media_player.const import DOMAIN from homeassistant.components.sonos.media_player import CONF_INTERFACE_ADDR from homeassistant.const import CONF_HOSTS, CONF_PLATFORM +from homeassistant.util.async_ import run_coroutine_threadsafe from tests.common import get_test_home_assistant @@ -328,7 +329,9 @@ class TestSonosMediaPlayer(unittest.TestCase): snapshotMock.return_value = True entity.soco.group = mock.MagicMock() entity.soco.group.members = [e.soco for e in entities] - sonos.SonosEntity.snapshot_multi(entities, True) + run_coroutine_threadsafe( + sonos.SonosEntity.snapshot_multi(self.hass, entities, True), + self.hass.loop).result() assert snapshotMock.call_count == 1 assert snapshotMock.call_args == mock.call() @@ -350,6 +353,8 @@ class TestSonosMediaPlayer(unittest.TestCase): entity._snapshot_group = mock.MagicMock() entity._snapshot_group.members = [e.soco for e in entities] entity._soco_snapshot = Snapshot(entity.soco) - sonos.SonosEntity.restore_multi(entities, True) + run_coroutine_threadsafe( + sonos.SonosEntity.restore_multi(self.hass, entities, True), + self.hass.loop).result() assert restoreMock.call_count == 1 assert restoreMock.call_args == mock.call() From e5da7a0014fb9736e799847cef6be2a18dab8d59 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Wed, 13 Mar 2019 02:54:30 -0700 Subject: [PATCH 224/291] Add breaking change section to PR template (#21994) --- .github/PULL_REQUEST_TEMPLATE.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md index 53cc6960fc3..ecdbddf5b5d 100644 --- a/.github/PULL_REQUEST_TEMPLATE.md +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -1,3 +1,7 @@ +## Breaking Change: + + + ## Description: From 007bf2bcb52112c8cb6e280dddf7883cf4b5a704 Mon Sep 17 00:00:00 2001 From: Jeff Irion Date: Wed, 13 Mar 2019 03:18:59 -0700 Subject: [PATCH 225/291] Rename 'firetv' to 'androidtv' and add Android TV functionality (#21944) * Working on adding androidtv functionality to firetv component * 'should_poll' must return True * Change 'properties' to 'device_properties' * Also mention 'Android TV' in services.yaml * Use GitHub for 'androidtv' requirement * Add 'androidtv==0.0.10' to requirements, remove 'firetv==1.0.9' * Add 'GET_PROPERTIES' adb command option; use pypi for REQUIREMENTS * Rename integration from 'firetv' to 'androidtv' * Change default name to 'Android TV' * Rename integration from 'firetv' to 'androidtv' * Change firetv to androidtv in .coveragerc * Change firetv to androidtv in requirements_all.txt * Remove 'DEFAULT_APPS' --- .coveragerc | 2 +- .../components/androidtv/__init__.py | 6 + .../{firetv => androidtv}/media_player.py | 410 +++++++++++------- .../components/androidtv/services.yaml | 11 + homeassistant/components/firetv/__init__.py | 6 - homeassistant/components/firetv/services.yaml | 11 - requirements_all.txt | 6 +- 7 files changed, 284 insertions(+), 168 deletions(-) create mode 100644 homeassistant/components/androidtv/__init__.py rename homeassistant/components/{firetv => androidtv}/media_player.py (52%) create mode 100644 homeassistant/components/androidtv/services.yaml delete mode 100644 homeassistant/components/firetv/__init__.py delete mode 100644 homeassistant/components/firetv/services.yaml diff --git a/.coveragerc b/.coveragerc index b25d8e1f54b..b7f2961f14d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -27,6 +27,7 @@ omit = homeassistant/components/ambient_station/* homeassistant/components/amcrest/* homeassistant/components/android_ip_webcam/* + homeassistant/components/androidtv/* homeassistant/components/apcupsd/* homeassistant/components/apiai/* homeassistant/components/apple_tv/* @@ -172,7 +173,6 @@ omit = homeassistant/components/fan/wemo.py homeassistant/components/fastdotcom/* homeassistant/components/fibaro/* - homeassistant/components/firetv/* homeassistant/components/folder_watcher/* homeassistant/components/foursquare/* homeassistant/components/freebox/* diff --git a/homeassistant/components/androidtv/__init__.py b/homeassistant/components/androidtv/__init__.py new file mode 100644 index 00000000000..fd108e05973 --- /dev/null +++ b/homeassistant/components/androidtv/__init__.py @@ -0,0 +1,6 @@ +""" +Support for functionality to interact with Android TV and Fire TV devices. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/media_player.androidtv/ +""" diff --git a/homeassistant/components/firetv/media_player.py b/homeassistant/components/androidtv/media_player.py similarity index 52% rename from homeassistant/components/firetv/media_player.py rename to homeassistant/components/androidtv/media_player.py index ca7f1de4246..ab43dc8c6ea 100644 --- a/homeassistant/components/firetv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -1,8 +1,8 @@ """ -Support for functionality to interact with FireTV devices. +Support for functionality to interact with Android TV and Fire TV devices. For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/media_player.firetv/ +https://home-assistant.io/components/media_player.androidtv/ """ import functools import logging @@ -12,18 +12,25 @@ from homeassistant.components.media_player import ( MediaPlayerDevice, PLATFORM_SCHEMA) from homeassistant.components.media_player.const import ( SUPPORT_NEXT_TRACK, SUPPORT_PAUSE, SUPPORT_PLAY, SUPPORT_PREVIOUS_TRACK, - SUPPORT_SELECT_SOURCE, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON) + SUPPORT_SELECT_SOURCE, SUPPORT_STOP, SUPPORT_TURN_OFF, SUPPORT_TURN_ON, + SUPPORT_VOLUME_MUTE, SUPPORT_VOLUME_STEP) from homeassistant.const import ( - ATTR_COMMAND, ATTR_ENTITY_ID, CONF_HOST, CONF_NAME, CONF_PORT, STATE_IDLE, - STATE_OFF, STATE_PAUSED, STATE_PLAYING, STATE_STANDBY) + ATTR_COMMAND, ATTR_ENTITY_ID, CONF_DEVICE_CLASS, CONF_HOST, CONF_NAME, + CONF_PORT, STATE_IDLE, STATE_OFF, STATE_PAUSED, STATE_PLAYING, + STATE_STANDBY) import homeassistant.helpers.config_validation as cv -FIRETV_DOMAIN = 'firetv' +ANDROIDTV_DOMAIN = 'androidtv' -REQUIREMENTS = ['firetv==1.0.9'] +REQUIREMENTS = ['androidtv==0.0.10'] _LOGGER = logging.getLogger(__name__) +SUPPORT_ANDROIDTV = SUPPORT_PAUSE | SUPPORT_PLAY | \ + SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ + SUPPORT_NEXT_TRACK | SUPPORT_STOP | SUPPORT_VOLUME_MUTE | \ + SUPPORT_VOLUME_STEP + SUPPORT_FIRETV = SUPPORT_PAUSE | SUPPORT_PLAY | \ SUPPORT_TURN_ON | SUPPORT_TURN_OFF | SUPPORT_PREVIOUS_TRACK | \ SUPPORT_NEXT_TRACK | SUPPORT_SELECT_SOURCE | SUPPORT_STOP @@ -34,11 +41,15 @@ CONF_ADB_SERVER_PORT = 'adb_server_port' CONF_APPS = 'apps' CONF_GET_SOURCES = 'get_sources' -DEFAULT_NAME = 'Amazon Fire TV' +DEFAULT_NAME = 'Android TV' DEFAULT_PORT = 5555 DEFAULT_ADB_SERVER_PORT = 5037 DEFAULT_GET_SOURCES = True -DEFAULT_APPS = {} +DEFAULT_DEVICE_CLASS = 'auto' + +DEVICE_ANDROIDTV = 'androidtv' +DEVICE_FIRETV = 'firetv' +DEVICE_CLASSES = [DEFAULT_DEVICE_CLASS, DEVICE_ANDROIDTV, DEVICE_FIRETV] SERVICE_ADB_COMMAND = 'adb_command' @@ -58,72 +69,94 @@ def has_adb_files(value): PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_HOST): cv.string, + vol.Optional(CONF_DEVICE_CLASS, default=DEFAULT_DEVICE_CLASS): + vol.In(DEVICE_CLASSES), vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, vol.Optional(CONF_PORT, default=DEFAULT_PORT): cv.port, vol.Optional(CONF_ADBKEY): has_adb_files, vol.Optional(CONF_ADB_SERVER_IP): cv.string, - vol.Optional( - CONF_ADB_SERVER_PORT, default=DEFAULT_ADB_SERVER_PORT): cv.port, + vol.Optional(CONF_ADB_SERVER_PORT, default=DEFAULT_ADB_SERVER_PORT): + cv.port, vol.Optional(CONF_GET_SOURCES, default=DEFAULT_GET_SOURCES): cv.boolean, - vol.Optional( - CONF_APPS, default=DEFAULT_APPS): vol.Schema({cv.string: cv.string}) + vol.Optional(CONF_APPS, default=dict()): + vol.Schema({cv.string: cv.string}) }) -# Translate from `FireTV` reported state to HA state. -FIRETV_STATES = {'off': STATE_OFF, - 'idle': STATE_IDLE, - 'standby': STATE_STANDBY, - 'playing': STATE_PLAYING, - 'paused': STATE_PAUSED} +# Translate from `AndroidTV` / `FireTV` reported state to HA state. +ANDROIDTV_STATES = {'off': STATE_OFF, + 'idle': STATE_IDLE, + 'standby': STATE_STANDBY, + 'playing': STATE_PLAYING, + 'paused': STATE_PAUSED} def setup_platform(hass, config, add_entities, discovery_info=None): - """Set up the FireTV platform.""" - from firetv import FireTV + """Set up the Android TV / Fire TV platform.""" + from androidtv import setup - hass.data.setdefault(FIRETV_DOMAIN, {}) + hass.data.setdefault(ANDROIDTV_DOMAIN, {}) host = '{0}:{1}'.format(config[CONF_HOST], config[CONF_PORT]) if CONF_ADB_SERVER_IP not in config: # Use "python-adb" (Python ADB implementation) if CONF_ADBKEY in config: - ftv = FireTV(host, config[CONF_ADBKEY]) + aftv = setup(host, config[CONF_ADBKEY], + device_class=config[CONF_DEVICE_CLASS]) adb_log = " using adbkey='{0}'".format(config[CONF_ADBKEY]) + else: - ftv = FireTV(host) + aftv = setup(host, device_class=config[CONF_DEVICE_CLASS]) adb_log = "" else: # Use "pure-python-adb" (communicate with ADB server) - ftv = FireTV(host, adb_server_ip=config[CONF_ADB_SERVER_IP], - adb_server_port=config[CONF_ADB_SERVER_PORT]) + aftv = setup(host, adb_server_ip=config[CONF_ADB_SERVER_IP], + adb_server_port=config[CONF_ADB_SERVER_PORT], + device_class=config[CONF_DEVICE_CLASS]) adb_log = " using ADB server at {0}:{1}".format( config[CONF_ADB_SERVER_IP], config[CONF_ADB_SERVER_PORT]) - if not ftv.available: - _LOGGER.warning("Could not connect to Fire TV at %s%s", host, adb_log) + if not aftv.available: + # Determine the name that will be used for the device in the log + if CONF_NAME in config: + device_name = config[CONF_NAME] + elif config[CONF_DEVICE_CLASS] == DEVICE_ANDROIDTV: + device_name = 'Android TV device' + elif config[CONF_DEVICE_CLASS] == DEVICE_FIRETV: + device_name = 'Fire TV device' + else: + device_name = 'Android TV / Fire TV device' + + _LOGGER.warning("Could not connect to %s at %s%s", + device_name, host, adb_log) return - name = config[CONF_NAME] - get_sources = config[CONF_GET_SOURCES] - apps = config[CONF_APPS] - - if host in hass.data[FIRETV_DOMAIN]: + if host in hass.data[ANDROIDTV_DOMAIN]: _LOGGER.warning("Platform already setup on %s, skipping", host) else: - device = FireTVDevice(ftv, name, get_sources, apps) - add_entities([device]) - _LOGGER.debug("Setup Fire TV at %s%s", host, adb_log) - hass.data[FIRETV_DOMAIN][host] = device + if aftv.DEVICE_CLASS == DEVICE_ANDROIDTV: + device = AndroidTVDevice(aftv, config[CONF_NAME], + config[CONF_APPS]) + device_name = config[CONF_NAME] if CONF_NAME in config \ + else 'Android TV' + else: + device = FireTVDevice(aftv, config[CONF_NAME], config[CONF_APPS], + config[CONF_GET_SOURCES]) + device_name = config[CONF_NAME] if CONF_NAME in config \ + else 'Fire TV' - if hass.services.has_service(FIRETV_DOMAIN, SERVICE_ADB_COMMAND): + add_entities([device]) + _LOGGER.debug("Setup %s at %s%s", device_name, host, adb_log) + hass.data[ANDROIDTV_DOMAIN][host] = device + + if hass.services.has_service(ANDROIDTV_DOMAIN, SERVICE_ADB_COMMAND): return def service_adb_command(service): """Dispatch service calls to target entities.""" cmd = service.data.get(ATTR_COMMAND) entity_id = service.data.get(ATTR_ENTITY_ID) - target_devices = [dev for dev in hass.data[FIRETV_DOMAIN].values() + target_devices = [dev for dev in hass.data[ANDROIDTV_DOMAIN].values() if dev.entity_id in entity_id] for target_device in target_devices: @@ -134,7 +167,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.info("Output of command '%s' from '%s': %s", cmd, target_device.entity_id, repr(output)) - hass.services.register(FIRETV_DOMAIN, SERVICE_ADB_COMMAND, + hass.services.register(ANDROIDTV_DOMAIN, SERVICE_ADB_COMMAND, service_adb_command, schema=SERVICE_ADB_COMMAND_SCHEMA) @@ -163,24 +196,21 @@ def adb_decorator(override_available=False): return _adb_decorator -class FireTVDevice(MediaPlayerDevice): - """Representation of an Amazon Fire TV device on the network.""" +class ADBDevice(MediaPlayerDevice): + """Representation of an Android TV or Fire TV device.""" - def __init__(self, ftv, name, get_sources, apps): - """Initialize the FireTV device.""" - from firetv import APPS, KEYS - self.apps = APPS - self.keys = KEYS - - self.apps.update(apps) - - self.firetv = ftv + def __init__(self, aftv, name, apps): + """Initialize the Android TV / Fire TV device.""" + from androidtv.constants import APPS, KEYS + self.aftv = aftv self._name = name - self._get_sources = get_sources + self._apps = APPS + self._apps.update(apps) + self._keys = KEYS # ADB exceptions to catch - if not self.firetv.adb_server_ip: + if not self.aftv.adb_server_ip: # Using "python-adb" (Python ADB implementation) from adb.adb_protocol import (InvalidChecksumError, InvalidCommandError, @@ -195,10 +225,25 @@ class FireTVDevice(MediaPlayerDevice): # Using "pure-python-adb" (communicate with ADB server) self.exceptions = (ConnectionResetError,) - self._state = None - self._available = self.firetv.available + # Property attributes + self._available = self.aftv.available self._current_app = None - self._running_apps = None + self._state = None + + @property + def app_id(self): + """Return the current app.""" + return self._current_app + + @property + def app_name(self): + """Return the friendly name of the current app.""" + return self._apps.get(self._current_app, self._current_app) + + @property + def available(self): + """Return whether or not the ADB connection is valid.""" + return self._available @property def name(self): @@ -210,30 +255,170 @@ class FireTVDevice(MediaPlayerDevice): """Device should be polled.""" return True - @property - def supported_features(self): - """Flag media player features that are supported.""" - return SUPPORT_FIRETV - @property def state(self): """Return the state of the player.""" return self._state - @property - def available(self): - """Return whether or not the ADB connection is valid.""" - return self._available + @adb_decorator() + def media_play(self): + """Send play command.""" + self.aftv.media_play() + + @adb_decorator() + def media_pause(self): + """Send pause command.""" + self.aftv.media_pause() + + @adb_decorator() + def media_play_pause(self): + """Send play/pause command.""" + self.aftv.media_play_pause() + + @adb_decorator() + def turn_on(self): + """Turn on the device.""" + self.aftv.turn_on() + + @adb_decorator() + def turn_off(self): + """Turn off the device.""" + self.aftv.turn_off() + + @adb_decorator() + def media_previous_track(self): + """Send previous track command (results in rewind).""" + self.aftv.media_previous() + + @adb_decorator() + def media_next_track(self): + """Send next track command (results in fast-forward).""" + self.aftv.media_next() + + @adb_decorator() + def adb_command(self, cmd): + """Send an ADB command to an Android TV / Fire TV device.""" + key = self._keys.get(cmd) + if key: + return self.aftv.adb_shell('input keyevent {}'.format(key)) + + if cmd == 'GET_PROPERTIES': + return self.aftv.get_properties_dict() + + return self.aftv.adb_shell(cmd) + + +class AndroidTVDevice(ADBDevice): + """Representation of an Android TV device.""" + + def __init__(self, aftv, name, apps): + """Initialize the Android TV device.""" + super().__init__(aftv, name, apps) + + self._device = None + self._muted = None + self._device_properties = self.aftv.device_properties + self._unique_id = 'androidtv-{}-{}'.format( + name, self._device_properties['serialno']) + self._volume = None + + @adb_decorator(override_available=True) + def update(self): + """Update the device state and, if necessary, re-connect.""" + # Check if device is disconnected. + if not self._available: + # Try to connect + self._available = self.aftv.connect(always_log_errors=False) + + # To be safe, wait until the next update to run ADB commands. + return + + # If the ADB connection is not intact, don't update. + if not self._available: + return + + # Get the `state`, `current_app`, and `running_apps`. + state, self._current_app, self._device, self._muted, self._volume = \ + self.aftv.update() + + self._state = ANDROIDTV_STATES[state] @property - def app_id(self): - """Return the current app.""" - return self._current_app + def is_volume_muted(self): + """Boolean if volume is currently muted.""" + return self._muted @property - def app_name(self): - """Return the friendly name of the current app.""" - return self.apps.get(self._current_app, self._current_app) + def source(self): + """Return the current playback device.""" + return self._device + + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_ANDROIDTV + + @property + def unique_id(self): + """Return the device unique id.""" + return self._unique_id + + @property + def volume_level(self): + """Return the volume level.""" + return self._volume + + @adb_decorator() + def media_stop(self): + """Send stop command.""" + self.aftv.media_stop() + + @adb_decorator() + def mute_volume(self, mute): + """Mute the volume.""" + self.aftv.mute_volume() + + @adb_decorator() + def volume_down(self): + """Send volume down command.""" + self.aftv.volume_down() + + @adb_decorator() + def volume_up(self): + """Send volume up command.""" + self.aftv.volume_up() + + +class FireTVDevice(ADBDevice): + """Representation of a Fire TV device.""" + + def __init__(self, aftv, name, apps, get_sources): + """Initialize the Fire TV device.""" + super().__init__(aftv, name, apps) + + self._get_sources = get_sources + self._running_apps = None + + @adb_decorator(override_available=True) + def update(self): + """Update the device state and, if necessary, re-connect.""" + # Check if device is disconnected. + if not self._available: + # Try to connect + self._available = self.aftv.connect(always_log_errors=False) + + # To be safe, wait until the next update to run ADB commands. + return + + # If the ADB connection is not intact, don't update. + if not self._available: + return + + # Get the `state`, `current_app`, and `running_apps`. + state, self._current_app, self._running_apps = \ + self.aftv.update(self._get_sources) + + self._state = ANDROIDTV_STATES[state] @property def source(self): @@ -245,76 +430,15 @@ class FireTVDevice(MediaPlayerDevice): """Return a list of running apps.""" return self._running_apps - @adb_decorator(override_available=True) - def update(self): - """Update the device state and, if necessary, re-connect.""" - # Check if device is disconnected. - if not self._available: - # Try to connect - self._available = self.firetv.connect() - - # To be safe, wait until the next update to run ADB commands. - return - - # If the ADB connection is not intact, don't update. - if not self._available: - return - - # Get the `state`, `current_app`, and `running_apps`. - ftv_state, self._current_app, self._running_apps = \ - self.firetv.update(self._get_sources) - - self._state = FIRETV_STATES[ftv_state] - - @adb_decorator() - def turn_on(self): - """Turn on the device.""" - self.firetv.turn_on() - - @adb_decorator() - def turn_off(self): - """Turn off the device.""" - self.firetv.turn_off() - - @adb_decorator() - def media_play(self): - """Send play command.""" - self.firetv.media_play() - - @adb_decorator() - def media_pause(self): - """Send pause command.""" - self.firetv.media_pause() - - @adb_decorator() - def media_play_pause(self): - """Send play/pause command.""" - self.firetv.media_play_pause() + @property + def supported_features(self): + """Flag media player features that are supported.""" + return SUPPORT_FIRETV @adb_decorator() def media_stop(self): """Send stop (back) command.""" - self.firetv.back() - - @adb_decorator() - def volume_up(self): - """Send volume up command.""" - self.firetv.volume_up() - - @adb_decorator() - def volume_down(self): - """Send volume down command.""" - self.firetv.volume_down() - - @adb_decorator() - def media_previous_track(self): - """Send previous track command (results in rewind).""" - self.firetv.media_previous() - - @adb_decorator() - def media_next_track(self): - """Send next track command (results in fast-forward).""" - self.firetv.media_next() + self.aftv.back() @adb_decorator() def select_source(self, source): @@ -325,14 +449,6 @@ class FireTVDevice(MediaPlayerDevice): """ if isinstance(source, str): if not source.startswith('!'): - self.firetv.launch_app(source) + self.aftv.launch_app(source) else: - self.firetv.stop_app(source[1:].lstrip()) - - @adb_decorator() - def adb_command(self, cmd): - """Send an ADB command to a Fire TV device.""" - key = self.keys.get(cmd) - if key: - return self.firetv.adb_shell('input keyevent {}'.format(key)) - return self.firetv.adb_shell(cmd) + self.aftv.stop_app(source[1:].lstrip()) diff --git a/homeassistant/components/androidtv/services.yaml b/homeassistant/components/androidtv/services.yaml new file mode 100644 index 00000000000..78ff0a828f6 --- /dev/null +++ b/homeassistant/components/androidtv/services.yaml @@ -0,0 +1,11 @@ +# Describes the format for available Android TV and Fire TV services + +adb_command: + description: Send an ADB command to an Android TV / Fire TV device. + fields: + entity_id: + description: Name(s) of Android TV / Fire TV entities. + example: 'media_player.android_tv_living_room' + command: + description: Either a key command or an ADB shell command. + example: 'HOME' diff --git a/homeassistant/components/firetv/__init__.py b/homeassistant/components/firetv/__init__.py deleted file mode 100644 index 68f55631332..00000000000 --- a/homeassistant/components/firetv/__init__.py +++ /dev/null @@ -1,6 +0,0 @@ -""" -Support for functionality to interact with FireTV devices. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/media_player.firetv/ -""" diff --git a/homeassistant/components/firetv/services.yaml b/homeassistant/components/firetv/services.yaml deleted file mode 100644 index 78019547641..00000000000 --- a/homeassistant/components/firetv/services.yaml +++ /dev/null @@ -1,11 +0,0 @@ -# Describes the format for available Fire TV services - -adb_command: - description: Send an ADB command to a Fire TV device. - fields: - entity_id: - description: Name(s) of Fire TV entities. - example: 'media_player.fire_tv_living_room' - command: - description: Either a key command or an ADB shell command. - example: 'HOME' diff --git a/requirements_all.txt b/requirements_all.txt index 1af8f373c6f..dc6b2e17d94 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -157,6 +157,9 @@ alpha_vantage==2.1.0 # homeassistant.components.amcrest amcrest==1.2.5 +# homeassistant.components.androidtv.media_player +androidtv==0.0.10 + # homeassistant.components.switch.anel_pwrctrl anel_pwrctrl-homeassistant==0.0.1.dev2 @@ -435,9 +438,6 @@ fiblary3==0.1.7 # homeassistant.components.sensor.fints fints==1.0.1 -# homeassistant.components.firetv.media_player -firetv==1.0.9 - # homeassistant.components.sensor.fitbit fitbit==0.3.0 From 18daee9af67f6d70103322cf9317031dd2dd6431 Mon Sep 17 00:00:00 2001 From: endor <1937941+endor-force@users.noreply.github.com> Date: Wed, 13 Mar 2019 12:20:15 +0100 Subject: [PATCH 226/291] Tellstick sensor configuration cleanup (#21402) * Cleaned up named sensor handling for future Breaking change for tellstick sensor configuration. * Fixed linting of long lines and closing bracket Linting warning for long line and not using best practice for visual indentation of closing bracket. Who's a good boy.. * Whitespace on line was not cleaned. * Removed spaces to clean up * More.. whitespace. Sloppy. * Constants from const, altered loops, added dictionary for sensor names. * Fixed whitespace * Inverted condition and created guard clause * Changed condition from not ... in to not in. * Fixed bad indentation on L91 and 92 --- homeassistant/components/tellstick/sensor.py | 35 ++++++++++++-------- 1 file changed, 22 insertions(+), 13 deletions(-) diff --git a/homeassistant/components/tellstick/sensor.py b/homeassistant/components/tellstick/sensor.py index c6d281772a5..0438ad79abc 100644 --- a/homeassistant/components/tellstick/sensor.py +++ b/homeassistant/components/tellstick/sensor.py @@ -5,7 +5,7 @@ from collections import namedtuple import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import TEMP_CELSIUS +from homeassistant.const import TEMP_CELSIUS, CONF_ID, CONF_NAME from homeassistant.helpers.entity import Entity import homeassistant.helpers.config_validation as cv @@ -20,15 +20,18 @@ CONF_ONLY_NAMED = 'only_named' CONF_TEMPERATURE_SCALE = 'temperature_scale' DEFAULT_DATATYPE_MASK = 127 -DEFAULT_ONLY_NAMED = False DEFAULT_TEMPERATURE_SCALE = TEMP_CELSIUS PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_ONLY_NAMED, default=DEFAULT_ONLY_NAMED): cv.boolean, vol.Optional(CONF_TEMPERATURE_SCALE, default=DEFAULT_TEMPERATURE_SCALE): cv.string, vol.Optional(CONF_DATATYPE_MASK, default=DEFAULT_DATATYPE_MASK): cv.positive_int, + vol.Optional(CONF_ONLY_NAMED, default=[]): + vol.All(cv.ensure_list, [vol.Schema({ + vol.Required(CONF_ID): cv.positive_int, + vol.Required(CONF_NAME): cv.string, + })]) }) @@ -69,20 +72,26 @@ def setup_platform(hass, config, add_entities, discovery_info=None): sensors = [] datatype_mask = config.get(CONF_DATATYPE_MASK) + if config[CONF_ONLY_NAMED]: + named_sensors = { + named_sensor[CONF_ID]: named_sensor[CONF_NAME] + for named_sensor in config[CONF_ONLY_NAMED]} + for tellcore_sensor in tellcore_lib.sensors(): - try: - sensor_name = config[tellcore_sensor.id] - except KeyError: - if config.get(CONF_ONLY_NAMED): - continue + if not config[CONF_ONLY_NAMED]: sensor_name = str(tellcore_sensor.id) + else: + if tellcore_sensor.id not in named_sensors: + continue + sensor_name = named_sensors[tellcore_sensor.id] for datatype in sensor_value_descriptions: - if datatype & datatype_mask: - if tellcore_sensor.has_value(datatype): - sensor_info = sensor_value_descriptions[datatype] - sensors.append(TellstickSensor( - sensor_name, tellcore_sensor, datatype, sensor_info)) + if datatype & datatype_mask and \ + tellcore_sensor.has_value(datatype): + sensor_info = sensor_value_descriptions[datatype] + sensors.append(TellstickSensor( + sensor_name, tellcore_sensor, + datatype, sensor_info)) add_entities(sensors) From 897862fca4f4152f7baf75aee5172808c6137143 Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 13 Mar 2019 08:19:26 -0600 Subject: [PATCH 227/291] Add availability and next run datetime to RainMachine switches (#21786) --- .../components/rainmachine/__init__.py | 2 +- .../components/rainmachine/switch.py | 41 ++++++++++--------- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 25 insertions(+), 22 deletions(-) diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 6da5b2d6c10..78083e0d9da 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -19,7 +19,7 @@ from .config_flow import configured_instances from .const import ( DATA_CLIENT, DEFAULT_PORT, DEFAULT_SCAN_INTERVAL, DEFAULT_SSL, DOMAIN) -REQUIREMENTS = ['regenmaschine==1.2.0'] +REQUIREMENTS = ['regenmaschine==1.4.0'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/rainmachine/switch.py b/homeassistant/components/rainmachine/switch.py index b48cc0a1e14..e3a1ddab912 100644 --- a/homeassistant/components/rainmachine/switch.py +++ b/homeassistant/components/rainmachine/switch.py @@ -5,6 +5,7 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/switch.rainmachine/ """ import logging +from datetime import datetime from homeassistant.components.rainmachine import ( DATA_CLIENT, DOMAIN as RAINMACHINE_DOMAIN, PROGRAM_UPDATE_TOPIC, @@ -19,6 +20,7 @@ DEPENDENCIES = ['rainmachine'] _LOGGER = logging.getLogger(__name__) +ATTR_NEXT_RUN = 'next_run' ATTR_AREA = 'area' ATTR_CS_ON = 'cs_on' ATTR_CURRENT_CYCLE = 'current_cycle' @@ -111,20 +113,12 @@ async def async_setup_entry(hass, entry, async_add_entities): entities = [] - programs = await rainmachine.client.programs.all() + programs = await rainmachine.client.programs.all(include_inactive=True) for program in programs: - if not program.get('active'): - continue - - _LOGGER.debug('Adding program: %s', program) entities.append(RainMachineProgram(rainmachine, program)) - zones = await rainmachine.client.zones.all() + zones = await rainmachine.client.zones.all(include_inactive=True) for zone in zones: - if not zone.get('active'): - continue - - _LOGGER.debug('Adding zone: %s', zone) entities.append( RainMachineZone( rainmachine, zone, rainmachine.default_zone_runtime)) @@ -144,16 +138,16 @@ class RainMachineSwitch(RainMachineEntity, SwitchDevice): self._rainmachine_entity_id = obj['uid'] self._switch_type = switch_type + @property + def available(self) -> bool: + """Return True if entity is available.""" + return bool(self._obj.get('active')) + @property def icon(self) -> str: """Return the icon.""" return 'mdi:water' - @property - def is_enabled(self) -> bool: - """Return whether the entity is enabled.""" - return self._obj.get('active') - @property def unique_id(self) -> str: """Return a unique, HASS-friendly identifier for this entity.""" @@ -222,8 +216,17 @@ class RainMachineProgram(RainMachineSwitch): self._obj = await self.rainmachine.client.programs.get( self._rainmachine_entity_id) + try: + next_run = datetime.strptime( + '{0} {1}'.format( + self._obj['nextRun'], self._obj['startTime']), + '%Y-%m-%d %H:%M').isoformat() + except ValueError: + next_run = None + self._attrs.update({ ATTR_ID: self._obj['uid'], + ATTR_NEXT_RUN: next_run, 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) @@ -297,13 +300,13 @@ class RainMachineZone(RainMachineSwitch): ATTR_CURRENT_CYCLE: self._obj.get('cycle'), ATTR_FIELD_CAPACITY: - self._properties_json.get('waterSense') - .get('fieldCapacity'), + self._properties_json.get('waterSense').get( + 'fieldCapacity'), ATTR_NO_CYCLES: self._obj.get('noOfCycles'), ATTR_PRECIP_RATE: - self._properties_json.get('waterSense') - .get('precipitationRate'), + self._properties_json.get('waterSense').get( + 'precipitationRate'), ATTR_RESTRICTIONS: self._obj.get('restriction'), ATTR_SLOPE: diff --git a/requirements_all.txt b/requirements_all.txt index dc6b2e17d94..8c889923859 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1505,7 +1505,7 @@ raspyrfm-client==1.2.8 recollect-waste==1.0.1 # homeassistant.components.rainmachine -regenmaschine==1.2.0 +regenmaschine==1.4.0 # homeassistant.components.python_script restrictedpython==4.0b8 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cf104aff669..b4fb923d995 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -261,7 +261,7 @@ pyunifi==2.16 pywebpush==1.6.0 # homeassistant.components.rainmachine -regenmaschine==1.2.0 +regenmaschine==1.4.0 # homeassistant.components.python_script restrictedpython==4.0b8 From a71394a0ce4f4ce8dc692289ef8ee7365fade86a Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 13 Mar 2019 08:20:13 -0600 Subject: [PATCH 228/291] Add program/zone enable/disable services to RainMachine (#21785) --- .../components/rainmachine/__init__.py | 33 +++++++++++++++++++ .../components/rainmachine/services.yaml | 24 ++++++++++++++ 2 files changed, 57 insertions(+) diff --git a/homeassistant/components/rainmachine/__init__.py b/homeassistant/components/rainmachine/__init__.py index 78083e0d9da..6d986fa5c67 100644 --- a/homeassistant/components/rainmachine/__init__.py +++ b/homeassistant/components/rainmachine/__init__.py @@ -74,6 +74,14 @@ SENSOR_SCHEMA = vol.Schema({ vol.All(cv.ensure_list, [vol.In(SENSORS)]) }) +SERVICE_ALTER_PROGRAM = vol.Schema({ + vol.Required(CONF_PROGRAM_ID): cv.positive_int, +}) + +SERVICE_ALTER_ZONE = vol.Schema({ + vol.Required(CONF_ZONE_ID): cv.positive_int, +}) + SERVICE_PAUSE_WATERING = vol.Schema({ vol.Required(CONF_SECONDS): cv.positive_int, }) @@ -189,6 +197,27 @@ async def async_setup_entry(hass, config_entry): refresh, timedelta(seconds=config_entry.data[CONF_SCAN_INTERVAL])) + async def disable_program(service): + """Disable a program.""" + await rainmachine.client.programs.disable( + service.data[CONF_PROGRAM_ID]) + async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) + + async def disable_zone(service): + """Disable a zone.""" + await rainmachine.client.zones.disable(service.data[CONF_ZONE_ID]) + async_dispatcher_send(hass, ZONE_UPDATE_TOPIC) + + async def enable_program(service): + """Enable a program.""" + await rainmachine.client.programs.enable(service.data[CONF_PROGRAM_ID]) + async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) + + async def enable_zone(service): + """Enable a zone.""" + await rainmachine.client.zones.enable(service.data[CONF_ZONE_ID]) + async_dispatcher_send(hass, ZONE_UPDATE_TOPIC) + async def pause_watering(service): """Pause watering for a set number of seconds.""" await rainmachine.client.watering.pause_all(service.data[CONF_SECONDS]) @@ -226,6 +255,10 @@ async def async_setup_entry(hass, config_entry): async_dispatcher_send(hass, PROGRAM_UPDATE_TOPIC) for service, method, schema in [ + ('disable_program', disable_program, SERVICE_ALTER_PROGRAM), + ('disable_zone', disable_zone, SERVICE_ALTER_ZONE), + ('enable_program', enable_program, SERVICE_ALTER_PROGRAM), + ('enable_zone', enable_zone, SERVICE_ALTER_ZONE), ('pause_watering', pause_watering, SERVICE_PAUSE_WATERING), ('start_program', start_program, SERVICE_START_PROGRAM_SCHEMA), ('start_zone', start_zone, SERVICE_START_ZONE_SCHEMA), diff --git a/homeassistant/components/rainmachine/services.yaml b/homeassistant/components/rainmachine/services.yaml index a165c14d0e6..288161968de 100644 --- a/homeassistant/components/rainmachine/services.yaml +++ b/homeassistant/components/rainmachine/services.yaml @@ -1,6 +1,30 @@ # Describes the format for available RainMachine services --- +disable_program: + description: Disable a program. + fields: + program_id: + description: The program to disable. + example: 3 +disable_zone: + description: Disable a zone. + fields: + zone_id: + description: The zone to disable. + example: 3 +enable_program: + description: Enable a program. + fields: + program_id: + description: The program to enable. + example: 3 +enable_zone: + description: Enable a zone. + fields: + zone_id: + description: The zone to enable. + example: 3 pause_watering: description: Pause all watering for a number of seconds. fields: From 186b48e2eb610a929c2c471c1c289cce0f072cde Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Wed, 13 Mar 2019 16:45:26 +0100 Subject: [PATCH 229/291] Bump NabuCasa library to 0.5 (#22010) --- homeassistant/components/cloud/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index cc6eeeb8192..c854fe69be9 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -23,7 +23,7 @@ from .const import ( CONF_USER_POOL_ID, DOMAIN, MODE_DEV, MODE_PROD) from .prefs import CloudPreferences -REQUIREMENTS = ['hass-nabucasa==0.4'] +REQUIREMENTS = ['hass-nabucasa==0.5'] DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 8c889923859..3d2cc183564 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -524,7 +524,7 @@ habitipy==0.2.0 hangups==0.4.6 # homeassistant.components.cloud -hass-nabucasa==0.4 +hass-nabucasa==0.5 # homeassistant.components.mqtt.server hbmqtt==0.9.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b4fb923d995..ae2b713f0df 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -114,7 +114,7 @@ ha-ffmpeg==1.11 hangups==0.4.6 # homeassistant.components.cloud -hass-nabucasa==0.4 +hass-nabucasa==0.5 # homeassistant.components.mqtt.server hbmqtt==0.9.4 From fe5e4b5b9b984156e38fdb4cf801d385486223c8 Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Wed, 13 Mar 2019 20:51:08 +0100 Subject: [PATCH 230/291] Avoid playing queue pollution with Sonos unjoin (#22004) --- homeassistant/components/sonos/media_player.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index c07057bfb63..3b3e5e14ec7 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -1012,7 +1012,11 @@ class SonosEntity(MediaPlayerDevice): """Unjoin several players from their group.""" def _unjoin_all(entities): """Sync helper.""" - for entity in entities: + # Unjoin slaves first to prevent inheritance of queues + coordinators = [e for e in entities if e.is_coordinator] + slaves = [e for e in entities if not e.is_coordinator] + + for entity in slaves + coordinators: entity.unjoin() async with hass.data[DATA_SONOS].topology_lock: @@ -1079,7 +1083,7 @@ class SonosEntity(MediaPlayerDevice): entity.media_pause() if with_group: - # Unjoin slaves that are not already in their target group + # Unjoin slaves first to prevent inheritance of queues for entity in [e for e in entities if not e.is_coordinator]: if entity._snapshot_group != entity._sonos_group: entity.unjoin() From de2c7a9567bdd4bbe9b1c3cd0bd9f3b163582c2c Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Wed, 13 Mar 2019 20:51:41 +0100 Subject: [PATCH 231/291] Wait for Sonos regrouping in service calls (#22006) --- .../components/sonos/media_player.py | 91 ++++++++++++++----- 1 file changed, 66 insertions(+), 25 deletions(-) diff --git a/homeassistant/components/sonos/media_player.py b/homeassistant/components/sonos/media_player.py index 3b3e5e14ec7..684e25ba599 100644 --- a/homeassistant/components/sonos/media_player.py +++ b/homeassistant/components/sonos/media_player.py @@ -6,6 +6,7 @@ import socket import asyncio import urllib +import async_timeout import requests import voluptuous as vol @@ -115,7 +116,7 @@ class SonosData: """Initialize the data.""" self.uids = set() self.entities = [] - self.topology_lock = asyncio.Lock(loop=hass.loop) + self.topology_condition = asyncio.Condition(loop=hass.loop) def setup_platform(hass, config, add_entities, discovery_info=None): @@ -364,7 +365,6 @@ class SonosEntity(MediaPlayerDevice): self._favorites = None self._soco_snapshot = None self._snapshot_group = None - self._restore_pending = False self._set_basic_information() @@ -752,15 +752,14 @@ class SonosEntity(MediaPlayerDevice): async def _async_handle_group_event(event): """Get async lock and handle event.""" - async with self.hass.data[DATA_SONOS].topology_lock: + async with self.hass.data[DATA_SONOS].topology_condition: group = await _async_extract_group(event) if self.unique_id == group[0]: - if self._restore_pending: - await self.hass.async_add_executor_job(self.restore) - _async_regroup(group) + self.hass.data[DATA_SONOS].topology_condition.notify_all() + if event: self._receives_events = True @@ -988,18 +987,26 @@ class SonosEntity(MediaPlayerDevice): """Form a group with other players.""" if self._coordinator: self.unjoin() + group = [self] + else: + group = self._sonos_group.copy() for slave in slaves: if slave.unique_id != self.unique_id: slave.soco.join(self.soco) # pylint: disable=protected-access slave._coordinator = self + if slave not in group: + group.append(slave) + + return group @staticmethod async def join_multi(hass, master, entities): """Form a group with other players.""" - async with hass.data[DATA_SONOS].topology_lock: - await hass.async_add_executor_job(master.join, entities) + async with hass.data[DATA_SONOS].topology_condition: + group = await hass.async_add_executor_job(master.join, entities) + await SonosEntity.wait_for_groups(hass, [group]) @soco_error() def unjoin(self): @@ -1019,8 +1026,9 @@ class SonosEntity(MediaPlayerDevice): for entity in slaves + coordinators: entity.unjoin() - async with hass.data[DATA_SONOS].topology_lock: + async with hass.data[DATA_SONOS].topology_condition: await hass.async_add_executor_job(_unjoin_all, entities) + await SonosEntity.wait_for_groups(hass, [[e] for e in entities]) @soco_error() def snapshot(self, with_group): @@ -1050,7 +1058,7 @@ class SonosEntity(MediaPlayerDevice): for entity in list(entities): entities.update(entity._sonos_group) - async with hass.data[DATA_SONOS].topology_lock: + async with hass.data[DATA_SONOS].topology_condition: await hass.async_add_executor_job(_snapshot_all, entities) @soco_error() @@ -1060,7 +1068,6 @@ class SonosEntity(MediaPlayerDevice): try: # pylint: disable=protected-access - self.soco._zgs_cache.clear() self._soco_snapshot.restore() except (TypeError, AttributeError, SoCoException) as ex: # Can happen if restoring a coordinator onto a current slave @@ -1068,20 +1075,20 @@ class SonosEntity(MediaPlayerDevice): self._soco_snapshot = None self._snapshot_group = None - self._restore_pending = False @staticmethod async def restore_multi(hass, entities, with_group): """Restore snapshots for all the entities.""" # pylint: disable=protected-access - def _restore_all(entities): - """Sync helper.""" - # Pause all current coordinators + def _restore_groups(entities, with_group): + """Pause all current coordinators and restore groups.""" for entity in (e for e in entities if e.is_coordinator): if entity.state == STATE_PLAYING: entity.media_pause() + groups = [] + if with_group: # Unjoin slaves first to prevent inheritance of queues for entity in [e for e in entities if not e.is_coordinator]: @@ -1092,19 +1099,17 @@ class SonosEntity(MediaPlayerDevice): for entity in (e for e in entities if e._snapshot_group): if entity._snapshot_group[0] == entity: entity.join(entity._snapshot_group) + groups.append(entity._snapshot_group.copy()) - # Restore slaves + return groups + + def _restore_players(entities): + """Restore state of all players.""" for entity in (e for e in entities if not e.is_coordinator): entity.restore() - # Restore coordinators (or delay if moving from slave) for entity in (e for e in entities if e.is_coordinator): - if entity._sonos_group[0] == entity: - # Was already coordinator - entity.restore() - else: - # Await coordinator role - entity._restore_pending = True + entity.restore() # Find all affected players entities = set(e for e in entities if e._soco_snapshot) @@ -1112,8 +1117,44 @@ class SonosEntity(MediaPlayerDevice): for entity in [e for e in entities if e._snapshot_group]: entities.update(entity._snapshot_group) - async with hass.data[DATA_SONOS].topology_lock: - await hass.async_add_executor_job(_restore_all, entities) + async with hass.data[DATA_SONOS].topology_condition: + groups = await hass.async_add_executor_job( + _restore_groups, entities, with_group) + + await SonosEntity.wait_for_groups(hass, groups) + + await hass.async_add_executor_job(_restore_players, entities) + + @staticmethod + async def wait_for_groups(hass, groups): + """Wait until all groups are present, or timeout.""" + # pylint: disable=protected-access + + def _test_groups(groups): + """Return whether all groups exist now.""" + for group in groups: + coordinator = group[0] + + # Test that coordinator is coordinating + current_group = coordinator._sonos_group + if coordinator != current_group[0]: + return False + + # Test that slaves match + if set(group[1:]) != set(current_group[1:]): + return False + + return True + + try: + with async_timeout.timeout(5): + while not _test_groups(groups): + await hass.data[DATA_SONOS].topology_condition.wait() + except asyncio.TimeoutError: + _LOGGER.warning("Timeout waiting for target groups %s", groups) + + for entity in hass.data[DATA_SONOS].entities: + entity.soco._zgs_cache.clear() @soco_error() @soco_coordinator From eed1168fa18b314bc50497101c5ba0fc2cfa39f3 Mon Sep 17 00:00:00 2001 From: beavis9k Date: Wed, 13 Mar 2019 13:52:45 -0600 Subject: [PATCH 232/291] fix error in LutronButton init if Button doesn't have a type (#21921) --- homeassistant/components/lutron/__init__.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/lutron/__init__.py b/homeassistant/components/lutron/__init__.py index fae44d3584d..f642e96d8f6 100644 --- a/homeassistant/components/lutron/__init__.py +++ b/homeassistant/components/lutron/__init__.py @@ -124,7 +124,8 @@ class LutronButton: """Register callback for activity on the button.""" name = '{}: {}'.format(keypad.name, button.name) self._hass = hass - self._has_release_event = 'RaiseLower' in button.button_type + self._has_release_event = (button.button_type is not None and + 'RaiseLower' in button.button_type) self._id = slugify(name) self._event = 'lutron_event' From deb66bb748c80594392481e8cfcc836f3d2b5fb0 Mon Sep 17 00:00:00 2001 From: Jc2k Date: Wed, 13 Mar 2019 19:53:33 +0000 Subject: [PATCH 233/291] HomeKit controller light - remove code that can never execute (#21951) --- .../components/homekit_controller/light.py | 22 +++++++------------ 1 file changed, 8 insertions(+), 14 deletions(-) diff --git a/homeassistant/components/homekit_controller/light.py b/homeassistant/components/homekit_controller/light.py index 193d0326b75..b5677c0e095 100644 --- a/homeassistant/components/homekit_controller/light.py +++ b/homeassistant/components/homekit_controller/light.py @@ -25,11 +25,11 @@ class HomeKitLight(HomeKitEntity, Light): def __init__(self, *args): """Initialise the light.""" super().__init__(*args) - self._on = None - self._brightness = None - self._color_temperature = None - self._hue = None - self._saturation = None + self._on = False + self._brightness = 0 + self._color_temperature = 0 + self._hue = 0 + self._saturation = 0 def get_characteristic_types(self): """Define the homekit characteristics the entity cares about.""" @@ -78,23 +78,17 @@ class HomeKitLight(HomeKitEntity, Light): @property def brightness(self): """Return the brightness of this light between 0..255.""" - if self._features & SUPPORT_BRIGHTNESS: - return self._brightness * 255 / 100 - return None + return self._brightness * 255 / 100 @property def hs_color(self): """Return the color property.""" - if self._features & SUPPORT_COLOR: - return (self._hue, self._saturation) - return None + return (self._hue, self._saturation) @property def color_temp(self): """Return the color temperature.""" - if self._features & SUPPORT_COLOR_TEMP: - return self._color_temperature - return None + return self._color_temperature @property def supported_features(self): From cac8e3484144ff3293f32681b53c4496cad1924b Mon Sep 17 00:00:00 2001 From: Marco Orovecchia Date: Wed, 13 Mar 2019 20:54:15 +0100 Subject: [PATCH 234/291] Nanoleaf availability check (#21945) * Added availability check for nanoleaf lights * pylint errors fixed * pynanoleaf bump --- homeassistant/components/light/nanoleaf.py | 34 +++++++++++++++------- requirements_all.txt | 2 +- 2 files changed, 25 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/light/nanoleaf.py b/homeassistant/components/light/nanoleaf.py index 571f4efc4ad..bd34c535b70 100644 --- a/homeassistant/components/light/nanoleaf.py +++ b/homeassistant/components/light/nanoleaf.py @@ -19,7 +19,7 @@ from homeassistant.util.color import \ color_temperature_mired_to_kelvin as mired_to_kelvin from homeassistant.util.json import load_json, save_json -REQUIREMENTS = ['pynanoleaf==0.0.2'] +REQUIREMENTS = ['pynanoleaf==0.0.5'] _LOGGER = logging.getLogger(__name__) @@ -43,7 +43,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_entities, discovery_info=None): """Set up the Nanoleaf light.""" - import pynanoleaf + from pynanoleaf import Nanoleaf, Unavailable if DATA_NANOLEAF not in hass.data: hass.data[DATA_NANOLEAF] = dict() @@ -63,7 +63,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): name = config[CONF_NAME] token = config[CONF_TOKEN] - nanoleaf_light = pynanoleaf.Nanoleaf(host) + nanoleaf_light = Nanoleaf(host) if not token: token = nanoleaf_light.request_token() @@ -78,7 +78,9 @@ def setup_platform(hass, config, add_entities, discovery_info=None): nanoleaf_light.token = token - if nanoleaf_light.on is None: + try: + nanoleaf_light.available + except Unavailable: _LOGGER.error( "Could not connect to Nanoleaf Light: %s on %s", name, host) return @@ -92,6 +94,7 @@ class NanoleafLight(Light): def __init__(self, light, name): """Initialize an Nanoleaf light.""" + self._available = True self._brightness = None self._color_temp = None self._effect = None @@ -101,6 +104,11 @@ class NanoleafLight(Light): self._hs_color = None self._state = None + @property + def available(self): + """Return availability.""" + return self._available + @property def brightness(self): """Return the brightness of the light.""" @@ -187,9 +195,15 @@ class NanoleafLight(Light): def update(self): """Fetch new state data for this light.""" - self._brightness = self._light.brightness - self._color_temp = self._light.color_temperature - self._effect = self._light.effect - self._effects_list = self._light.effects - self._hs_color = self._light.hue, self._light.saturation - self._state = self._light.on + try: + self._available = self._light.available + self._brightness = self._light.brightness + self._color_temp = self._light.color_temperature + self._effect = self._light.effect + self._effects_list = self._light.effects + self._hs_color = self._light.hue, self._light.saturation + self._state = self._light.on + except Exception as err: # pylint:disable=broad-except + _LOGGER.error("Could not update status for %s (%s)", + self.name, err) + self._available = False diff --git a/requirements_all.txt b/requirements_all.txt index 3d2cc183564..584330b833b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1174,7 +1174,7 @@ pymyq==1.1.0 pymysensors==0.18.0 # homeassistant.components.light.nanoleaf -pynanoleaf==0.0.2 +pynanoleaf==0.0.5 # homeassistant.components.lock.nello pynello==2.0.2 From d0c8f6de56ffbf17cdb8db713d4080b1ec58f235 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 13 Mar 2019 12:49:05 -0700 Subject: [PATCH 235/291] Updated frontend to 20190313.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index e0bfbbf63c2..70c2bab0829 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -21,7 +21,7 @@ from homeassistant.loader import bind_hass from .storage import async_setup_frontend_storage -REQUIREMENTS = ['home-assistant-frontend==20190312.0'] +REQUIREMENTS = ['home-assistant-frontend==20190313.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 584330b833b..c930723d00b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -554,7 +554,7 @@ hole==0.3.0 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190312.0 +home-assistant-frontend==20190313.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index ae2b713f0df..f7f1b77559a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -126,7 +126,7 @@ hdate==0.8.7 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190312.0 +home-assistant-frontend==20190313.0 # homeassistant.components.homekit_controller homekit[IP]==0.13.0 From 50ec3d7de5b3f30618cb97339005f796be0c5205 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 13 Mar 2019 12:49:42 -0700 Subject: [PATCH 236/291] Update translations --- .../homekit_controller/.translations/cy.json | 31 +++++++++++++++++ .../homekit_controller/.translations/es.json | 33 +++++++++++++++++++ .../homekit_controller/.translations/pl.json | 22 +++++++++++++ .../components/ipma/.translations/es.json | 1 + .../components/ps4/.translations/es.json | 5 ++- .../sensor/.translations/season.af.json | 8 +++++ .../sensor/.translations/season.eu.json | 8 +++++ .../components/toon/.translations/pl.json | 14 ++++++-- 8 files changed, 119 insertions(+), 3 deletions(-) create mode 100644 homeassistant/components/homekit_controller/.translations/cy.json create mode 100644 homeassistant/components/homekit_controller/.translations/es.json create mode 100644 homeassistant/components/sensor/.translations/season.af.json create mode 100644 homeassistant/components/sensor/.translations/season.eu.json diff --git a/homeassistant/components/homekit_controller/.translations/cy.json b/homeassistant/components/homekit_controller/.translations/cy.json new file mode 100644 index 00000000000..59e402080f3 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/cy.json @@ -0,0 +1,31 @@ +{ + "config": { + "abort": { + "ignored_model": "Mae cymorth HomeKit ar gyfer y model hwn wedi'i rwystro gan fod integreiddiad cynhenid mwy cyflawn ar gael.", + "invalid_config_entry": "Mae'r ddyfais yn dangos bod eisoes wedi paru ond mae cofnod ffurwedd groes amdano yn Home Assistant sydd angen ei diddymu", + "no_devices": "Ni ellir ddod o hyd i ddyfeisiau heb eu paru" + }, + "error": { + "authentication_error": "Cod HomeKit anghywir. Gwiriwch a cheisiwch eto.", + "unable_to_pair": "Methu paru, pl\u00eds ceisiwch eto", + "unknown_error": "Dyfeis wedi adrodd gwall anhysbys. Methodd paru." + }, + "step": { + "pair": { + "data": { + "pairing_code": "Cod Paru" + }, + "description": "Rhowch eich cod paru HomeKit i ddefnyddio'r ategolyn hwn", + "title": "Paru gyda ategolyn HomeKit" + }, + "user": { + "data": { + "device": "Dyfais" + }, + "description": "Dewiswch y ddyfais rydych eisiau paru efo", + "title": "Paru gyda ategolyn HomeKit" + } + }, + "title": "Ategolyn HomeKit" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/es.json b/homeassistant/components/homekit_controller/.translations/es.json new file mode 100644 index 00000000000..1b1edbd5146 --- /dev/null +++ b/homeassistant/components/homekit_controller/.translations/es.json @@ -0,0 +1,33 @@ +{ + "config": { + "abort": { + "already_configured": "El accesorio ya est\u00e1 configurado con este controlador.", + "already_paired": "Este accesorio ya est\u00e1 emparejado con otro dispositivo. Por favor, reinicia el accesorio e int\u00e9ntalo de nuevo.", + "ignored_model": "El soporte de HomeKit para este modelo est\u00e1 bloqueado ya que est\u00e1 disponible una integraci\u00f3n nativa m\u00e1s completa.", + "invalid_config_entry": "Este dispositivo se muestra como listo para emparejar pero ya existe una entrada de configuraci\u00f3n conflictiva en Home Assistant que debe ser eliminada primero.", + "no_devices": "No se encontraron dispositivos no emparejados" + }, + "error": { + "authentication_error": "C\u00f3digo HomeKit incorrecto. Por favor, compru\u00e9belo e int\u00e9ntelo de nuevo.", + "unable_to_pair": "No se ha podido emparejar, por favor int\u00e9ntelo de nuevo.", + "unknown_error": "El dispositivo report\u00f3 un error desconocido. El emparejamiento ha fallado." + }, + "step": { + "pair": { + "data": { + "pairing_code": "C\u00f3digo de Emparejamiento" + }, + "description": "Introduce tu c\u00f3digo de emparejamiento HomeKit para usar este accesorio", + "title": "Emparejar con accesorio HomeKit" + }, + "user": { + "data": { + "device": "Dispositivo" + }, + "description": "Selecciona el dispositivo que desea emparejar", + "title": "Emparejar con accesorio HomeKit" + } + }, + "title": "Accesorio HomeKit" + } +} \ No newline at end of file diff --git a/homeassistant/components/homekit_controller/.translations/pl.json b/homeassistant/components/homekit_controller/.translations/pl.json index 62aff22fee7..11efebf250e 100644 --- a/homeassistant/components/homekit_controller/.translations/pl.json +++ b/homeassistant/components/homekit_controller/.translations/pl.json @@ -1,7 +1,29 @@ { "config": { + "abort": { + "already_configured": "Akcesorium jest ju\u017c skonfigurowane z tym kontrolerem.", + "already_paired": "To akcesorium jest ju\u017c sparowane z innym urz\u0105dzeniem. Zresetuj akcesorium i spr\u00f3buj ponownie.", + "ignored_model": "Obs\u0142uga HomeKit dla tego modelu jest zablokowana, poniewa\u017c dost\u0119pna jest pe\u0142niejsza integracja natywna.", + "invalid_config_entry": "To urz\u0105dzenie jest wy\u015bwietlane jako gotowe do sparowania, ale istnieje ju\u017c konfliktowy wpis konfiguracyjny dla niego w Home Assistant, kt\u00f3ry musi zosta\u0107 najpierw usuni\u0119ty.", + "no_devices": "Nie znaleziono niesparowanych urz\u0105dze\u0144" + }, + "error": { + "authentication_error": "Niepoprawny kod parowania HomeKit. Sprawd\u017a go i spr\u00f3buj ponownie.", + "unable_to_pair": "Nie mo\u017cna sparowa\u0107, spr\u00f3buj ponownie.", + "unknown_error": "Urz\u0105dzenie zg\u0142osi\u0142o nieznany b\u0142\u0105d. Parowanie nie powiod\u0142o si\u0119." + }, "step": { + "pair": { + "data": { + "pairing_code": "Kod parowania" + }, + "description": "Wprowad\u017a kod parowania HomeKit, aby u\u017cy\u0107 tego akcesorium", + "title": "Sparuj z akcesorium HomeKit" + }, "user": { + "data": { + "device": "Urz\u0105dzenie" + }, "description": "Wybierz urz\u0105dzenie, kt\u00f3re chcesz sparowa\u0107", "title": "Sparuj z akcesorium HomeKit" } diff --git a/homeassistant/components/ipma/.translations/es.json b/homeassistant/components/ipma/.translations/es.json index c364ca286e3..acb8b51a44c 100644 --- a/homeassistant/components/ipma/.translations/es.json +++ b/homeassistant/components/ipma/.translations/es.json @@ -10,6 +10,7 @@ "longitude": "Longitud", "name": "Nombre" }, + "description": "Instituto Portugu\u00eas do Mar e Atmosfera", "title": "Ubicaci\u00f3n" } }, diff --git a/homeassistant/components/ps4/.translations/es.json b/homeassistant/components/ps4/.translations/es.json index 41cbd28492a..65798ba4d0c 100644 --- a/homeassistant/components/ps4/.translations/es.json +++ b/homeassistant/components/ps4/.translations/es.json @@ -3,9 +3,12 @@ "abort": { "credential_error": "Error al obtener las credenciales.", "devices_configured": "Todos los dispositivos encontrados ya est\u00e1n configurados.", - "no_devices_found": "No se encuentran dispositivos PlayStation 4 en la red." + "no_devices_found": "No se encuentran dispositivos PlayStation 4 en la red.", + "port_987_bind_error": "No se pudo unir al puerto 987.", + "port_997_bind_error": "No se pudo unir al puerto 997." }, "error": { + "login_failed": "No se ha podido emparejar con PlayStation 4. Verifique que el PIN sea correcto.", "not_ready": "PlayStation 4 no est\u00e1 encendido o conectado a la red." }, "step": { diff --git a/homeassistant/components/sensor/.translations/season.af.json b/homeassistant/components/sensor/.translations/season.af.json new file mode 100644 index 00000000000..0dbe4a131ee --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.af.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "Herfs", + "spring": "Lente", + "summer": "Somer", + "winter": "Winter" + } +} \ No newline at end of file diff --git a/homeassistant/components/sensor/.translations/season.eu.json b/homeassistant/components/sensor/.translations/season.eu.json new file mode 100644 index 00000000000..f226d920043 --- /dev/null +++ b/homeassistant/components/sensor/.translations/season.eu.json @@ -0,0 +1,8 @@ +{ + "state": { + "autumn": "Udazkeneko", + "spring": "Spring", + "summer": "Uda", + "winter": "Winter" + } +} \ No newline at end of file diff --git a/homeassistant/components/toon/.translations/pl.json b/homeassistant/components/toon/.translations/pl.json index 83c9574c2f0..26627389ddd 100644 --- a/homeassistant/components/toon/.translations/pl.json +++ b/homeassistant/components/toon/.translations/pl.json @@ -17,8 +17,18 @@ "password": "Has\u0142o", "tenant": "Najemca", "username": "Nazwa u\u017cytkownika" - } + }, + "description": "Uwierzytelnij swoje konto Eneco Toon (nie konto programisty).", + "title": "Po\u0142\u0105cz swoje konto Toon" + }, + "display": { + "data": { + "display": "Wybierz wy\u015bwietlacz" + }, + "description": "Wybierz wy\u015bwietlacz Toon, z kt\u00f3rym chcesz si\u0119 po\u0142\u0105czy\u0107.", + "title": "Wybierz wy\u015bwietlacz" } - } + }, + "title": "Toon" } } \ No newline at end of file From 5957e4b75b7e629ef22952b1ffd47985c731bb5e Mon Sep 17 00:00:00 2001 From: emontnemery Date: Wed, 13 Mar 2019 20:58:20 +0100 Subject: [PATCH 237/291] Pass Message object to MQTT message callbacks (#21959) * Pass Message object to MQTT message callbacks * Improve method of detecting deprecated msg callback * Fix mysensors * Fixup * Review comments * Fix merge error --- homeassistant/components/mqtt/__init__.py | 99 +++++++++++++------ .../components/mqtt/alarm_control_panel.py | 17 ++-- .../components/mqtt/binary_sensor.py | 3 +- homeassistant/components/mqtt/camera.py | 4 +- homeassistant/components/mqtt/climate.py | 24 +++-- homeassistant/components/mqtt/cover.py | 14 +-- .../components/mqtt/device_tracker.py | 4 +- homeassistant/components/mqtt/discovery.py | 4 +- homeassistant/components/mqtt/fan.py | 12 +-- .../components/mqtt/light/schema_basic.py | 51 +++++----- .../components/mqtt/light/schema_json.py | 4 +- .../components/mqtt/light/schema_template.py | 18 ++-- homeassistant/components/mqtt/lock.py | 3 +- homeassistant/components/mqtt/sensor.py | 3 +- homeassistant/components/mqtt/switch.py | 3 +- homeassistant/components/mqtt/vacuum.py | 26 ++--- homeassistant/components/mysensors/gateway.py | 4 +- tests/components/mqtt/test_init.py | 40 ++++---- tests/components/mqtt/test_subscription.py | 12 +-- 19 files changed, 203 insertions(+), 142 deletions(-) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index ed671a2f8ce..e4d468e2155 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -5,6 +5,8 @@ For more details about this component, please refer to the documentation at https://home-assistant.io/components/mqtt/ """ import asyncio +import inspect +from functools import partial, wraps from itertools import groupby import json import logging @@ -264,7 +266,19 @@ MQTT_PUBLISH_SCHEMA = vol.Schema({ # pylint: disable=invalid-name PublishPayloadType = Union[str, bytes, int, float, None] SubscribePayloadType = Union[str, bytes] # Only bytes if encoding is None -MessageCallbackType = Callable[[str, SubscribePayloadType, int], None] + + +@attr.s(slots=True, frozen=True) +class Message: + """MQTT Message.""" + + topic = attr.ib(type=str) + payload = attr.ib(type=PublishPayloadType) + qos = attr.ib(type=int) + retain = attr.ib(type=bool) + + +MessageCallbackType = Callable[[Message], None] def _build_publish_data(topic: Any, qos: int, retain: bool) -> ServiceDataType: @@ -304,6 +318,30 @@ def publish_template(hass: HomeAssistantType, topic, payload_template, hass.services.call(DOMAIN, SERVICE_PUBLISH, data) +def wrap_msg_callback( + msg_callback: MessageCallbackType) -> MessageCallbackType: + """Wrap an MQTT message callback to support deprecated signature.""" + # Check for partials to properly determine if coroutine function + check_func = msg_callback + while isinstance(check_func, partial): + check_func = check_func.func + + wrapper_func = None + if asyncio.iscoroutinefunction(check_func): + @wraps(msg_callback) + async def async_wrapper(msg: Any) -> None: + """Catch and log exception.""" + await msg_callback(msg.topic, msg.payload, msg.qos) + wrapper_func = async_wrapper + else: + @wraps(msg_callback) + def wrapper(msg: Any) -> None: + """Catch and log exception.""" + msg_callback(msg.topic, msg.payload, msg.qos) + wrapper_func = wrapper + return wrapper_func + + @bind_hass async def async_subscribe(hass: HomeAssistantType, topic: str, msg_callback: MessageCallbackType, @@ -313,11 +351,25 @@ async def async_subscribe(hass: HomeAssistantType, topic: str, Call the return value to unsubscribe. """ + # Count callback parameters which don't have a default value + non_default = 0 + if msg_callback: + non_default = sum(p.default == inspect.Parameter.empty for _, p in + inspect.signature(msg_callback).parameters.items()) + + wrapped_msg_callback = msg_callback + # If we have 3 paramaters with no default value, wrap the callback + if non_default == 3: + _LOGGER.info( + "Signature of MQTT msg_callback '%s.%s' is deprecated", + inspect.getmodule(msg_callback).__name__, msg_callback.__name__) + wrapped_msg_callback = wrap_msg_callback(msg_callback) + async_remove = await hass.data[DATA_MQTT].async_subscribe( topic, catch_log_exception( - msg_callback, lambda topic, msg, qos: + wrapped_msg_callback, lambda msg: "Exception in {} when handling msg on '{}': '{}'".format( - msg_callback.__name__, topic, msg)), + msg_callback.__name__, msg.topic, msg.payload)), qos, encoding) return async_remove @@ -575,16 +627,6 @@ class Subscription: encoding = attr.ib(type=str, default='utf-8') -@attr.s(slots=True, frozen=True) -class Message: - """MQTT Message.""" - - topic = attr.ib(type=str) - payload = attr.ib(type=PublishPayloadType) - qos = attr.ib(type=int, default=0) - retain = attr.ib(type=bool, default=False) - - class MQTT: """Home Assistant MQTT client.""" @@ -770,7 +812,8 @@ class MQTT: @callback def _mqtt_handle_message(self, msg) -> None: - _LOGGER.debug("Received message on %s: %s", msg.topic, msg.payload) + _LOGGER.debug("Received message on %s%s: %s", msg.topic, + " (retained)" if msg.retain else "", msg.payload) for subscription in self.subscriptions: if not _match_topic(subscription.topic, msg.topic): @@ -787,7 +830,8 @@ class MQTT: continue self.hass.async_run_job( - subscription.callback, msg.topic, payload, msg.qos) + subscription.callback, Message(msg.topic, payload, msg.qos, + msg.retain)) def _mqtt_on_disconnect(self, _mqttc, _userdata, result_code: int) -> None: """Disconnected callback.""" @@ -865,11 +909,9 @@ class MqttAttributes(Entity): from .subscription import async_subscribe_topics @callback - def attributes_message_received(topic: str, - payload: SubscribePayloadType, - qos: int) -> None: + def attributes_message_received(msg: Message) -> None: try: - json_dict = json.loads(payload) + json_dict = json.loads(msg.payload) if isinstance(json_dict, dict): self._attributes = json_dict self.async_write_ha_state() @@ -877,7 +919,7 @@ class MqttAttributes(Entity): _LOGGER.warning("JSON result was not a dictionary") self._attributes = None except ValueError: - _LOGGER.warning("Erroneous JSON: %s", payload) + _LOGGER.warning("Erroneous JSON: %s", msg.payload) self._attributes = None self._attributes_sub_state = await async_subscribe_topics( @@ -927,13 +969,11 @@ class MqttAvailability(Entity): from .subscription import async_subscribe_topics @callback - def availability_message_received(topic: str, - payload: SubscribePayloadType, - qos: int) -> None: + def availability_message_received(msg: Message) -> None: """Handle a new received MQTT availability message.""" - if payload == self._avail_config[CONF_PAYLOAD_AVAILABLE]: + if msg.payload == self._avail_config[CONF_PAYLOAD_AVAILABLE]: self._available = True - elif payload == self._avail_config[CONF_PAYLOAD_NOT_AVAILABLE]: + elif msg.payload == self._avail_config[CONF_PAYLOAD_NOT_AVAILABLE]: self._available = False self.async_write_ha_state() @@ -1064,12 +1104,13 @@ async def websocket_subscribe(hass, connection, msg): if not connection.user.is_admin: raise Unauthorized - async def forward_messages(topic: str, payload: str, qos: int): + async def forward_messages(mqttmsg: Message): """Forward events to websocket.""" connection.send_message(websocket_api.event_message(msg['id'], { - 'topic': topic, - 'payload': payload, - 'qos': qos, + 'topic': mqttmsg.topic, + 'payload': mqttmsg.payload, + 'qos': mqttmsg.qos, + 'retain': mqttmsg.retain, })) connection.subscriptions[msg['id']] = await async_subscribe( diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index a03716676cd..c350b32b4ff 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -126,16 +126,17 @@ class MqttAlarm(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, async def _subscribe_topics(self): """(Re)Subscribe to topics.""" @callback - def message_received(topic, payload, qos): + def message_received(msg): """Run when new MQTT message has been received.""" - if payload not in (STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, - STATE_ALARM_ARMED_AWAY, - STATE_ALARM_ARMED_NIGHT, - STATE_ALARM_PENDING, - STATE_ALARM_TRIGGERED): - _LOGGER.warning("Received unexpected payload: %s", payload) + if msg.payload not in ( + STATE_ALARM_DISARMED, STATE_ALARM_ARMED_HOME, + STATE_ALARM_ARMED_AWAY, + STATE_ALARM_ARMED_NIGHT, + STATE_ALARM_PENDING, + STATE_ALARM_TRIGGERED): + _LOGGER.warning("Received unexpected payload: %s", msg.payload) return - self._state = payload + self._state = msg.payload self.async_write_ha_state() self._sub_state = await subscription.async_subscribe_topics( diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index 103958376c0..f2a93d06f8e 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -133,8 +133,9 @@ class MqttBinarySensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self.async_write_ha_state() @callback - def state_message_received(_topic, payload, _qos): + def state_message_received(msg): """Handle a new received MQTT state message.""" + payload = msg.payload value_template = self._config.get(CONF_VALUE_TEMPLATE) if value_template is not None: payload = value_template.async_render_with_possible_json_value( diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index b9cdb5bef02..ca41f3c4225 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -102,9 +102,9 @@ class MqttCamera(MqttDiscoveryUpdate, Camera): async def _subscribe_topics(self): """(Re)Subscribe to topics.""" @callback - def message_received(topic, payload, qos): + def message_received(msg): """Handle new MQTT messages.""" - self._last_image = payload + self._last_image = msg.payload self._sub_state = await subscription.async_subscribe_topics( self.hass, self._sub_state, diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index 25f5aa68571..ae847437932 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -288,8 +288,9 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, qos = self._config.get(CONF_QOS) @callback - def handle_current_temp_received(topic, payload, qos): + def handle_current_temp_received(msg): """Handle current temperature coming via MQTT.""" + payload = msg.payload if CONF_CURRENT_TEMPERATURE_TEMPLATE in self._value_templates: payload =\ self._value_templates[CONF_CURRENT_TEMPERATURE_TEMPLATE].\ @@ -308,8 +309,9 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, 'qos': qos} @callback - def handle_mode_received(topic, payload, qos): + def handle_mode_received(msg): """Handle receiving mode via MQTT.""" + payload = msg.payload if CONF_MODE_STATE_TEMPLATE in self._value_templates: payload = self._value_templates[CONF_MODE_STATE_TEMPLATE].\ async_render_with_possible_json_value(payload) @@ -327,8 +329,9 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, 'qos': qos} @callback - def handle_temperature_received(topic, payload, qos): + def handle_temperature_received(msg): """Handle target temperature coming via MQTT.""" + payload = msg.payload if CONF_TEMPERATURE_STATE_TEMPLATE in self._value_templates: payload = \ self._value_templates[CONF_TEMPERATURE_STATE_TEMPLATE].\ @@ -347,8 +350,9 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, 'qos': qos} @callback - def handle_fan_mode_received(topic, payload, qos): + def handle_fan_mode_received(msg): """Handle receiving fan mode via MQTT.""" + payload = msg.payload if CONF_FAN_MODE_STATE_TEMPLATE in self._value_templates: payload = \ self._value_templates[CONF_FAN_MODE_STATE_TEMPLATE].\ @@ -367,8 +371,9 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, 'qos': qos} @callback - def handle_swing_mode_received(topic, payload, qos): + def handle_swing_mode_received(msg): """Handle receiving swing mode via MQTT.""" + payload = msg.payload if CONF_SWING_MODE_STATE_TEMPLATE in self._value_templates: payload = \ self._value_templates[CONF_SWING_MODE_STATE_TEMPLATE].\ @@ -387,8 +392,9 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, 'qos': qos} @callback - def handle_away_mode_received(topic, payload, qos): + def handle_away_mode_received(msg): """Handle receiving away mode via MQTT.""" + payload = msg.payload payload_on = self._config.get(CONF_PAYLOAD_ON) payload_off = self._config.get(CONF_PAYLOAD_OFF) if CONF_AWAY_MODE_STATE_TEMPLATE in self._value_templates: @@ -416,8 +422,9 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, 'qos': qos} @callback - def handle_aux_mode_received(topic, payload, qos): + def handle_aux_mode_received(msg): """Handle receiving aux mode via MQTT.""" + payload = msg.payload payload_on = self._config.get(CONF_PAYLOAD_ON) payload_off = self._config.get(CONF_PAYLOAD_OFF) if CONF_AUX_STATE_TEMPLATE in self._value_templates: @@ -444,8 +451,9 @@ class MqttClimate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, 'qos': qos} @callback - def handle_hold_mode_received(topic, payload, qos): + def handle_hold_mode_received(msg): """Handle receiving hold mode via MQTT.""" + payload = msg.payload if CONF_HOLD_STATE_TEMPLATE in self._value_templates: payload = self._value_templates[CONF_HOLD_STATE_TEMPLATE].\ async_render_with_possible_json_value(payload) diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index f4f73c76863..37222cbe868 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -216,19 +216,20 @@ class MqttCover(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, topics = {} @callback - def tilt_updated(topic, payload, qos): + def tilt_updated(msg): """Handle tilt updates.""" - if (payload.isnumeric() and - (self._config.get(CONF_TILT_MIN) <= int(payload) <= + if (msg.payload.isnumeric() and + (self._config.get(CONF_TILT_MIN) <= int(msg.payload) <= self._config.get(CONF_TILT_MAX))): - level = self.find_percentage_in_range(float(payload)) + level = self.find_percentage_in_range(float(msg.payload)) self._tilt_value = level self.async_write_ha_state() @callback - def state_message_received(topic, payload, qos): + def state_message_received(msg): """Handle new MQTT state messages.""" + payload = msg.payload if template is not None: payload = template.async_render_with_possible_json_value( payload) @@ -243,8 +244,9 @@ class MqttCover(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self.async_write_ha_state() @callback - def position_message_received(topic, payload, qos): + def position_message_received(msg): """Handle new MQTT state messages.""" + payload = msg.payload if template is not None: payload = template.async_render_with_possible_json_value( payload) diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index 06bd6d771a4..bf55d955ce1 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -31,10 +31,10 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None): for dev_id, topic in devices.items(): @callback - def async_message_received(topic, payload, qos, dev_id=dev_id): + def async_message_received(msg, dev_id=dev_id): """Handle received MQTT message.""" hass.async_create_task( - async_see(dev_id=dev_id, location_name=payload)) + async_see(dev_id=dev_id, location_name=msg.payload)) await mqtt.async_subscribe( hass, topic, async_message_received, qos) diff --git a/homeassistant/components/mqtt/discovery.py b/homeassistant/components/mqtt/discovery.py index 885c14f609f..745e54d0ed7 100644 --- a/homeassistant/components/mqtt/discovery.py +++ b/homeassistant/components/mqtt/discovery.py @@ -200,8 +200,10 @@ def clear_discovery_hash(hass, discovery_hash): async def async_start(hass: HomeAssistantType, discovery_topic, hass_config, config_entry=None) -> bool: """Initialize of MQTT Discovery.""" - async def async_device_message_received(topic, payload, qos): + async def async_device_message_received(msg): """Process the received message.""" + payload = msg.payload + topic = msg.topic match = TOPIC_MATCHER.match(topic) if not match: diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index eb1e6e84101..7c9f816eff7 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -212,9 +212,9 @@ class MqttFan(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, templates[key] = tpl.async_render_with_possible_json_value @callback - def state_received(topic, payload, qos): + def state_received(msg): """Handle new received MQTT message.""" - payload = templates[CONF_STATE](payload) + payload = templates[CONF_STATE](msg.payload) if payload == self._payload[STATE_ON]: self._state = True elif payload == self._payload[STATE_OFF]: @@ -228,9 +228,9 @@ class MqttFan(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, 'qos': self._config.get(CONF_QOS)} @callback - def speed_received(topic, payload, qos): + def speed_received(msg): """Handle new received MQTT message for the speed.""" - payload = templates[ATTR_SPEED](payload) + payload = templates[ATTR_SPEED](msg.payload) if payload == self._payload[SPEED_LOW]: self._speed = SPEED_LOW elif payload == self._payload[SPEED_MEDIUM]: @@ -247,9 +247,9 @@ class MqttFan(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._speed = SPEED_OFF @callback - def oscillation_received(topic, payload, qos): + def oscillation_received(msg): """Handle new received MQTT message for the oscillation.""" - payload = templates[OSCILLATION](payload) + payload = templates[OSCILLATION](msg.payload) if payload == self._payload[OSCILLATE_ON_PAYLOAD]: self._oscillation = True elif payload == self._payload[OSCILLATE_OFF_PAYLOAD]: diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index 256e0f46d85..a985a707485 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -254,11 +254,12 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, last_state = await self.async_get_last_state() @callback - def state_received(topic, payload, qos): + def state_received(msg): """Handle new MQTT messages.""" - payload = templates[CONF_STATE](payload) + payload = templates[CONF_STATE](msg.payload) if not payload: - _LOGGER.debug("Ignoring empty state message from '%s'", topic) + _LOGGER.debug("Ignoring empty state message from '%s'", + msg.topic) return if payload == self._payload['on']: @@ -276,12 +277,12 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._state = last_state.state == STATE_ON @callback - def brightness_received(topic, payload, qos): + def brightness_received(msg): """Handle new MQTT messages for the brightness.""" - payload = templates[CONF_BRIGHTNESS](payload) + payload = templates[CONF_BRIGHTNESS](msg.payload) if not payload: _LOGGER.debug("Ignoring empty brightness message from '%s'", - topic) + msg.topic) return device_value = float(payload) @@ -305,11 +306,12 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._brightness = None @callback - def rgb_received(topic, payload, qos): + def rgb_received(msg): """Handle new MQTT messages for RGB.""" - payload = templates[CONF_RGB](payload) + payload = templates[CONF_RGB](msg.payload) if not payload: - _LOGGER.debug("Ignoring empty rgb message from '%s'", topic) + _LOGGER.debug("Ignoring empty rgb message from '%s'", + msg.topic) return rgb = [int(val) for val in payload.split(',')] @@ -333,12 +335,12 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._hs = (0, 0) @callback - def color_temp_received(topic, payload, qos): + def color_temp_received(msg): """Handle new MQTT messages for color temperature.""" - payload = templates[CONF_COLOR_TEMP](payload) + payload = templates[CONF_COLOR_TEMP](msg.payload) if not payload: _LOGGER.debug("Ignoring empty color temp message from '%s'", - topic) + msg.topic) return self._color_temp = int(payload) @@ -359,11 +361,12 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._color_temp = None @callback - def effect_received(topic, payload, qos): + def effect_received(msg): """Handle new MQTT messages for effect.""" - payload = templates[CONF_EFFECT](payload) + payload = templates[CONF_EFFECT](msg.payload) if not payload: - _LOGGER.debug("Ignoring empty effect message from '%s'", topic) + _LOGGER.debug("Ignoring empty effect message from '%s'", + msg.topic) return self._effect = payload @@ -384,11 +387,11 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._effect = None @callback - def hs_received(topic, payload, qos): + def hs_received(msg): """Handle new MQTT messages for hs color.""" - payload = templates[CONF_HS](payload) + payload = templates[CONF_HS](msg.payload) if not payload: - _LOGGER.debug("Ignoring empty hs message from '%s'", topic) + _LOGGER.debug("Ignoring empty hs message from '%s'", msg.topic) return try: @@ -412,12 +415,12 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._hs = (0, 0) @callback - def white_value_received(topic, payload, qos): + def white_value_received(msg): """Handle new MQTT messages for white value.""" - payload = templates[CONF_WHITE_VALUE](payload) + payload = templates[CONF_WHITE_VALUE](msg.payload) if not payload: _LOGGER.debug("Ignoring empty white value message from '%s'", - topic) + msg.topic) return device_value = float(payload) @@ -441,12 +444,12 @@ class MqttLight(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, self._white_value = None @callback - def xy_received(topic, payload, qos): + def xy_received(msg): """Handle new MQTT messages for xy color.""" - payload = templates[CONF_XY](payload) + payload = templates[CONF_XY](msg.payload) if not payload: _LOGGER.debug("Ignoring empty xy-color message from '%s'", - topic) + msg.topic) return xy_color = [float(val) for val in payload.split(',')] diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index df3aa7fe89e..12f688afbf7 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -201,9 +201,9 @@ class MqttLightJson(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, last_state = await self.async_get_last_state() @callback - def state_received(topic, payload, qos): + def state_received(msg): """Handle new MQTT messages.""" - values = json.loads(payload) + values = json.loads(msg.payload) if values['state'] == 'ON': self._state = True diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index 0773a0cf05d..27c1fb00441 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -188,10 +188,10 @@ class MqttTemplate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, last_state = await self.async_get_last_state() @callback - def state_received(topic, payload, qos): + def state_received(msg): """Handle new MQTT messages.""" state = self._templates[CONF_STATE_TEMPLATE].\ - async_render_with_possible_json_value(payload) + async_render_with_possible_json_value(msg.payload) if state == STATE_ON: self._state = True elif state == STATE_OFF: @@ -203,7 +203,7 @@ class MqttTemplate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, try: self._brightness = int( self._templates[CONF_BRIGHTNESS_TEMPLATE]. - async_render_with_possible_json_value(payload) + async_render_with_possible_json_value(msg.payload) ) except ValueError: _LOGGER.warning("Invalid brightness value received") @@ -212,7 +212,7 @@ class MqttTemplate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, try: self._color_temp = int( self._templates[CONF_COLOR_TEMP_TEMPLATE]. - async_render_with_possible_json_value(payload) + async_render_with_possible_json_value(msg.payload) ) except ValueError: _LOGGER.warning("Invalid color temperature value received") @@ -221,13 +221,13 @@ class MqttTemplate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, try: red = int( self._templates[CONF_RED_TEMPLATE]. - async_render_with_possible_json_value(payload)) + async_render_with_possible_json_value(msg.payload)) green = int( self._templates[CONF_GREEN_TEMPLATE]. - async_render_with_possible_json_value(payload)) + async_render_with_possible_json_value(msg.payload)) blue = int( self._templates[CONF_BLUE_TEMPLATE]. - async_render_with_possible_json_value(payload)) + async_render_with_possible_json_value(msg.payload)) self._hs = color_util.color_RGB_to_hs(red, green, blue) except ValueError: _LOGGER.warning("Invalid color value received") @@ -236,14 +236,14 @@ class MqttTemplate(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, try: self._white_value = int( self._templates[CONF_WHITE_VALUE_TEMPLATE]. - async_render_with_possible_json_value(payload) + async_render_with_possible_json_value(msg.payload) ) except ValueError: _LOGGER.warning('Invalid white value received') if self._templates[CONF_EFFECT_TEMPLATE] is not None: effect = self._templates[CONF_EFFECT_TEMPLATE].\ - async_render_with_possible_json_value(payload) + async_render_with_possible_json_value(msg.payload) if effect in self._config.get(CONF_EFFECT_LIST): self._effect = effect diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index c8f1bedeeff..d9adc37d79a 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -120,8 +120,9 @@ class MqttLock(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, value_template.hass = self.hass @callback - def message_received(topic, payload, qos): + def message_received(msg): """Handle new MQTT messages.""" + payload = msg.payload if value_template is not None: payload = value_template.async_render_with_possible_json_value( payload) diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index 0a507b1bc4f..c6ef3344fcf 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -133,8 +133,9 @@ class MqttSensor(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, template.hass = self.hass @callback - def message_received(topic, payload, qos): + def message_received(msg): """Handle new MQTT messages.""" + payload = msg.payload # auto-expire enabled? expire_after = self._config.get(CONF_EXPIRE_AFTER) if expire_after is not None and expire_after > 0: diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index 50243274bfb..de7da6b7249 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -143,8 +143,9 @@ class MqttSwitch(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, template.hass = self.hass @callback - def state_message_received(topic, payload, qos): + def state_message_received(msg): """Handle new MQTT state messages.""" + payload = msg.payload if template is not None: payload = template.async_render_with_possible_json_value( payload) diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index 081bf5fc583..eb7e78b6254 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -284,45 +284,45 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, tpl.hass = self.hass @callback - def message_received(topic, payload, qos): + def message_received(msg): """Handle new MQTT message.""" - if topic == self._state_topics[CONF_BATTERY_LEVEL_TOPIC] and \ + if msg.topic == self._state_topics[CONF_BATTERY_LEVEL_TOPIC] and \ self._templates[CONF_BATTERY_LEVEL_TEMPLATE]: battery_level = self._templates[CONF_BATTERY_LEVEL_TEMPLATE]\ .async_render_with_possible_json_value( - payload, error_value=None) + msg.payload, error_value=None) if battery_level is not None: self._battery_level = int(battery_level) - if topic == self._state_topics[CONF_CHARGING_TOPIC] and \ + if msg.topic == self._state_topics[CONF_CHARGING_TOPIC] and \ self._templates[CONF_CHARGING_TEMPLATE]: charging = self._templates[CONF_CHARGING_TEMPLATE]\ .async_render_with_possible_json_value( - payload, error_value=None) + msg.payload, error_value=None) if charging is not None: self._charging = cv.boolean(charging) - if topic == self._state_topics[CONF_CLEANING_TOPIC] and \ + if msg.topic == self._state_topics[CONF_CLEANING_TOPIC] and \ self._templates[CONF_CLEANING_TEMPLATE]: cleaning = self._templates[CONF_CLEANING_TEMPLATE]\ .async_render_with_possible_json_value( - payload, error_value=None) + msg.payload, error_value=None) if cleaning is not None: self._cleaning = cv.boolean(cleaning) - if topic == self._state_topics[CONF_DOCKED_TOPIC] and \ + if msg.topic == self._state_topics[CONF_DOCKED_TOPIC] and \ self._templates[CONF_DOCKED_TEMPLATE]: docked = self._templates[CONF_DOCKED_TEMPLATE]\ .async_render_with_possible_json_value( - payload, error_value=None) + msg.payload, error_value=None) if docked is not None: self._docked = cv.boolean(docked) - if topic == self._state_topics[CONF_ERROR_TOPIC] and \ + if msg.topic == self._state_topics[CONF_ERROR_TOPIC] and \ self._templates[CONF_ERROR_TEMPLATE]: error = self._templates[CONF_ERROR_TEMPLATE]\ .async_render_with_possible_json_value( - payload, error_value=None) + msg.payload, error_value=None) if error is not None: self._error = cv.string(error) @@ -338,11 +338,11 @@ class MqttVacuum(MqttAttributes, MqttAvailability, MqttDiscoveryUpdate, else: self._status = "Stopped" - if topic == self._state_topics[CONF_FAN_SPEED_TOPIC] and \ + if msg.topic == self._state_topics[CONF_FAN_SPEED_TOPIC] and \ self._templates[CONF_FAN_SPEED_TEMPLATE]: fan_speed = self._templates[CONF_FAN_SPEED_TEMPLATE]\ .async_render_with_possible_json_value( - payload, error_value=None) + msg.payload, error_value=None) if fan_speed is not None: self._fan_speed = fan_speed diff --git a/homeassistant/components/mysensors/gateway.py b/homeassistant/components/mysensors/gateway.py index d4a52655d19..62ea20cbb91 100644 --- a/homeassistant/components/mysensors/gateway.py +++ b/homeassistant/components/mysensors/gateway.py @@ -98,9 +98,9 @@ async def _get_gateway(hass, config, gateway_conf, persistence_file): def sub_callback(topic, sub_cb, qos): """Call MQTT subscribe function.""" @callback - def internal_callback(*args): + def internal_callback(msg): """Call callback.""" - sub_cb(*args) + sub_cb(msg.topic, msg.payload, msg.qos) hass.async_create_task( mqtt.async_subscribe(topic, internal_callback, qos)) diff --git a/tests/components/mqtt/test_init.py b/tests/components/mqtt/test_init.py index 81941173d68..5c441a68bea 100644 --- a/tests/components/mqtt/test_init.py +++ b/tests/components/mqtt/test_init.py @@ -316,8 +316,8 @@ class TestMQTTCallbacks(unittest.TestCase): self.hass.block_till_done() assert 1 == len(self.calls) - assert 'test-topic' == self.calls[0][0] - assert 'test-payload' == self.calls[0][1] + assert 'test-topic' == self.calls[0][0].topic + assert 'test-payload' == self.calls[0][0].payload unsub() @@ -343,8 +343,8 @@ class TestMQTTCallbacks(unittest.TestCase): self.hass.block_till_done() assert 1 == len(self.calls) - assert 'test-topic/bier/on' == self.calls[0][0] - assert 'test-payload' == self.calls[0][1] + assert 'test-topic/bier/on' == self.calls[0][0].topic + assert 'test-payload' == self.calls[0][0].payload def test_subscribe_topic_level_wildcard_no_subtree_match(self): """Test the subscription of wildcard topics.""" @@ -372,8 +372,8 @@ class TestMQTTCallbacks(unittest.TestCase): self.hass.block_till_done() assert 1 == len(self.calls) - assert 'test-topic/bier/on' == self.calls[0][0] - assert 'test-payload' == self.calls[0][1] + assert 'test-topic/bier/on' == self.calls[0][0].topic + assert 'test-payload' == self.calls[0][0].payload def test_subscribe_topic_subtree_wildcard_root_topic(self): """Test the subscription of wildcard topics.""" @@ -383,8 +383,8 @@ class TestMQTTCallbacks(unittest.TestCase): self.hass.block_till_done() assert 1 == len(self.calls) - assert 'test-topic' == self.calls[0][0] - assert 'test-payload' == self.calls[0][1] + assert 'test-topic' == self.calls[0][0].topic + assert 'test-payload' == self.calls[0][0].payload def test_subscribe_topic_subtree_wildcard_no_match(self): """Test the subscription of wildcard topics.""" @@ -403,8 +403,8 @@ class TestMQTTCallbacks(unittest.TestCase): self.hass.block_till_done() assert 1 == len(self.calls) - assert 'hi/test-topic' == self.calls[0][0] - assert 'test-payload' == self.calls[0][1] + assert 'hi/test-topic' == self.calls[0][0].topic + assert 'test-payload' == self.calls[0][0].payload def test_subscribe_topic_level_wildcard_and_wildcard_subtree_topic(self): """Test the subscription of wildcard topics.""" @@ -414,8 +414,8 @@ class TestMQTTCallbacks(unittest.TestCase): self.hass.block_till_done() assert 1 == len(self.calls) - assert 'hi/test-topic/here-iam' == self.calls[0][0] - assert 'test-payload' == self.calls[0][1] + assert 'hi/test-topic/here-iam' == self.calls[0][0].topic + assert 'test-payload' == self.calls[0][0].payload def test_subscribe_topic_level_wildcard_and_wildcard_level_no_match(self): """Test the subscription of wildcard topics.""" @@ -443,8 +443,8 @@ class TestMQTTCallbacks(unittest.TestCase): self.hass.block_till_done() assert 1 == len(self.calls) - assert '$test-topic/subtree/on' == self.calls[0][0] - assert 'test-payload' == self.calls[0][1] + assert '$test-topic/subtree/on' == self.calls[0][0].topic + assert 'test-payload' == self.calls[0][0].payload def test_subscribe_topic_sys_root_and_wildcard_topic(self): """Test the subscription of $ root and wildcard topics.""" @@ -454,8 +454,8 @@ class TestMQTTCallbacks(unittest.TestCase): self.hass.block_till_done() assert 1 == len(self.calls) - assert '$test-topic/some-topic' == self.calls[0][0] - assert 'test-payload' == self.calls[0][1] + assert '$test-topic/some-topic' == self.calls[0][0].topic + assert 'test-payload' == self.calls[0][0].payload def test_subscribe_topic_sys_root_and_wildcard_subtree_topic(self): """Test the subscription of $ root and wildcard subtree topics.""" @@ -466,8 +466,8 @@ class TestMQTTCallbacks(unittest.TestCase): self.hass.block_till_done() assert 1 == len(self.calls) - assert '$test-topic/subtree/some-topic' == self.calls[0][0] - assert 'test-payload' == self.calls[0][1] + assert '$test-topic/subtree/some-topic' == self.calls[0][0].topic + assert 'test-payload' == self.calls[0][0].payload def test_subscribe_special_characters(self): """Test the subscription to topics with special characters.""" @@ -479,8 +479,8 @@ class TestMQTTCallbacks(unittest.TestCase): fire_mqtt_message(self.hass, topic, payload) self.hass.block_till_done() assert 1 == len(self.calls) - assert topic == self.calls[0][0] - assert payload == self.calls[0][1] + assert topic == self.calls[0][0].topic + assert payload == self.calls[0][0].payload def test_mqtt_failed_connection_results_in_disconnect(self): """Test if connection failure leads to disconnect.""" diff --git a/tests/components/mqtt/test_subscription.py b/tests/components/mqtt/test_subscription.py index b4b005d0d1e..cd274079e01 100644 --- a/tests/components/mqtt/test_subscription.py +++ b/tests/components/mqtt/test_subscription.py @@ -35,8 +35,8 @@ async def test_subscribe_topics(hass, mqtt_mock, caplog): async_fire_mqtt_message(hass, 'test-topic1', 'test-payload1') await hass.async_block_till_done() assert 1 == len(calls1) - assert 'test-topic1' == calls1[0][0] - assert 'test-payload1' == calls1[0][1] + assert 'test-topic1' == calls1[0][0].topic + assert 'test-payload1' == calls1[0][0].payload assert 0 == len(calls2) async_fire_mqtt_message(hass, 'test-topic2', 'test-payload2') @@ -44,8 +44,8 @@ async def test_subscribe_topics(hass, mqtt_mock, caplog): await hass.async_block_till_done() assert 1 == len(calls1) assert 1 == len(calls2) - assert 'test-topic2' == calls2[0][0] - assert 'test-payload2' == calls2[0][1] + assert 'test-topic2' == calls2[0][0].topic + assert 'test-payload2' == calls2[0][0].payload await async_unsubscribe_topics(hass, sub_state) @@ -108,8 +108,8 @@ async def test_modify_topics(hass, mqtt_mock, caplog): await hass.async_block_till_done() await hass.async_block_till_done() assert 2 == len(calls1) - assert 'test-topic1_1' == calls1[1][0] - assert 'test-payload' == calls1[1][1] + assert 'test-topic1_1' == calls1[1][0].topic + assert 'test-payload' == calls1[1][0].payload assert 1 == len(calls2) await async_unsubscribe_topics(hass, sub_state) From 83243e95d3da58552ee8db92e1a6d7fe88dccaf2 Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Wed, 13 Mar 2019 14:59:37 -0500 Subject: [PATCH 238/291] Remove default temp unit (#22012) --- homeassistant/components/smartthings/sensor.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index 58426df7d21..4f7ad1a1398 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -89,7 +89,7 @@ CAPABILITY_TO_SENSORS = { 'powerSource': [ Map('powerSource', "Power Source", None, None)], 'refrigerationSetpoint': [ - Map('refrigerationSetpoint', "Refrigeration Setpoint", TEMP_CELSIUS, + Map('refrigerationSetpoint', "Refrigeration Setpoint", None, DEVICE_CLASS_TEMPERATURE)], 'relativeHumidityMeasurement': [ Map('humidity', "Relative Humidity Measurement", '%', @@ -107,15 +107,15 @@ CAPABILITY_TO_SENSORS = { 'smokeDetector': [ Map('smoke', "Smoke Detector", None, None)], 'temperatureMeasurement': [ - Map('temperature', "Temperature Measurement", TEMP_CELSIUS, + Map('temperature', "Temperature Measurement", None, DEVICE_CLASS_TEMPERATURE)], 'thermostatCoolingSetpoint': [ - Map('coolingSetpoint', "Thermostat Cooling Setpoint", TEMP_CELSIUS, + Map('coolingSetpoint', "Thermostat Cooling Setpoint", None, DEVICE_CLASS_TEMPERATURE)], 'thermostatFanMode': [ Map('thermostatFanMode', "Thermostat Fan Mode", None, None)], 'thermostatHeatingSetpoint': [ - Map('heatingSetpoint', "Thermostat Heating Setpoint", TEMP_CELSIUS, + Map('heatingSetpoint', "Thermostat Heating Setpoint", None, DEVICE_CLASS_TEMPERATURE)], 'thermostatMode': [ Map('thermostatMode', "Thermostat Mode", None, None)], @@ -123,7 +123,7 @@ CAPABILITY_TO_SENSORS = { Map('thermostatOperatingState', "Thermostat Operating State", None, None)], 'thermostatSetpoint': [ - Map('thermostatSetpoint', "Thermostat Setpoint", TEMP_CELSIUS, + Map('thermostatSetpoint', "Thermostat Setpoint", None, DEVICE_CLASS_TEMPERATURE)], 'threeAxis': [ Map('threeAxis', "Three Axis", None, None)], From 2dcd9d94c8e80347b81ebfd07ea8d25ae1f43ebb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Isabella=20Gross=20Alstr=C3=B6m?= Date: Wed, 13 Mar 2019 21:00:08 +0100 Subject: [PATCH 239/291] Allow all success status codes in REST notify response (#22011) For example Discord webhooks returns a 204 success code as response, which gets logged as an error in the log, even though it is successful. Update the allowed statuses to accept all 2xx responses as successful. --- homeassistant/components/notify/rest.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/notify/rest.py b/homeassistant/components/notify/rest.py index 710a1a597e9..df25045c6ec 100644 --- a/homeassistant/components/notify/rest.py +++ b/homeassistant/components/notify/rest.py @@ -112,7 +112,8 @@ class RestNotificationService(BaseNotificationService): response = requests.get(self._resource, headers=self._headers, params=data, timeout=10) - if response.status_code not in (200, 201, 202): + success_codes = (200, 201, 202, 203, 204, 205, 206, 207, 208, 226) + if response.status_code not in success_codes: _LOGGER.exception( "Error sending message. Response %d: %s:", response.status_code, response.reason) From 1ffc0e3c7c18d7642217f60055ef2276e71304ef Mon Sep 17 00:00:00 2001 From: Gijs Reichert Date: Wed, 13 Mar 2019 21:00:58 +0100 Subject: [PATCH 240/291] Check updated_date for list and pick first (#22008) --- homeassistant/components/sensor/whois.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/sensor/whois.py b/homeassistant/components/sensor/whois.py index a189c0e858e..3685652387a 100644 --- a/homeassistant/components/sensor/whois.py +++ b/homeassistant/components/sensor/whois.py @@ -134,7 +134,11 @@ class WhoisSensor(Entity): attrs[ATTR_NAME_SERVERS] = ' '.join(response['nameservers']) if 'updated_date' in response: - attrs[ATTR_UPDATED] = response['updated_date'].isoformat() + update_date = response['updated_date'] + if isinstance(update_date, list): + attrs[ATTR_UPDATED] = update_date[0].isoformat() + else: + attrs[ATTR_UPDATED] = update_date.isoformat() if 'registrar' in response: attrs[ATTR_REGISTRAR] = response['registrar'] From b601fa52ba5ad275fca183cd975c1a88a19e2de0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 13 Mar 2019 13:02:55 -0700 Subject: [PATCH 241/291] Bumped version to 0.89.0b0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 85097dcb652..c4033b81dfd 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 89 -PATCH_VERSION = '0.dev0' +PATCH_VERSION = '0b0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 07022c46f2d118428a06cc15aa86782d24d6d5c9 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 13 Mar 2019 13:03:58 -0700 Subject: [PATCH 242/291] Version bump to 0.90.0b0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index c4033b81dfd..7b74c25d08a 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -1,7 +1,7 @@ # coding: utf-8 """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 -MINOR_VERSION = 89 +MINOR_VERSION = 90 PATCH_VERSION = '0b0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) From 42c9472a746169e9ee9b2544e20914fee83bbe5b Mon Sep 17 00:00:00 2001 From: Phil Hawthorne Date: Thu, 14 Mar 2019 13:08:23 +1100 Subject: [PATCH 243/291] Remove UTF8 decoding for Waze (#22020) Removes the UFT8 decoding for the Waze sensor, which broke in 0.89 Fixes #21739 --- homeassistant/components/sensor/waze_travel_time.py | 1 - 1 file changed, 1 deletion(-) diff --git a/homeassistant/components/sensor/waze_travel_time.py b/homeassistant/components/sensor/waze_travel_time.py index 83b4f3ad934..96a4c747293 100644 --- a/homeassistant/components/sensor/waze_travel_time.py +++ b/homeassistant/components/sensor/waze_travel_time.py @@ -218,7 +218,6 @@ class WazeTravelTime(Entity): 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, From c78e332df36706e82edb135e2bc034090da882c6 Mon Sep 17 00:00:00 2001 From: cgtobi Date: Thu, 14 Mar 2019 18:18:25 +0100 Subject: [PATCH 244/291] Bring back the boiler status (#22021) --- homeassistant/components/netatmo/climate.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/netatmo/climate.py b/homeassistant/components/netatmo/climate.py index 409358c2f04..2d8b06dd466 100644 --- a/homeassistant/components/netatmo/climate.py +++ b/homeassistant/components/netatmo/climate.py @@ -187,7 +187,7 @@ class NetatmoThermostat(ClimateDevice): "module_id": self._data.room_status[self._room_id]['module_id'] } if module_type == NA_THERM: - state_attributes["boiler_status"] = self.current_operation + state_attributes["boiler_status"] = self._data.boilerstatus elif module_type == NA_VALVE: state_attributes["heating_power_request"] = \ self._data.room_status[self._room_id]['heating_power_request'] From 90c878a7eda68df641334cdd5cdc97af6016c933 Mon Sep 17 00:00:00 2001 From: emontnemery Date: Thu, 14 Mar 2019 18:58:32 +0100 Subject: [PATCH 245/291] Update additional platforms to use new MQTT message callback (#22030) * Move additional platforms to new MQTT callback * Fix automation.mqtt --- .../components/alarm_control_panel/manual_mqtt.py | 12 ++++++------ homeassistant/components/automation/mqtt.py | 12 ++++++------ homeassistant/components/device_tracker/mqtt_json.py | 8 ++++---- .../components/mqtt_eventstream/__init__.py | 4 ++-- homeassistant/components/owntracks/__init__.py | 8 ++++---- homeassistant/components/sensor/arwn.py | 6 +++--- homeassistant/components/sensor/mqtt_room.py | 6 +++--- homeassistant/components/snips/__init__.py | 8 ++++---- 8 files changed, 32 insertions(+), 32 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/manual_mqtt.py b/homeassistant/components/alarm_control_panel/manual_mqtt.py index 693c15fa424..9bee2b81d61 100644 --- a/homeassistant/components/alarm_control_panel/manual_mqtt.py +++ b/homeassistant/components/alarm_control_panel/manual_mqtt.py @@ -342,18 +342,18 @@ class ManualMQTTAlarm(alarm.AlarmControlPanel): ) @callback - def message_received(topic, payload, qos): + def message_received(msg): """Run when new MQTT message has been received.""" - if payload == self._payload_disarm: + if msg.payload == self._payload_disarm: self.async_alarm_disarm(self._code) - elif payload == self._payload_arm_home: + elif msg.payload == self._payload_arm_home: self.async_alarm_arm_home(self._code) - elif payload == self._payload_arm_away: + elif msg.payload == self._payload_arm_away: self.async_alarm_arm_away(self._code) - elif payload == self._payload_arm_night: + elif msg.payload == self._payload_arm_night: self.async_alarm_arm_night(self._code) else: - _LOGGER.warning("Received unexpected payload: %s", payload) + _LOGGER.warning("Received unexpected payload: %s", msg.payload) return await mqtt.async_subscribe( diff --git a/homeassistant/components/automation/mqtt.py b/homeassistant/components/automation/mqtt.py index 5f52da745ee..ff89cd47024 100644 --- a/homeassistant/components/automation/mqtt.py +++ b/homeassistant/components/automation/mqtt.py @@ -29,18 +29,18 @@ async def async_trigger(hass, config, action, automation_info): encoding = config[CONF_ENCODING] or None @callback - def mqtt_automation_listener(msg_topic, msg_payload, qos): + def mqtt_automation_listener(mqttmsg): """Listen for MQTT messages.""" - if payload is None or payload == msg_payload: + if payload is None or payload == mqttmsg.payload: data = { 'platform': 'mqtt', - 'topic': msg_topic, - 'payload': msg_payload, - 'qos': qos, + 'topic': mqttmsg.topic, + 'payload': mqttmsg.payload, + 'qos': mqttmsg.qos, } try: - data['payload_json'] = json.loads(msg_payload) + data['payload_json'] = json.loads(mqttmsg.payload) except ValueError: pass diff --git a/homeassistant/components/device_tracker/mqtt_json.py b/homeassistant/components/device_tracker/mqtt_json.py index 3a820d189f4..0a1b327dca9 100644 --- a/homeassistant/components/device_tracker/mqtt_json.py +++ b/homeassistant/components/device_tracker/mqtt_json.py @@ -41,17 +41,17 @@ async def async_setup_scanner(hass, config, async_see, discovery_info=None): for dev_id, topic in devices.items(): @callback - def async_message_received(topic, payload, qos, dev_id=dev_id): + def async_message_received(msg, dev_id=dev_id): """Handle received MQTT message.""" try: - data = GPS_JSON_PAYLOAD_SCHEMA(json.loads(payload)) + data = GPS_JSON_PAYLOAD_SCHEMA(json.loads(msg.payload)) except vol.MultipleInvalid: _LOGGER.error("Skipping update for following data " "because of missing or malformatted data: %s", - payload) + msg.payload) return except ValueError: - _LOGGER.error("Error parsing JSON payload: %s", payload) + _LOGGER.error("Error parsing JSON payload: %s", msg.payload) return kwargs = _parse_see_args(dev_id, data) diff --git a/homeassistant/components/mqtt_eventstream/__init__.py b/homeassistant/components/mqtt_eventstream/__init__.py index 6e545d19fe2..fb6a94f1870 100644 --- a/homeassistant/components/mqtt_eventstream/__init__.py +++ b/homeassistant/components/mqtt_eventstream/__init__.py @@ -74,9 +74,9 @@ def async_setup(hass, config): # Process events from a remote server that are received on a queue. @callback - def _event_receiver(topic, payload, qos): + def _event_receiver(msg): """Receive events published by and fire them on this hass instance.""" - event = json.loads(payload) + event = json.loads(msg.payload) event_type = event.get('event_type') event_data = event.get('event_data') diff --git a/homeassistant/components/owntracks/__init__.py b/homeassistant/components/owntracks/__init__.py index c0d3d152270..df6b815e4c5 100644 --- a/homeassistant/components/owntracks/__init__.py +++ b/homeassistant/components/owntracks/__init__.py @@ -99,16 +99,16 @@ async def async_connect_mqtt(hass, component): """Subscribe to MQTT topic.""" context = hass.data[DOMAIN]['context'] - async def async_handle_mqtt_message(topic, payload, qos): + async def async_handle_mqtt_message(msg): """Handle incoming OwnTracks message.""" try: - message = json.loads(payload) + message = json.loads(msg.payload) except ValueError: # If invalid JSON - _LOGGER.error("Unable to parse payload as JSON: %s", payload) + _LOGGER.error("Unable to parse payload as JSON: %s", msg.payload) return - message['topic'] = topic + message['topic'] = msg.topic hass.helpers.dispatcher.async_dispatcher_send( DOMAIN, hass, context, message) diff --git a/homeassistant/components/sensor/arwn.py b/homeassistant/components/sensor/arwn.py index 2b79e4c3a9a..95825f4ca13 100644 --- a/homeassistant/components/sensor/arwn.py +++ b/homeassistant/components/sensor/arwn.py @@ -61,7 +61,7 @@ async def async_setup_platform(hass, config, async_add_entities, discovery_info=None): """Set up the ARWN platform.""" @callback - def async_sensor_event_received(topic, payload, qos): + def async_sensor_event_received(msg): """Process events as sensors. When a new event on our topic (arwn/#) is received we map it @@ -74,8 +74,8 @@ async def async_setup_platform(hass, config, async_add_entities, This lets us dynamically incorporate sensors without any configuration on our side. """ - event = json.loads(payload) - sensors = discover_sensors(topic, event) + event = json.loads(msg.payload) + sensors = discover_sensors(msg.topic, event) if not sensors: return diff --git a/homeassistant/components/sensor/mqtt_room.py b/homeassistant/components/sensor/mqtt_room.py index b52f039281c..36f99719da4 100644 --- a/homeassistant/components/sensor/mqtt_room.py +++ b/homeassistant/components/sensor/mqtt_room.py @@ -90,16 +90,16 @@ class MQTTRoomSensor(Entity): self.async_schedule_update_ha_state() @callback - def message_received(topic, payload, qos): + def message_received(msg): """Handle new MQTT messages.""" try: - data = MQTT_PAYLOAD(payload) + data = MQTT_PAYLOAD(msg.payload) except vol.MultipleInvalid as error: _LOGGER.debug( "Skipping update because of malformatted data: %s", error) return - device = _parse_update_data(topic, data) + device = _parse_update_data(msg.topic, data) if device.get(CONF_DEVICE_ID) == self._device_id: if self._distance is None or self._updated is None: update_state(**device) diff --git a/homeassistant/components/snips/__init__.py b/homeassistant/components/snips/__init__.py index 20cc7137ef8..0cc96d66b1a 100644 --- a/homeassistant/components/snips/__init__.py +++ b/homeassistant/components/snips/__init__.py @@ -95,14 +95,14 @@ async def async_setup(hass, config): if CONF_FEEDBACK in config[DOMAIN]: async_set_feedback(None, config[DOMAIN][CONF_FEEDBACK]) - async def message_received(topic, payload, qos): + async def message_received(msg): """Handle new messages on MQTT.""" - _LOGGER.debug("New intent: %s", payload) + _LOGGER.debug("New intent: %s", msg.payload) try: - request = json.loads(payload) + request = json.loads(msg.payload) except TypeError: - _LOGGER.error('Received invalid JSON: %s', payload) + _LOGGER.error('Received invalid JSON: %s', msg.payload) return if (request['intent']['confidenceScore'] From 7057958e3e082a0e4a42ccfe5ed099b4c15ac396 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Thu, 14 Mar 2019 09:10:36 -0700 Subject: [PATCH 246/291] Fix lifx light async error (#22031) --- homeassistant/components/lifx/light.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/lifx/light.py b/homeassistant/components/lifx/light.py index c0b6158f186..19a9f7583ec 100644 --- a/homeassistant/components/lifx/light.py +++ b/homeassistant/components/lifx/light.py @@ -24,7 +24,7 @@ from homeassistant.core import callback import homeassistant.helpers.config_validation as cv import homeassistant.helpers.device_registry as dr from homeassistant.helpers.event import async_track_point_in_utc_time -from homeassistant.helpers.service import extract_entity_ids +from homeassistant.helpers.service import async_extract_entity_ids import homeassistant.util.color as color_util _LOGGER = logging.getLogger(__name__) @@ -250,7 +250,7 @@ class LIFXManager: async def service_handler(service): """Apply a service.""" tasks = [] - for light in self.service_to_entities(service): + for light in await self.async_service_to_entities(service): if service.service == SERVICE_LIFX_SET_STATE: task = light.set_state(**service.data) tasks.append(self.hass.async_create_task(task)) @@ -265,7 +265,7 @@ class LIFXManager: """Register the LIFX effects as hass service calls.""" async def service_handler(service): """Apply a service, i.e. start an effect.""" - entities = self.service_to_entities(service) + entities = await self.async_service_to_entities(service) if entities: await self.start_effect( entities, service.service, **service.data) @@ -314,9 +314,9 @@ class LIFXManager: elif service == SERVICE_EFFECT_STOP: await self.effects_conductor.stop(bulbs) - def service_to_entities(self, service): + async def async_service_to_entities(self, service): """Return the known entities that a service call mentions.""" - entity_ids = extract_entity_ids(self.hass, service) + entity_ids = await async_extract_entity_ids(self.hass, service) if entity_ids: entities = [entity for entity in self.entities.values() if entity.entity_id in entity_ids] From 707d32495bad25ad345fc21798d5de970d4cf968 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 15 Mar 2019 00:18:31 +0100 Subject: [PATCH 247/291] Fix Google Assistant User with Cloud (#22042) * Fix Google Assistant User with Cloud * Fix User Agent ID * respell * Fix object * Fix tests * fix lint * Fix lint --- homeassistant/components/cloud/__init__.py | 10 +++ homeassistant/components/cloud/client.py | 12 ++-- homeassistant/components/cloud/const.py | 1 + homeassistant/components/cloud/prefs.py | 14 +++- tests/components/cloud/test_init.py | 77 ++++++++++++++++++---- 5 files changed, 94 insertions(+), 20 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index c854fe69be9..2e324f06738 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -3,6 +3,7 @@ import logging import voluptuous as vol +from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.components.alexa import smart_home as alexa_sh from homeassistant.components.google_assistant import const as ga_c from homeassistant.const import ( @@ -136,12 +137,21 @@ async def async_setup(hass, config): else: kwargs = {CONF_MODE: DEFAULT_MODE} + # Alexa/Google custom config alexa_conf = kwargs.pop(CONF_ALEXA, None) or ALEXA_SCHEMA({}) google_conf = kwargs.pop(CONF_GOOGLE_ACTIONS, None) or GACTIONS_SCHEMA({}) + # Cloud settings prefs = CloudPreferences(hass) await prefs.async_initialize() + # Cloud user + if not prefs.cloud_user: + user = await hass.auth.async_create_system_user( + 'Home Assistant Cloud', [GROUP_ID_ADMIN]) + await prefs.async_update(cloud_user=user.id) + + # Initialize Cloud websession = hass.helpers.aiohttp_client.async_get_clientsession() client = CloudClient(hass, prefs, websession, alexa_conf, google_conf) cloud = hass.data[DOMAIN] = Cloud(client, **kwargs) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 063a9daf00a..f73c16b1904 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -136,12 +136,16 @@ class CloudClient(Interface): if not self._prefs.google_enabled: return ga.turned_off_response(payload) - cloud = self._hass.data[DOMAIN] - return await ga.async_handle_message( - self._hass, self.google_config, - cloud.claims['cognito:username'], payload + answer = await ga.async_handle_message( + self._hass, self.google_config, self.prefs.cloud_user, payload ) + # Fix AgentUserId + cloud = self._hass.data[DOMAIN] + answer['payload']['agentUserId'] = cloud.claims['cognito:username'] + + return answer + async def async_webhook_message( self, payload: Dict[Any, Any]) -> Dict[Any, Any]: """Process cloud webhook message to client.""" diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 65e026389f0..fdedacd6dbb 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -7,6 +7,7 @@ PREF_ENABLE_GOOGLE = 'google_enabled' PREF_ENABLE_REMOTE = 'remote_enabled' PREF_GOOGLE_ALLOW_UNLOCK = 'google_allow_unlock' PREF_CLOUDHOOKS = 'cloudhooks' +PREF_CLOUD_USER = 'cloud_user' CONF_ALEXA = 'alexa' CONF_ALIASES = 'aliases' diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 263c17935cb..16ff8f0c213 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -1,7 +1,7 @@ """Preference management for cloud.""" from .const import ( DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE, - PREF_GOOGLE_ALLOW_UNLOCK, PREF_CLOUDHOOKS) + PREF_GOOGLE_ALLOW_UNLOCK, PREF_CLOUDHOOKS, PREF_CLOUD_USER) STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 @@ -26,14 +26,16 @@ class CloudPreferences: PREF_ENABLE_GOOGLE: True, PREF_ENABLE_REMOTE: False, PREF_GOOGLE_ALLOW_UNLOCK: False, - PREF_CLOUDHOOKS: {} + PREF_CLOUDHOOKS: {}, + PREF_CLOUD_USER: None, } self._prefs = prefs async def async_update(self, *, google_enabled=_UNDEF, alexa_enabled=_UNDEF, remote_enabled=_UNDEF, - google_allow_unlock=_UNDEF, cloudhooks=_UNDEF): + google_allow_unlock=_UNDEF, cloudhooks=_UNDEF, + cloud_user=_UNDEF): """Update user preferences.""" for key, value in ( (PREF_ENABLE_GOOGLE, google_enabled), @@ -41,6 +43,7 @@ class CloudPreferences: (PREF_ENABLE_REMOTE, remote_enabled), (PREF_GOOGLE_ALLOW_UNLOCK, google_allow_unlock), (PREF_CLOUDHOOKS, cloudhooks), + (PREF_CLOUD_USER, cloud_user), ): if value is not _UNDEF: self._prefs[key] = value @@ -75,3 +78,8 @@ class CloudPreferences: def cloudhooks(self): """Return the published cloud webhooks.""" return self._prefs.get(PREF_CLOUDHOOKS, {}) + + @property + def cloud_user(self) -> str: + """Return ID from Home Assistant Cloud system user.""" + return self._prefs.get(PREF_CLOUD_USER) diff --git a/tests/components/cloud/test_init.py b/tests/components/cloud/test_init.py index d3e2e50f3a7..0de395c8bbc 100644 --- a/tests/components/cloud/test_init.py +++ b/tests/components/cloud/test_init.py @@ -1,24 +1,21 @@ """Test the cloud component.""" -from unittest.mock import MagicMock, patch +from unittest.mock import patch -from homeassistant.const import ( - EVENT_HOMEASSISTANT_STOP, EVENT_HOMEASSISTANT_START) +from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.components import cloud from homeassistant.components.cloud.const import DOMAIN - +from homeassistant.components.cloud.prefs import STORAGE_KEY +from homeassistant.const import ( + EVENT_HOMEASSISTANT_START, EVENT_HOMEASSISTANT_STOP) +from homeassistant.setup import async_setup_component from tests.common import mock_coro -async def test_constructor_loads_info_from_config(): +async def test_constructor_loads_info_from_config(hass): """Test non-dev mode loads info from SERVERS constant.""" - hass = MagicMock(data={}) - - with patch( - "homeassistant.components.cloud.prefs.CloudPreferences." - "async_initialize", - return_value=mock_coro() - ): - result = await cloud.async_setup(hass, { + with patch("hass_nabucasa.Cloud.start", return_value=mock_coro()): + result = await async_setup_component(hass, 'cloud', { + 'http': {}, 'cloud': { cloud.CONF_MODE: cloud.MODE_DEV, 'cognito_client_id': 'test-cognito_client_id', @@ -79,3 +76,57 @@ async def test_startup_shutdown_events(hass, mock_cloud_fixture): await hass.async_block_till_done() assert mock_stop.called + + +async def test_setup_existing_cloud_user(hass, hass_storage): + """Test setup with API push default data.""" + user = await hass.auth.async_create_system_user('Cloud test') + hass_storage[STORAGE_KEY] = { + 'version': 1, + 'data': { + 'cloud_user': user.id + } + } + with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()): + result = await async_setup_component(hass, 'cloud', { + 'http': {}, + 'cloud': { + cloud.CONF_MODE: cloud.MODE_DEV, + 'cognito_client_id': 'test-cognito_client_id', + 'user_pool_id': 'test-user_pool_id', + 'region': 'test-region', + 'relayer': 'test-relayer', + } + }) + assert result + + assert hass_storage[STORAGE_KEY]['data']['cloud_user'] == user.id + + +async def test_setup_setup_cloud_user(hass, hass_storage): + """Test setup with API push default data.""" + hass_storage[STORAGE_KEY] = { + 'version': 1, + 'data': { + 'cloud_user': None + } + } + with patch('hass_nabucasa.Cloud.start', return_value=mock_coro()): + result = await async_setup_component(hass, 'cloud', { + 'http': {}, + 'cloud': { + cloud.CONF_MODE: cloud.MODE_DEV, + 'cognito_client_id': 'test-cognito_client_id', + 'user_pool_id': 'test-user_pool_id', + 'region': 'test-region', + 'relayer': 'test-relayer', + } + }) + assert result + + cloud_user = await hass.auth.async_get_user( + hass_storage[STORAGE_KEY]['data']['cloud_user'] + ) + + assert cloud_user + assert cloud_user.groups[0].id == GROUP_ID_ADMIN From fb895bba804927da756b5a48f67ed6f03ed6f3ca Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 14 Mar 2019 16:25:00 -0700 Subject: [PATCH 248/291] Bumped version to 0.90.0b1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 7b74c25d08a..b1a0fbb1e4f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 90 -PATCH_VERSION = '0b0' +PATCH_VERSION = '0b1' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 9e6a7a635797d127575debf238abf8f2a8212d9a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 15 Mar 2019 10:43:32 -0700 Subject: [PATCH 249/291] Updated frontend to 20190315.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 70c2bab0829..a8d2cbc35b9 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -21,7 +21,7 @@ from homeassistant.loader import bind_hass from .storage import async_setup_frontend_storage -REQUIREMENTS = ['home-assistant-frontend==20190313.0'] +REQUIREMENTS = ['home-assistant-frontend==20190315.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index c930723d00b..9200b803b8f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -554,7 +554,7 @@ hole==0.3.0 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190313.0 +home-assistant-frontend==20190315.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f7f1b77559a..36f94167565 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -126,7 +126,7 @@ hdate==0.8.7 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190313.0 +home-assistant-frontend==20190315.0 # homeassistant.components.homekit_controller homekit[IP]==0.13.0 From b336322e9eac684230b256e2c310013c7389e512 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 13 Mar 2019 15:22:43 -0700 Subject: [PATCH 250/291] Mobile App: Require encryption for registrations that support it (#21852) ## Description: **Related issue (if applicable):** fixes #21758 ## Checklist: - [x] The code change is tested and works locally. - [x] Local tests pass with `tox`. **Your PR cannot be merged unless tests pass** - [x] There is no commented out code in this PR. --- homeassistant/components/mobile_app/const.py | 1 + .../components/mobile_app/webhook.py | 25 ++++++++++++------- tests/components/mobile_app/test_webhook.py | 15 +++++++++++ 3 files changed, 32 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 60b4cde4708..11b6f3e9865 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -41,6 +41,7 @@ ATTR_WEBHOOK_ENCRYPTED = 'encrypted' ATTR_WEBHOOK_ENCRYPTED_DATA = 'encrypted_data' ATTR_WEBHOOK_TYPE = 'type' +ERR_ENCRYPTION_REQUIRED = 'encryption_required' ERR_INVALID_COMPONENT = 'invalid_component' ERR_RENDER_FAILURE = 'render_failure' ERR_SAVE_FAILURE = 'save_failure' diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 61188b50e1b..9efd1fcd9f8 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -20,15 +20,16 @@ from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import HomeAssistantType -from .const import (ATTR_APP_COMPONENT, DATA_DELETED_IDS, - ATTR_DEVICE_NAME, ATTR_EVENT_DATA, ATTR_EVENT_TYPE, - DATA_REGISTRATIONS, ATTR_TEMPLATE, ATTR_TEMPLATE_VARIABLES, - ATTR_WEBHOOK_DATA, ATTR_WEBHOOK_ENCRYPTED, - ATTR_WEBHOOK_ENCRYPTED_DATA, ATTR_WEBHOOK_TYPE, - CONF_SECRET, DOMAIN, ERR_RENDER_FAILURE, - WEBHOOK_PAYLOAD_SCHEMA, WEBHOOK_SCHEMAS, - WEBHOOK_TYPE_CALL_SERVICE, WEBHOOK_TYPE_FIRE_EVENT, - WEBHOOK_TYPE_RENDER_TEMPLATE, WEBHOOK_TYPE_UPDATE_LOCATION, +from .const import (ATTR_APP_COMPONENT, ATTR_DEVICE_NAME, ATTR_EVENT_DATA, + ATTR_EVENT_TYPE, ATTR_SUPPORTS_ENCRYPTION, ATTR_TEMPLATE, + ATTR_TEMPLATE_VARIABLES, ATTR_WEBHOOK_DATA, + ATTR_WEBHOOK_ENCRYPTED, ATTR_WEBHOOK_ENCRYPTED_DATA, + ATTR_WEBHOOK_TYPE, CONF_SECRET, DATA_DELETED_IDS, + DATA_REGISTRATIONS, DOMAIN, ERR_ENCRYPTION_REQUIRED, + ERR_RENDER_FAILURE, WEBHOOK_PAYLOAD_SCHEMA, + WEBHOOK_SCHEMAS, WEBHOOK_TYPE_CALL_SERVICE, + WEBHOOK_TYPE_FIRE_EVENT, WEBHOOK_TYPE_RENDER_TEMPLATE, + WEBHOOK_TYPE_UPDATE_LOCATION, WEBHOOK_TYPE_UPDATE_REGISTRATION) from .helpers import (_decrypt_payload, empty_okay_response, error_response, @@ -78,6 +79,12 @@ async def handle_webhook(store: Store, hass: HomeAssistantType, _LOGGER.warning('Received invalid JSON from mobile_app') return empty_okay_response(status=HTTP_BAD_REQUEST) + if (ATTR_WEBHOOK_ENCRYPTED not in req_data and + registration[ATTR_SUPPORTS_ENCRYPTION]): + _LOGGER.warning("Refusing to accept unencrypted webhook from %s", + registration[ATTR_DEVICE_NAME]) + return error_response(ERR_ENCRYPTION_REQUIRED, "Encryption required") + try: req_data = WEBHOOK_PAYLOAD_SCHEMA(req_data) except vol.Invalid as ex: diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index a935110754c..bbdfcde93e7 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -149,3 +149,18 @@ async def test_webhook_handle_decryption(webhook_client): # noqa: F811 decrypted_data = decrypted_data.decode("utf-8") assert json.loads(decrypted_data) == {'rendered': 'Hello world'} + + +async def test_webhook_requires_encryption(webhook_client): # noqa: F811 + """Test that encrypted registrations only accept encrypted data.""" + resp = await webhook_client.post( + '/api/webhook/mobile_app_test', + json=RENDER_TEMPLATE + ) + + assert resp.status == 400 + + webhook_json = await resp.json() + assert 'error' in webhook_json + assert webhook_json['success'] is False + assert webhook_json['error']['code'] == 'encryption_required' From c67113ad55652da1eee9231c7b5105dd7918b021 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 13 Mar 2019 15:33:37 -0700 Subject: [PATCH 251/291] Mobile App: Support rendering multiple templates at once (#21851) * Support rendering multiple templates at once * Only catch TemplateError and dont log the error --- homeassistant/components/mobile_app/const.py | 7 ++-- .../components/mobile_app/webhook.py | 32 +++++++++---------- tests/components/mobile_app/const.py | 4 ++- tests/components/mobile_app/test_http_api.py | 6 ++-- tests/components/mobile_app/test_webhook.py | 6 ++-- 5 files changed, 29 insertions(+), 26 deletions(-) diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 11b6f3e9865..3f1a6bc988c 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -43,7 +43,6 @@ ATTR_WEBHOOK_TYPE = 'type' ERR_ENCRYPTION_REQUIRED = 'encryption_required' ERR_INVALID_COMPONENT = 'invalid_component' -ERR_RENDER_FAILURE = 'render_failure' ERR_SAVE_FAILURE = 'save_failure' WEBHOOK_TYPE_CALL_SERVICE = 'call_service' @@ -99,8 +98,10 @@ FIRE_EVENT_SCHEMA = vol.Schema({ }) RENDER_TEMPLATE_SCHEMA = vol.Schema({ - vol.Required(ATTR_TEMPLATE): cv.string, - vol.Optional(ATTR_TEMPLATE_VARIABLES, default={}): dict, + str: { + vol.Required(ATTR_TEMPLATE): cv.template, + vol.Optional(ATTR_TEMPLATE_VARIABLES, default={}): dict, + } }) WEBHOOK_SCHEMAS = { diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 9efd1fcd9f8..e8372c8648d 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -15,7 +15,7 @@ from homeassistant.const import (ATTR_DOMAIN, ATTR_SERVICE, ATTR_SERVICE_DATA, from homeassistant.core import EventOrigin from homeassistant.exceptions import (HomeAssistantError, ServiceNotFound, TemplateError) -from homeassistant.helpers import template +from homeassistant.helpers.template import attach from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import HomeAssistantType @@ -26,10 +26,9 @@ from .const import (ATTR_APP_COMPONENT, ATTR_DEVICE_NAME, ATTR_EVENT_DATA, ATTR_WEBHOOK_ENCRYPTED, ATTR_WEBHOOK_ENCRYPTED_DATA, ATTR_WEBHOOK_TYPE, CONF_SECRET, DATA_DELETED_IDS, DATA_REGISTRATIONS, DOMAIN, ERR_ENCRYPTION_REQUIRED, - ERR_RENDER_FAILURE, WEBHOOK_PAYLOAD_SCHEMA, - WEBHOOK_SCHEMAS, WEBHOOK_TYPE_CALL_SERVICE, - WEBHOOK_TYPE_FIRE_EVENT, WEBHOOK_TYPE_RENDER_TEMPLATE, - WEBHOOK_TYPE_UPDATE_LOCATION, + WEBHOOK_PAYLOAD_SCHEMA, WEBHOOK_SCHEMAS, + WEBHOOK_TYPE_CALL_SERVICE, WEBHOOK_TYPE_FIRE_EVENT, + WEBHOOK_TYPE_RENDER_TEMPLATE, WEBHOOK_TYPE_UPDATE_LOCATION, WEBHOOK_TYPE_UPDATE_REGISTRATION) from .helpers import (_decrypt_payload, empty_okay_response, error_response, @@ -132,17 +131,18 @@ async def handle_webhook(store: Store, hass: HomeAssistantType, return empty_okay_response(headers=headers) if webhook_type == WEBHOOK_TYPE_RENDER_TEMPLATE: - try: - tpl = template.Template(data[ATTR_TEMPLATE], hass) - rendered = tpl.async_render(data.get(ATTR_TEMPLATE_VARIABLES)) - return webhook_response({"rendered": rendered}, - registration=registration, headers=headers) - # noqa: E722 pylint: disable=broad-except - except (ValueError, TemplateError, Exception) as ex: - _LOGGER.error("Error when rendering template during mobile_app " - "webhook (device name: %s): %s", - registration[ATTR_DEVICE_NAME], ex) - return error_response(ERR_RENDER_FAILURE, str(ex), headers=headers) + resp = {} + for key, item in data.items(): + try: + tpl = item[ATTR_TEMPLATE] + attach(hass, tpl) + resp[key] = tpl.async_render(item.get(ATTR_TEMPLATE_VARIABLES)) + # noqa: E722 pylint: disable=broad-except + except TemplateError as ex: + resp[key] = {"error": str(ex)} + + return webhook_response(resp, registration=registration, + headers=headers) if webhook_type == WEBHOOK_TYPE_UPDATE_LOCATION: try: diff --git a/tests/components/mobile_app/const.py b/tests/components/mobile_app/const.py index 919a2a6e1fb..6dfe050191b 100644 --- a/tests/components/mobile_app/const.py +++ b/tests/components/mobile_app/const.py @@ -49,7 +49,9 @@ REGISTER_CLEARTEXT = { RENDER_TEMPLATE = { 'type': 'render_template', 'data': { - 'template': 'Hello world' + 'one': { + 'template': 'Hello world' + } } } diff --git a/tests/components/mobile_app/test_http_api.py b/tests/components/mobile_app/test_http_api.py index 195d33e15b2..7861e63459a 100644 --- a/tests/components/mobile_app/test_http_api.py +++ b/tests/components/mobile_app/test_http_api.py @@ -5,7 +5,7 @@ import pytest from homeassistant.components.mobile_app.const import CONF_SECRET from homeassistant.const import CONF_WEBHOOK_ID -from .const import REGISTER +from .const import REGISTER, RENDER_TEMPLATE from . import authed_api_client # noqa: F401 @@ -35,7 +35,7 @@ async def test_registration(hass_client, authed_api_client): # noqa: F811 key = key[:keylen] key = key.ljust(keylen, b'\0') - payload = json.dumps({'template': 'Hello world'}).encode("utf-8") + payload = json.dumps(RENDER_TEMPLATE['data']).encode("utf-8") data = SecretBox(key).encrypt(payload, encoder=Base64Encoder).decode("utf-8") @@ -62,7 +62,7 @@ async def test_registration(hass_client, authed_api_client): # noqa: F811 encoder=Base64Encoder) decrypted_data = decrypted_data.decode("utf-8") - assert json.loads(decrypted_data) == {'rendered': 'Hello world'} + assert json.loads(decrypted_data) == {'one': 'Hello world'} async def test_register_invalid_component(authed_api_client): # noqa: F811 diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index bbdfcde93e7..75e8903c494 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -24,7 +24,7 @@ async def test_webhook_handle_render_template(webhook_client): # noqa: F811 assert resp.status == 200 json = await resp.json() - assert json == {'rendered': 'Hello world'} + assert json == {'one': 'Hello world'} async def test_webhook_handle_call_services(hass, webhook_client): # noqa: E501 F811 @@ -123,7 +123,7 @@ async def test_webhook_handle_decryption(webhook_client): # noqa: F811 key = key[:keylen] key = key.ljust(keylen, b'\0') - payload = json.dumps({'template': 'Hello world'}).encode("utf-8") + payload = json.dumps(RENDER_TEMPLATE['data']).encode("utf-8") data = SecretBox(key).encrypt(payload, encoder=Base64Encoder).decode("utf-8") @@ -148,7 +148,7 @@ async def test_webhook_handle_decryption(webhook_client): # noqa: F811 encoder=Base64Encoder) decrypted_data = decrypted_data.decode("utf-8") - assert json.loads(decrypted_data) == {'rendered': 'Hello world'} + assert json.loads(decrypted_data) == {'one': 'Hello world'} async def test_webhook_requires_encryption(webhook_client): # noqa: F811 From 3fd1e8d38285b8ca491e6982576ae1eefc8ddaca Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 13 Mar 2019 15:38:53 -0700 Subject: [PATCH 252/291] Mobile App: Update Location schema updates & device ID generation (#21849) * Update location schema * Generate a random device ID at registration time for later use with device_tracker.see * Remove host name from device_tracker.see payload * Drop consider_home from the payload * Remove stale consider_home in schema * Remove source_type --- homeassistant/components/mobile_app/const.py | 24 ++++++++++- .../components/mobile_app/http_api.py | 12 ++++-- .../components/mobile_app/webhook.py | 42 ++++++++++++++----- 3 files changed, 61 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 3f1a6bc988c..7a497d76454 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -1,7 +1,10 @@ """Constants for mobile_app.""" import voluptuous as vol -from homeassistant.components.device_tracker import SERVICE_SEE_PAYLOAD_SCHEMA +from homeassistant.components.device_tracker import (ATTR_BATTERY, + ATTR_GPS, + ATTR_GPS_ACCURACY, + ATTR_LOCATION_NAME) from homeassistant.const import (ATTR_DOMAIN, ATTR_SERVICE, ATTR_SERVICE_DATA) from homeassistant.helpers import config_validation as cv @@ -23,6 +26,7 @@ ATTR_APP_DATA = 'app_data' ATTR_APP_ID = 'app_id' ATTR_APP_NAME = 'app_name' ATTR_APP_VERSION = 'app_version' +ATTR_DEVICE_ID = 'device_id' ATTR_DEVICE_NAME = 'device_name' ATTR_MANUFACTURER = 'manufacturer' ATTR_MODEL = 'model' @@ -36,6 +40,11 @@ ATTR_EVENT_TYPE = 'event_type' ATTR_TEMPLATE = 'template' ATTR_TEMPLATE_VARIABLES = 'variables' +ATTR_SPEED = 'speed' +ATTR_ALTITUDE = 'altitude' +ATTR_COURSE = 'course' +ATTR_VERTICAL_ACCURACY = 'vertical_accuracy' + ATTR_WEBHOOK_DATA = 'data' ATTR_WEBHOOK_ENCRYPTED = 'encrypted' ATTR_WEBHOOK_ENCRYPTED_DATA = 'encrypted_data' @@ -104,10 +113,21 @@ RENDER_TEMPLATE_SCHEMA = vol.Schema({ } }) +UPDATE_LOCATION_SCHEMA = vol.Schema({ + vol.Optional(ATTR_LOCATION_NAME): cv.string, + vol.Required(ATTR_GPS): cv.gps, + vol.Required(ATTR_GPS_ACCURACY): cv.positive_int, + vol.Optional(ATTR_BATTERY): cv.positive_int, + vol.Optional(ATTR_SPEED): cv.positive_int, + vol.Optional(ATTR_ALTITUDE): cv.positive_int, + vol.Optional(ATTR_COURSE): cv.positive_int, + vol.Optional(ATTR_VERTICAL_ACCURACY): cv.positive_int, +}) + WEBHOOK_SCHEMAS = { WEBHOOK_TYPE_CALL_SERVICE: CALL_SERVICE_SCHEMA, WEBHOOK_TYPE_FIRE_EVENT: FIRE_EVENT_SCHEMA, WEBHOOK_TYPE_RENDER_TEMPLATE: RENDER_TEMPLATE_SCHEMA, - WEBHOOK_TYPE_UPDATE_LOCATION: SERVICE_SEE_PAYLOAD_SCHEMA, + WEBHOOK_TYPE_UPDATE_LOCATION: UPDATE_LOCATION_SCHEMA, WEBHOOK_TYPE_UPDATE_REGISTRATION: UPDATE_REGISTRATION_SCHEMA, } diff --git a/homeassistant/components/mobile_app/http_api.py b/homeassistant/components/mobile_app/http_api.py index 30083cc86b1..4948407b63b 100644 --- a/homeassistant/components/mobile_app/http_api.py +++ b/homeassistant/components/mobile_app/http_api.py @@ -1,4 +1,5 @@ """Provides an HTTP API for mobile_app.""" +import uuid from typing import Dict from aiohttp.web import Response, Request @@ -15,10 +16,11 @@ from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import HomeAssistantType from homeassistant.loader import get_component -from .const import (ATTR_APP_COMPONENT, ATTR_SUPPORTS_ENCRYPTION, - CONF_CLOUDHOOK_URL, CONF_SECRET, CONF_USER_ID, - DATA_REGISTRATIONS, DOMAIN, ERR_INVALID_COMPONENT, - ERR_SAVE_FAILURE, REGISTRATION_SCHEMA) +from .const import (ATTR_APP_COMPONENT, ATTR_DEVICE_ID, + ATTR_SUPPORTS_ENCRYPTION, CONF_CLOUDHOOK_URL, CONF_SECRET, + CONF_USER_ID, DATA_REGISTRATIONS, DOMAIN, + ERR_INVALID_COMPONENT, ERR_SAVE_FAILURE, + REGISTRATION_SCHEMA) from .helpers import error_response, supports_encryption, savable_state @@ -66,6 +68,8 @@ class RegistrationsView(HomeAssistantView): data[CONF_CLOUDHOOK_URL] = \ await async_create_cloudhook(hass, webhook_id) + data[ATTR_DEVICE_ID] = str(uuid.uuid4()).replace("-", "") + data[CONF_WEBHOOK_ID] = webhook_id if data[ATTR_SUPPORTS_ENCRYPTION] and supports_encryption(): diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index e8372c8648d..4d3e0aef4c6 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -6,7 +6,9 @@ from typing import Dict from aiohttp.web import HTTPBadRequest, Response, Request import voluptuous as vol -from homeassistant.components.device_tracker import (DOMAIN as DT_DOMAIN, +from homeassistant.components.device_tracker import (ATTR_ATTRIBUTES, + ATTR_DEV_ID, + DOMAIN as DT_DOMAIN, SERVICE_SEE as DT_SEE) from homeassistant.components.webhook import async_register as webhook_register @@ -20,15 +22,19 @@ from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import HomeAssistantType -from .const import (ATTR_APP_COMPONENT, ATTR_DEVICE_NAME, ATTR_EVENT_DATA, - ATTR_EVENT_TYPE, ATTR_SUPPORTS_ENCRYPTION, ATTR_TEMPLATE, - ATTR_TEMPLATE_VARIABLES, ATTR_WEBHOOK_DATA, - ATTR_WEBHOOK_ENCRYPTED, ATTR_WEBHOOK_ENCRYPTED_DATA, - ATTR_WEBHOOK_TYPE, CONF_SECRET, DATA_DELETED_IDS, - DATA_REGISTRATIONS, DOMAIN, ERR_ENCRYPTION_REQUIRED, - WEBHOOK_PAYLOAD_SCHEMA, WEBHOOK_SCHEMAS, - WEBHOOK_TYPE_CALL_SERVICE, WEBHOOK_TYPE_FIRE_EVENT, - WEBHOOK_TYPE_RENDER_TEMPLATE, WEBHOOK_TYPE_UPDATE_LOCATION, +from .const import (ATTR_ALTITUDE, ATTR_APP_COMPONENT, ATTR_BATTERY, + ATTR_COURSE, ATTR_DEVICE_ID, ATTR_DEVICE_NAME, + ATTR_EVENT_DATA, ATTR_EVENT_TYPE, ATTR_GPS, + ATTR_GPS_ACCURACY, ATTR_LOCATION_NAME, ATTR_SPEED, + ATTR_SUPPORTS_ENCRYPTION, ATTR_TEMPLATE, + ATTR_TEMPLATE_VARIABLES, ATTR_VERTICAL_ACCURACY, + ATTR_WEBHOOK_DATA, ATTR_WEBHOOK_ENCRYPTED, + ATTR_WEBHOOK_ENCRYPTED_DATA, ATTR_WEBHOOK_TYPE, + CONF_SECRET, DATA_DELETED_IDS, DATA_REGISTRATIONS, DOMAIN, + ERR_ENCRYPTION_REQUIRED, WEBHOOK_PAYLOAD_SCHEMA, + WEBHOOK_SCHEMAS, WEBHOOK_TYPE_CALL_SERVICE, + WEBHOOK_TYPE_FIRE_EVENT, WEBHOOK_TYPE_RENDER_TEMPLATE, + WEBHOOK_TYPE_UPDATE_LOCATION, WEBHOOK_TYPE_UPDATE_REGISTRATION) from .helpers import (_decrypt_payload, empty_okay_response, error_response, @@ -145,9 +151,23 @@ async def handle_webhook(store: Store, hass: HomeAssistantType, headers=headers) if webhook_type == WEBHOOK_TYPE_UPDATE_LOCATION: + see_payload = { + ATTR_DEV_ID: registration[ATTR_DEVICE_ID], + ATTR_LOCATION_NAME: data.get(ATTR_LOCATION_NAME), + ATTR_GPS: data.get(ATTR_GPS), + ATTR_GPS_ACCURACY: data.get(ATTR_GPS_ACCURACY), + ATTR_BATTERY: data.get(ATTR_BATTERY), + ATTR_ATTRIBUTES: { + ATTR_SPEED: data.get(ATTR_SPEED), + ATTR_ALTITUDE: data.get(ATTR_ALTITUDE), + ATTR_COURSE: data.get(ATTR_COURSE), + ATTR_VERTICAL_ACCURACY: data.get(ATTR_VERTICAL_ACCURACY), + } + } + try: await hass.services.async_call(DT_DOMAIN, - DT_SEE, data, + DT_SEE, see_payload, blocking=True, context=context) # noqa: E722 pylint: disable=broad-except except (vol.Invalid, ServiceNotFound, Exception) as ex: From f7dcfe28b680db4b60cc36ba50b2c7c94f8648f9 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Thu, 14 Mar 2019 12:57:50 -0700 Subject: [PATCH 253/291] Mobile App: Register devices into the registry (#21856) * Register devices into the registry * Switch to device ID instead of webhook ID * Rearchitect mobile_app to support config entries * Kill DATA_REGISTRATIONS by migrating registrations into config entries * Fix tests * Improve how we get the config_entry_id * Remove single_instance_allowed * Simplify setup_registration * Move webhook registering functions into __init__.py since they are only ever used once * Kill get_registration websocket command * Support description_placeholders in async_abort * Add link to mobile_app implementing apps in abort dialog * Store config entry and device registry entry in hass.data instead of looking it up * Add testing to ensure that the config entry is created at registration * Fix busted async_abort test * Remove unnecessary check for entry is None --- .../mobile_app/.translations/en.json | 14 +++ .../components/mobile_app/__init__.py | 103 +++++++++++++++--- homeassistant/components/mobile_app/const.py | 5 +- .../components/mobile_app/helpers.py | 4 +- .../components/mobile_app/http_api.py | 37 ++----- .../components/mobile_app/strings.json | 14 +++ .../components/mobile_app/webhook.py | 73 +++++-------- .../components/mobile_app/websocket_api.py | 48 ++------ homeassistant/config_entries.py | 1 + homeassistant/data_entry_flow.py | 6 +- .../components/config/test_config_entries.py | 1 + tests/components/mobile_app/__init__.py | 65 ++++++----- tests/components/mobile_app/test_http_api.py | 29 ++++- tests/components/mobile_app/test_webhook.py | 39 ++++--- .../mobile_app/test_websocket_api.py | 27 ----- 15 files changed, 254 insertions(+), 212 deletions(-) create mode 100644 homeassistant/components/mobile_app/.translations/en.json create mode 100644 homeassistant/components/mobile_app/strings.json diff --git a/homeassistant/components/mobile_app/.translations/en.json b/homeassistant/components/mobile_app/.translations/en.json new file mode 100644 index 00000000000..646151a5229 --- /dev/null +++ b/homeassistant/components/mobile_app/.translations/en.json @@ -0,0 +1,14 @@ +{ + "config": { + "title": "Mobile App", + "step": { + "confirm": { + "title": "Mobile App", + "description": "Do you want to set up the Mobile App component?" + } + }, + "abort": { + "install_app": "Open the mobile app to set up the integration with Home Assistant. See [the docs]({apps_url}) for a list of compatible apps." + } + } +} diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 0d95bfe6832..1c348ea0782 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -1,11 +1,18 @@ """Integrates Native Apps to Home Assistant.""" +from homeassistant import config_entries +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.components.webhook import async_register as webhook_register +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType, HomeAssistantType -from .const import (DATA_DELETED_IDS, DATA_REGISTRATIONS, DATA_STORE, DOMAIN, - STORAGE_KEY, STORAGE_VERSION) +from .const import (ATTR_APP_COMPONENT, ATTR_DEVICE_ID, ATTR_DEVICE_NAME, + ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, + DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, DATA_DEVICES, + DATA_STORE, DOMAIN, STORAGE_KEY, STORAGE_VERSION) -from .http_api import register_http_handlers -from .webhook import register_deleted_webhooks, setup_registration +from .http_api import RegistrationsView +from .webhook import handle_webhook from .websocket_api import register_websocket_handlers DEPENDENCIES = ['device_tracker', 'http', 'webhook'] @@ -15,24 +22,88 @@ REQUIREMENTS = ['PyNaCl==1.3.0'] async def async_setup(hass: HomeAssistantType, config: ConfigType): """Set up the mobile app component.""" + hass.data[DOMAIN] = { + DATA_CONFIG_ENTRIES: {}, DATA_DELETED_IDS: [], DATA_DEVICES: {}, + } + store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) app_config = await store.async_load() if app_config is None: - app_config = {DATA_DELETED_IDS: [], DATA_REGISTRATIONS: {}} + app_config = { + DATA_CONFIG_ENTRIES: {}, DATA_DELETED_IDS: [], DATA_DEVICES: {}, + } - if hass.data.get(DOMAIN) is None: - hass.data[DOMAIN] = {DATA_DELETED_IDS: [], DATA_REGISTRATIONS: {}} - - hass.data[DOMAIN][DATA_DELETED_IDS] = app_config.get(DATA_DELETED_IDS, []) - hass.data[DOMAIN][DATA_REGISTRATIONS] = app_config.get(DATA_REGISTRATIONS, - {}) + hass.data[DOMAIN] = app_config hass.data[DOMAIN][DATA_STORE] = store - for registration in app_config[DATA_REGISTRATIONS].values(): - setup_registration(hass, store, registration) - - register_http_handlers(hass, store) + hass.http.register_view(RegistrationsView()) register_websocket_handlers(hass) - register_deleted_webhooks(hass, store) + + for deleted_id in hass.data[DOMAIN][DATA_DELETED_IDS]: + try: + webhook_register(hass, DOMAIN, "Deleted Webhook", deleted_id, + handle_webhook) + except ValueError: + pass return True + + +async def async_setup_entry(hass, entry): + """Set up a mobile_app entry.""" + registration = entry.data + + webhook_id = registration[CONF_WEBHOOK_ID] + + hass.data[DOMAIN][DATA_CONFIG_ENTRIES][webhook_id] = entry + + device_registry = await dr.async_get_registry(hass) + + identifiers = { + (ATTR_DEVICE_ID, registration[ATTR_DEVICE_ID]), + (CONF_WEBHOOK_ID, registration[CONF_WEBHOOK_ID]) + } + + device = device_registry.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers=identifiers, + manufacturer=registration[ATTR_MANUFACTURER], + model=registration[ATTR_MODEL], + name=registration[ATTR_DEVICE_NAME], + sw_version=registration[ATTR_OS_VERSION] + ) + + hass.data[DOMAIN][DATA_DEVICES][webhook_id] = device + + registration_name = 'Mobile App: {}'.format(registration[ATTR_DEVICE_NAME]) + webhook_register(hass, DOMAIN, registration_name, webhook_id, + handle_webhook) + + if ATTR_APP_COMPONENT in registration: + load_platform(hass, registration[ATTR_APP_COMPONENT], DOMAIN, {}, + {DOMAIN: {}}) + + return True + + +@config_entries.HANDLERS.register(DOMAIN) +class MobileAppFlowHandler(config_entries.ConfigFlow): + """Handle a Mobile App config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH + + async def async_step_user(self, user_input=None): + """Handle a flow initialized by the user.""" + placeholders = { + 'apps_url': + 'https://www.home-assistant.io/components/mobile_app/#apps' + } + + return self.async_abort(reason='install_app', + description_placeholders=placeholders) + + async def async_step_registration(self, user_input=None): + """Handle a flow initialized during registration.""" + return self.async_create_entry(title=user_input[ATTR_DEVICE_NAME], + data=user_input) diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 7a497d76454..3ba029fec0e 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -17,8 +17,9 @@ CONF_CLOUDHOOK_URL = 'cloudhook_url' CONF_SECRET = 'secret' CONF_USER_ID = 'user_id' +DATA_CONFIG_ENTRIES = 'config_entries' DATA_DELETED_IDS = 'deleted_ids' -DATA_REGISTRATIONS = 'registrations' +DATA_DEVICES = 'devices' DATA_STORE = 'store' ATTR_APP_COMPONENT = 'app_component' @@ -26,6 +27,7 @@ ATTR_APP_DATA = 'app_data' ATTR_APP_ID = 'app_id' ATTR_APP_NAME = 'app_name' ATTR_APP_VERSION = 'app_version' +ATTR_CONFIG_ENTRY_ID = 'entry_id' ATTR_DEVICE_ID = 'device_id' ATTR_DEVICE_NAME = 'device_name' ATTR_MANUFACTURER = 'manufacturer' @@ -52,7 +54,6 @@ ATTR_WEBHOOK_TYPE = 'type' ERR_ENCRYPTION_REQUIRED = 'encryption_required' ERR_INVALID_COMPONENT = 'invalid_component' -ERR_SAVE_FAILURE = 'save_failure' WEBHOOK_TYPE_CALL_SERVICE = 'call_service' WEBHOOK_TYPE_FIRE_EVENT = 'fire_event' diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py index 1f67170a72c..5ec3b99b291 100644 --- a/homeassistant/components/mobile_app/helpers.py +++ b/homeassistant/components/mobile_app/helpers.py @@ -11,8 +11,7 @@ from homeassistant.helpers.typing import HomeAssistantType from .const import (ATTR_APP_DATA, ATTR_APP_ID, ATTR_APP_NAME, ATTR_APP_VERSION, ATTR_DEVICE_NAME, ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, ATTR_SUPPORTS_ENCRYPTION, - CONF_SECRET, CONF_USER_ID, DATA_DELETED_IDS, - DATA_REGISTRATIONS, DOMAIN) + CONF_SECRET, CONF_USER_ID, DATA_DELETED_IDS, DOMAIN) _LOGGER = logging.getLogger(__name__) @@ -125,7 +124,6 @@ def savable_state(hass: HomeAssistantType) -> Dict: """Return a clean object containing things that should be saved.""" return { DATA_DELETED_IDS: hass.data[DOMAIN][DATA_DELETED_IDS], - DATA_REGISTRATIONS: hass.data[DOMAIN][DATA_REGISTRATIONS] } diff --git a/homeassistant/components/mobile_app/http_api.py b/homeassistant/components/mobile_app/http_api.py index 4948407b63b..8076d217cac 100644 --- a/homeassistant/components/mobile_app/http_api.py +++ b/homeassistant/components/mobile_app/http_api.py @@ -8,29 +8,16 @@ from homeassistant.auth.util import generate_secret from homeassistant.components.cloud import async_create_cloudhook from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator -from homeassistant.const import (HTTP_CREATED, HTTP_INTERNAL_SERVER_ERROR, - CONF_WEBHOOK_ID) +from homeassistant.const import (HTTP_CREATED, CONF_WEBHOOK_ID) -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.storage import Store -from homeassistant.helpers.typing import HomeAssistantType from homeassistant.loader import get_component from .const import (ATTR_APP_COMPONENT, ATTR_DEVICE_ID, ATTR_SUPPORTS_ENCRYPTION, CONF_CLOUDHOOK_URL, CONF_SECRET, - CONF_USER_ID, DATA_REGISTRATIONS, DOMAIN, - ERR_INVALID_COMPONENT, ERR_SAVE_FAILURE, + CONF_USER_ID, DOMAIN, ERR_INVALID_COMPONENT, REGISTRATION_SCHEMA) -from .helpers import error_response, supports_encryption, savable_state - -from .webhook import setup_registration - - -def register_http_handlers(hass: HomeAssistantType, store: Store) -> bool: - """Register the HTTP handlers/views.""" - hass.http.register_view(RegistrationsView(store)) - return True +from .helpers import error_response, supports_encryption class RegistrationsView(HomeAssistantView): @@ -39,10 +26,6 @@ class RegistrationsView(HomeAssistantView): url = '/api/mobile_app/registrations' name = 'api:mobile_app:register' - def __init__(self, store: Store) -> None: - """Initialize the view.""" - self._store = store - @RequestDataValidator(REGISTRATION_SCHEMA) async def post(self, request: Request, data: Dict) -> Response: """Handle the POST request for registration.""" @@ -79,16 +62,10 @@ class RegistrationsView(HomeAssistantView): data[CONF_USER_ID] = request['hass_user'].id - hass.data[DOMAIN][DATA_REGISTRATIONS][webhook_id] = data - - try: - await self._store.async_save(savable_state(hass)) - except HomeAssistantError: - return error_response(ERR_SAVE_FAILURE, - "Error saving registration", - status=HTTP_INTERNAL_SERVER_ERROR) - - setup_registration(hass, self._store, data) + ctx = {'source': 'registration'} + await hass.async_create_task( + hass.config_entries.flow.async_init(DOMAIN, context=ctx, + data=data)) return self.json({ CONF_CLOUDHOOK_URL: data.get(CONF_CLOUDHOOK_URL), diff --git a/homeassistant/components/mobile_app/strings.json b/homeassistant/components/mobile_app/strings.json new file mode 100644 index 00000000000..646151a5229 --- /dev/null +++ b/homeassistant/components/mobile_app/strings.json @@ -0,0 +1,14 @@ +{ + "config": { + "title": "Mobile App", + "step": { + "confirm": { + "title": "Mobile App", + "description": "Do you want to set up the Mobile App component?" + } + }, + "abort": { + "install_app": "Open the mobile app to set up the integration with Home Assistant. See [the docs]({apps_url}) for a list of compatible apps." + } + } +} diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 4d3e0aef4c6..1fab29160b7 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -1,7 +1,5 @@ """Webhook handlers for mobile_app.""" -from functools import partial import logging -from typing import Dict from aiohttp.web import HTTPBadRequest, Response, Request import voluptuous as vol @@ -10,27 +8,24 @@ from homeassistant.components.device_tracker import (ATTR_ATTRIBUTES, ATTR_DEV_ID, DOMAIN as DT_DOMAIN, SERVICE_SEE as DT_SEE) -from homeassistant.components.webhook import async_register as webhook_register from homeassistant.const import (ATTR_DOMAIN, ATTR_SERVICE, ATTR_SERVICE_DATA, CONF_WEBHOOK_ID, HTTP_BAD_REQUEST) from homeassistant.core import EventOrigin -from homeassistant.exceptions import (HomeAssistantError, ServiceNotFound, - TemplateError) +from homeassistant.exceptions import (ServiceNotFound, TemplateError) +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.template import attach -from homeassistant.helpers.discovery import load_platform -from homeassistant.helpers.storage import Store from homeassistant.helpers.typing import HomeAssistantType -from .const import (ATTR_ALTITUDE, ATTR_APP_COMPONENT, ATTR_BATTERY, - ATTR_COURSE, ATTR_DEVICE_ID, ATTR_DEVICE_NAME, - ATTR_EVENT_DATA, ATTR_EVENT_TYPE, ATTR_GPS, - ATTR_GPS_ACCURACY, ATTR_LOCATION_NAME, ATTR_SPEED, +from .const import (ATTR_ALTITUDE, ATTR_BATTERY, ATTR_COURSE, ATTR_DEVICE_ID, + ATTR_DEVICE_NAME, ATTR_EVENT_DATA, ATTR_EVENT_TYPE, + ATTR_GPS, ATTR_GPS_ACCURACY, ATTR_LOCATION_NAME, + ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, ATTR_SPEED, ATTR_SUPPORTS_ENCRYPTION, ATTR_TEMPLATE, ATTR_TEMPLATE_VARIABLES, ATTR_VERTICAL_ACCURACY, ATTR_WEBHOOK_DATA, ATTR_WEBHOOK_ENCRYPTED, ATTR_WEBHOOK_ENCRYPTED_DATA, ATTR_WEBHOOK_TYPE, - CONF_SECRET, DATA_DELETED_IDS, DATA_REGISTRATIONS, DOMAIN, + CONF_SECRET, DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, DOMAIN, ERR_ENCRYPTION_REQUIRED, WEBHOOK_PAYLOAD_SCHEMA, WEBHOOK_SCHEMAS, WEBHOOK_TYPE_CALL_SERVICE, WEBHOOK_TYPE_FIRE_EVENT, WEBHOOK_TYPE_RENDER_TEMPLATE, @@ -38,45 +33,24 @@ from .const import (ATTR_ALTITUDE, ATTR_APP_COMPONENT, ATTR_BATTERY, WEBHOOK_TYPE_UPDATE_REGISTRATION) from .helpers import (_decrypt_payload, empty_okay_response, error_response, - registration_context, safe_registration, savable_state, + registration_context, safe_registration, webhook_response) _LOGGER = logging.getLogger(__name__) -def register_deleted_webhooks(hass: HomeAssistantType, store: Store): - """Register previously deleted webhook IDs so we can return 410.""" - for deleted_id in hass.data[DOMAIN][DATA_DELETED_IDS]: - try: - webhook_register(hass, DOMAIN, "Deleted Webhook", deleted_id, - partial(handle_webhook, store)) - except ValueError: - pass - - -def setup_registration(hass: HomeAssistantType, store: Store, - registration: Dict) -> None: - """Register the webhook for a registration and loads the app component.""" - registration_name = 'Mobile App: {}'.format(registration[ATTR_DEVICE_NAME]) - webhook_id = registration[CONF_WEBHOOK_ID] - webhook_register(hass, DOMAIN, registration_name, webhook_id, - partial(handle_webhook, store)) - - if ATTR_APP_COMPONENT in registration: - load_platform(hass, registration[ATTR_APP_COMPONENT], DOMAIN, {}, - {DOMAIN: {}}) - - -async def handle_webhook(store: Store, hass: HomeAssistantType, - webhook_id: str, request: Request) -> Response: +async def handle_webhook(hass: HomeAssistantType, webhook_id: str, + request: Request) -> Response: """Handle webhook callback.""" if webhook_id in hass.data[DOMAIN][DATA_DELETED_IDS]: return Response(status=410) headers = {} - registration = hass.data[DOMAIN][DATA_REGISTRATIONS][webhook_id] + config_entry = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][webhook_id] + + registration = config_entry.data try: req_data = await request.json() @@ -179,13 +153,22 @@ async def handle_webhook(store: Store, hass: HomeAssistantType, if webhook_type == WEBHOOK_TYPE_UPDATE_REGISTRATION: new_registration = {**registration, **data} - hass.data[DOMAIN][DATA_REGISTRATIONS][webhook_id] = new_registration + device_registry = await dr.async_get_registry(hass) - try: - await store.async_save(savable_state(hass)) - except HomeAssistantError as ex: - _LOGGER.error("Error updating mobile_app registration: %s", ex) - return empty_okay_response() + device_registry.async_get_or_create( + config_entry_id=config_entry.entry_id, + identifiers={ + (ATTR_DEVICE_ID, registration[ATTR_DEVICE_ID]), + (CONF_WEBHOOK_ID, registration[CONF_WEBHOOK_ID]) + }, + manufacturer=new_registration[ATTR_MANUFACTURER], + model=new_registration[ATTR_MODEL], + name=new_registration[ATTR_DEVICE_NAME], + sw_version=new_registration[ATTR_OS_VERSION] + ) + + hass.config_entries.async_update_entry(config_entry, + data=new_registration) return webhook_response(safe_registration(new_registration), registration=registration, headers=headers) diff --git a/homeassistant/components/mobile_app/websocket_api.py b/homeassistant/components/mobile_app/websocket_api.py index 5f6a25cbcec..7bc1e59d623 100644 --- a/homeassistant/components/mobile_app/websocket_api.py +++ b/homeassistant/components/mobile_app/websocket_api.py @@ -17,16 +17,14 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import HomeAssistantType -from .const import (CONF_CLOUDHOOK_URL, CONF_USER_ID, DATA_DELETED_IDS, - DATA_REGISTRATIONS, DATA_STORE, DOMAIN) +from .const import (CONF_CLOUDHOOK_URL, CONF_USER_ID, DATA_CONFIG_ENTRIES, + DATA_DELETED_IDS, DATA_STORE, DOMAIN) from .helpers import safe_registration, savable_state def register_websocket_handlers(hass: HomeAssistantType) -> bool: """Register the websocket handlers.""" - async_register_command(hass, websocket_get_registration) - async_register_command(hass, websocket_get_user_registrations) async_register_command(hass, websocket_delete_registration) @@ -34,39 +32,6 @@ def register_websocket_handlers(hass: HomeAssistantType) -> bool: return True -@ws_require_user() -@async_response -@websocket_command({ - vol.Required('type'): 'mobile_app/get_registration', - vol.Required(CONF_WEBHOOK_ID): cv.string, -}) -async def websocket_get_registration( - hass: HomeAssistantType, connection: ActiveConnection, - msg: dict) -> None: - """Return the registration for the given webhook_id.""" - user = connection.user - - webhook_id = msg.get(CONF_WEBHOOK_ID) - if webhook_id is None: - connection.send_error(msg['id'], ERR_INVALID_FORMAT, - "Webhook ID not provided") - return - - registration = hass.data[DOMAIN][DATA_REGISTRATIONS].get(webhook_id) - - if registration is None: - connection.send_error(msg['id'], ERR_NOT_FOUND, - "Webhook ID not found in storage") - return - - if registration[CONF_USER_ID] != user.id and not user.is_admin: - return error_message( - msg['id'], ERR_UNAUTHORIZED, 'User is not registration owner') - - connection.send_message( - result_message(msg['id'], safe_registration(registration))) - - @ws_require_user() @async_response @websocket_command({ @@ -87,7 +52,8 @@ async def websocket_get_user_registrations( user_registrations = [] - for registration in hass.data[DOMAIN][DATA_REGISTRATIONS].values(): + for config_entry in hass.config_entries.async_entries(domain=DOMAIN): + registration = config_entry.data if connection.user.is_admin or registration[CONF_USER_ID] is user_id: user_registrations.append(safe_registration(registration)) @@ -113,7 +79,9 @@ async def websocket_delete_registration(hass: HomeAssistantType, "Webhook ID not provided") return - registration = hass.data[DOMAIN][DATA_REGISTRATIONS].get(webhook_id) + config_entry = hass.data[DOMAIN][DATA_CONFIG_ENTRIES][webhook_id] + + registration = config_entry.data if registration is None: connection.send_error(msg['id'], ERR_NOT_FOUND, @@ -124,7 +92,7 @@ async def websocket_delete_registration(hass: HomeAssistantType, return error_message( msg['id'], ERR_UNAUTHORIZED, 'User is not registration owner') - del hass.data[DOMAIN][DATA_REGISTRATIONS][webhook_id] + await hass.config_entries.async_remove(config_entry.entry_id) hass.data[DOMAIN][DATA_DELETED_IDS].append(webhook_id) diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 1036c02fd0d..e00d7204a79 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -161,6 +161,7 @@ FLOWS = [ 'locative', 'luftdaten', 'mailgun', + 'mobile_app', 'mqtt', 'nest', 'openuv', diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 57265cf696d..acd0befda4e 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -170,11 +170,13 @@ class FlowHandler: } @callback - def async_abort(self, *, reason: str) -> Dict: + def async_abort(self, *, reason: str, + description_placeholders: Optional[Dict] = None) -> Dict: """Abort the config flow.""" return { 'type': RESULT_TYPE_ABORT, 'flow_id': self.flow_id, 'handler': self.handler, - 'reason': reason + 'reason': reason, + 'description_placeholders': description_placeholders, } diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index 87ed83d9a7e..d5e4331f7b9 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -226,6 +226,7 @@ def test_abort(hass, client): data = yield from resp.json() data.pop('flow_id') assert data == { + 'description_placeholders': None, 'handler': 'test', 'reason': 'bla', 'type': 'abort' diff --git a/tests/components/mobile_app/__init__.py b/tests/components/mobile_app/__init__.py index 1f91eb7e442..bed275a534d 100644 --- a/tests/components/mobile_app/__init__.py +++ b/tests/components/mobile_app/__init__.py @@ -2,48 +2,59 @@ # pylint: disable=redefined-outer-name,unused-import import pytest +from tests.common import mock_device_registry + from homeassistant.setup import async_setup_component -from homeassistant.components.mobile_app.const import (DATA_DELETED_IDS, - DATA_REGISTRATIONS, - CONF_SECRET, - CONF_USER_ID, DOMAIN, +from homeassistant.components.mobile_app.const import (DATA_CONFIG_ENTRIES, + DATA_DELETED_IDS, + DATA_DEVICES, + DOMAIN, STORAGE_KEY, STORAGE_VERSION) -from homeassistant.const import CONF_WEBHOOK_ID + +from .const import REGISTER, REGISTER_CLEARTEXT @pytest.fixture -def webhook_client(hass, aiohttp_client, hass_storage, hass_admin_user): +def registry(hass): + """Return a configured device registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +async def create_registrations(authed_api_client): + """Return two new registrations.""" + enc_reg = await authed_api_client.post( + '/api/mobile_app/registrations', json=REGISTER + ) + + assert enc_reg.status == 201 + enc_reg_json = await enc_reg.json() + + clear_reg = await authed_api_client.post( + '/api/mobile_app/registrations', json=REGISTER_CLEARTEXT + ) + + assert clear_reg.status == 201 + clear_reg_json = await clear_reg.json() + + return (enc_reg_json, clear_reg_json) + + +@pytest.fixture +async def webhook_client(hass, aiohttp_client, hass_storage, hass_admin_user): """mobile_app mock client.""" hass_storage[STORAGE_KEY] = { 'version': STORAGE_VERSION, 'data': { - DATA_REGISTRATIONS: { - 'mobile_app_test': { - CONF_SECRET: '58eb127991594dad934d1584bdee5f27', - 'supports_encryption': True, - CONF_WEBHOOK_ID: 'mobile_app_test', - 'device_name': 'Test Device', - CONF_USER_ID: hass_admin_user.id, - }, - 'mobile_app_test_cleartext': { - 'supports_encryption': False, - CONF_WEBHOOK_ID: 'mobile_app_test_cleartext', - 'device_name': 'Test Device (Cleartext)', - CONF_USER_ID: hass_admin_user.id, - } - }, - DATA_DELETED_IDS: [], + DATA_CONFIG_ENTRIES: {}, DATA_DELETED_IDS: [], DATA_DEVICES: {}, } } - assert hass.loop.run_until_complete(async_setup_component( - hass, DOMAIN, { - DOMAIN: {} - })) + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) - return hass.loop.run_until_complete(aiohttp_client(hass.http.app)) + return await aiohttp_client(hass.http.app) @pytest.fixture diff --git a/tests/components/mobile_app/test_http_api.py b/tests/components/mobile_app/test_http_api.py index 7861e63459a..eb9d1f54d93 100644 --- a/tests/components/mobile_app/test_http_api.py +++ b/tests/components/mobile_app/test_http_api.py @@ -2,14 +2,15 @@ # pylint: disable=redefined-outer-name,unused-import import pytest -from homeassistant.components.mobile_app.const import CONF_SECRET +from homeassistant.components.mobile_app.const import CONF_SECRET, DOMAIN from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.setup import async_setup_component from .const import REGISTER, RENDER_TEMPLATE from . import authed_api_client # noqa: F401 -async def test_registration(hass_client, authed_api_client): # noqa: F811 +async def test_registration(hass, hass_client): # noqa: F811 """Test that registrations happen.""" try: # pylint: disable=unused-import @@ -21,7 +22,11 @@ async def test_registration(hass_client, authed_api_client): # noqa: F811 import json - resp = await authed_api_client.post( + await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + api_client = await hass_client() + + resp = await api_client.post( '/api/mobile_app/registrations', json=REGISTER ) @@ -30,6 +35,20 @@ async def test_registration(hass_client, authed_api_client): # noqa: F811 assert CONF_WEBHOOK_ID in register_json assert CONF_SECRET in register_json + entries = hass.config_entries.async_entries(DOMAIN) + + assert entries[0].data['app_data'] == REGISTER['app_data'] + assert entries[0].data['app_id'] == REGISTER['app_id'] + assert entries[0].data['app_name'] == REGISTER['app_name'] + assert entries[0].data['app_version'] == REGISTER['app_version'] + assert entries[0].data['device_name'] == REGISTER['device_name'] + assert entries[0].data['manufacturer'] == REGISTER['manufacturer'] + assert entries[0].data['model'] == REGISTER['model'] + assert entries[0].data['os_name'] == REGISTER['os_name'] + assert entries[0].data['os_version'] == REGISTER['os_version'] + assert entries[0].data['supports_encryption'] == \ + REGISTER['supports_encryption'] + keylen = SecretBox.KEY_SIZE key = register_json[CONF_SECRET].encode("utf-8") key = key[:keylen] @@ -46,9 +65,7 @@ async def test_registration(hass_client, authed_api_client): # noqa: F811 'encrypted_data': data, } - webhook_client = await hass_client() - - resp = await webhook_client.post( + resp = await api_client.post( '/api/webhook/{}'.format(register_json[CONF_WEBHOOK_ID]), json=container ) diff --git a/tests/components/mobile_app/test_webhook.py b/tests/components/mobile_app/test_webhook.py index 75e8903c494..a70e8ba1275 100644 --- a/tests/components/mobile_app/test_webhook.py +++ b/tests/components/mobile_app/test_webhook.py @@ -1,5 +1,6 @@ """Webhook tests for mobile_app.""" # pylint: disable=redefined-outer-name,unused-import +import logging import pytest from homeassistant.components.mobile_app.const import CONF_SECRET @@ -8,16 +9,20 @@ from homeassistant.core import callback from tests.common import async_mock_service -from . import authed_api_client, webhook_client # noqa: F401 +from . import (authed_api_client, create_registrations, # noqa: F401 + webhook_client) # noqa: F401 from .const import (CALL_SERVICE, FIRE_EVENT, REGISTER_CLEARTEXT, RENDER_TEMPLATE, UPDATE) +_LOGGER = logging.getLogger(__name__) -async def test_webhook_handle_render_template(webhook_client): # noqa: F811 + +async def test_webhook_handle_render_template(create_registrations, # noqa: F401, F811, E501 + webhook_client): # noqa: F811 """Test that we render templates properly.""" resp = await webhook_client.post( - '/api/webhook/mobile_app_test_cleartext', + '/api/webhook/{}'.format(create_registrations[1]['webhook_id']), json=RENDER_TEMPLATE ) @@ -27,12 +32,13 @@ async def test_webhook_handle_render_template(webhook_client): # noqa: F811 assert json == {'one': 'Hello world'} -async def test_webhook_handle_call_services(hass, webhook_client): # noqa: E501 F811 +async def test_webhook_handle_call_services(hass, create_registrations, # noqa: F401, F811, E501 + webhook_client): # noqa: E501 F811 """Test that we call services properly.""" calls = async_mock_service(hass, 'test', 'mobile_app') resp = await webhook_client.post( - '/api/webhook/mobile_app_test_cleartext', + '/api/webhook/{}'.format(create_registrations[1]['webhook_id']), json=CALL_SERVICE ) @@ -41,7 +47,8 @@ async def test_webhook_handle_call_services(hass, webhook_client): # noqa: E501 assert len(calls) == 1 -async def test_webhook_handle_fire_event(hass, webhook_client): # noqa: F811 +async def test_webhook_handle_fire_event(hass, create_registrations, # noqa: F401, F811, E501 + webhook_client): # noqa: F811 """Test that we can fire events.""" events = [] @@ -53,7 +60,7 @@ async def test_webhook_handle_fire_event(hass, webhook_client): # noqa: F811 hass.bus.async_listen('test_event', store_event) resp = await webhook_client.post( - '/api/webhook/mobile_app_test_cleartext', + '/api/webhook/{}'.format(create_registrations[1]['webhook_id']), json=FIRE_EVENT ) @@ -93,10 +100,12 @@ async def test_webhook_update_registration(webhook_client, hass_client): # noqa assert CONF_SECRET not in update_json -async def test_webhook_returns_error_incorrect_json(webhook_client, caplog): # noqa: E501 F811 +async def test_webhook_returns_error_incorrect_json(webhook_client, # noqa: F401, F811, E501 + create_registrations, # noqa: F401, F811, E501 + caplog): # noqa: E501 F811 """Test that an error is returned when JSON is invalid.""" resp = await webhook_client.post( - '/api/webhook/mobile_app_test_cleartext', + '/api/webhook/{}'.format(create_registrations[1]['webhook_id']), data='not json' ) @@ -106,7 +115,8 @@ async def test_webhook_returns_error_incorrect_json(webhook_client, caplog): # assert 'invalid JSON' in caplog.text -async def test_webhook_handle_decryption(webhook_client): # noqa: F811 +async def test_webhook_handle_decryption(webhook_client, # noqa: F811 + create_registrations): # noqa: F401, F811, E501 """Test that we can encrypt/decrypt properly.""" try: # pylint: disable=unused-import @@ -119,7 +129,7 @@ async def test_webhook_handle_decryption(webhook_client): # noqa: F811 import json keylen = SecretBox.KEY_SIZE - key = "58eb127991594dad934d1584bdee5f27".encode("utf-8") + key = create_registrations[0]['secret'].encode("utf-8") key = key[:keylen] key = key.ljust(keylen, b'\0') @@ -135,7 +145,7 @@ async def test_webhook_handle_decryption(webhook_client): # noqa: F811 } resp = await webhook_client.post( - '/api/webhook/mobile_app_test', + '/api/webhook/{}'.format(create_registrations[0]['webhook_id']), json=container ) @@ -151,10 +161,11 @@ async def test_webhook_handle_decryption(webhook_client): # noqa: F811 assert json.loads(decrypted_data) == {'one': 'Hello world'} -async def test_webhook_requires_encryption(webhook_client): # noqa: F811 +async def test_webhook_requires_encryption(webhook_client, # noqa: F811 + create_registrations): # noqa: F401, F811, E501 """Test that encrypted registrations only accept encrypted data.""" resp = await webhook_client.post( - '/api/webhook/mobile_app_test', + '/api/webhook/{}'.format(create_registrations[0]['webhook_id']), json=RENDER_TEMPLATE ) diff --git a/tests/components/mobile_app/test_websocket_api.py b/tests/components/mobile_app/test_websocket_api.py index 614fd33974b..ee656159d2e 100644 --- a/tests/components/mobile_app/test_websocket_api.py +++ b/tests/components/mobile_app/test_websocket_api.py @@ -9,33 +9,6 @@ from . import authed_api_client, setup_ws, webhook_client # noqa: F401 from .const import (CALL_SERVICE, REGISTER) -async def test_webocket_get_registration(hass, setup_ws, authed_api_client, # noqa: E501 F811 - hass_ws_client): - """Test get_registration websocket command.""" - register_resp = await authed_api_client.post( - '/api/mobile_app/registrations', json=REGISTER - ) - - assert register_resp.status == 201 - register_json = await register_resp.json() - assert CONF_WEBHOOK_ID in register_json - assert CONF_SECRET in register_json - - client = await hass_ws_client(hass) - await client.send_json({ - 'id': 5, - 'type': 'mobile_app/get_registration', - CONF_WEBHOOK_ID: register_json[CONF_WEBHOOK_ID], - }) - - msg = await client.receive_json() - - assert msg['id'] == 5 - assert msg['type'] == TYPE_RESULT - assert msg['success'] - assert msg['result']['app_id'] == 'io.homeassistant.mobile_app_test' - - async def test_webocket_get_user_registrations(hass, aiohttp_client, hass_ws_client, hass_read_only_access_token): From f0b7d76e269b4492dd957bd0a82c6d5893d2ac3a Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Thu, 14 Mar 2019 17:24:53 -0700 Subject: [PATCH 254/291] Mobile App: Sensors (#21854) ## Description: **Related issue (if applicable):** fixes #21782 ## Checklist: - [x] The code change is tested and works locally. - [x] Local tests pass with `tox`. **Your PR cannot be merged unless tests pass** - [x] There is no commented out code in this PR. --- .../components/mobile_app/__init__.py | 36 +++-- .../components/mobile_app/binary_sensor.py | 54 +++++++ homeassistant/components/mobile_app/const.py | 56 ++++++- homeassistant/components/mobile_app/entity.py | 98 +++++++++++++ .../components/mobile_app/helpers.py | 5 +- homeassistant/components/mobile_app/sensor.py | 58 ++++++++ .../components/mobile_app/webhook.py | 107 ++++++++++++-- tests/components/mobile_app/__init__.py | 8 +- tests/components/mobile_app/test_entity.py | 137 ++++++++++++++++++ 9 files changed, 529 insertions(+), 30 deletions(-) create mode 100644 homeassistant/components/mobile_app/binary_sensor.py create mode 100644 homeassistant/components/mobile_app/entity.py create mode 100644 homeassistant/components/mobile_app/sensor.py create mode 100644 tests/components/mobile_app/test_entity.py diff --git a/homeassistant/components/mobile_app/__init__.py b/homeassistant/components/mobile_app/__init__.py index 1c348ea0782..ecbe8d70847 100644 --- a/homeassistant/components/mobile_app/__init__.py +++ b/homeassistant/components/mobile_app/__init__.py @@ -3,13 +3,13 @@ from homeassistant import config_entries from homeassistant.const import CONF_WEBHOOK_ID from homeassistant.components.webhook import async_register as webhook_register from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.discovery import load_platform from homeassistant.helpers.typing import ConfigType, HomeAssistantType -from .const import (ATTR_APP_COMPONENT, ATTR_DEVICE_ID, ATTR_DEVICE_NAME, - ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, +from .const import (ATTR_DEVICE_ID, ATTR_DEVICE_NAME, ATTR_MANUFACTURER, + ATTR_MODEL, ATTR_OS_VERSION, DATA_BINARY_SENSOR, DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, DATA_DEVICES, - DATA_STORE, DOMAIN, STORAGE_KEY, STORAGE_VERSION) + DATA_SENSOR, DATA_STORE, DOMAIN, STORAGE_KEY, + STORAGE_VERSION) from .http_api import RegistrationsView from .webhook import handle_webhook @@ -22,19 +22,25 @@ REQUIREMENTS = ['PyNaCl==1.3.0'] async def async_setup(hass: HomeAssistantType, config: ConfigType): """Set up the mobile app component.""" - hass.data[DOMAIN] = { - DATA_CONFIG_ENTRIES: {}, DATA_DELETED_IDS: [], DATA_DEVICES: {}, - } - store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) app_config = await store.async_load() if app_config is None: app_config = { - DATA_CONFIG_ENTRIES: {}, DATA_DELETED_IDS: [], DATA_DEVICES: {}, + DATA_BINARY_SENSOR: {}, + DATA_CONFIG_ENTRIES: {}, + DATA_DELETED_IDS: [], + DATA_DEVICES: {}, + DATA_SENSOR: {} } - hass.data[DOMAIN] = app_config - hass.data[DOMAIN][DATA_STORE] = store + hass.data[DOMAIN] = { + DATA_BINARY_SENSOR: app_config.get(DATA_BINARY_SENSOR, {}), + DATA_CONFIG_ENTRIES: {}, + DATA_DELETED_IDS: app_config.get(DATA_DELETED_IDS, []), + DATA_DEVICES: {}, + DATA_SENSOR: app_config.get(DATA_SENSOR, {}), + DATA_STORE: store, + } hass.http.register_view(RegistrationsView()) register_websocket_handlers(hass) @@ -79,9 +85,11 @@ async def async_setup_entry(hass, entry): webhook_register(hass, DOMAIN, registration_name, webhook_id, handle_webhook) - if ATTR_APP_COMPONENT in registration: - load_platform(hass, registration[ATTR_APP_COMPONENT], DOMAIN, {}, - {DOMAIN: {}}) + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, + DATA_BINARY_SENSOR)) + hass.async_create_task( + hass.config_entries.async_forward_entry_setup(entry, DATA_SENSOR)) return True diff --git a/homeassistant/components/mobile_app/binary_sensor.py b/homeassistant/components/mobile_app/binary_sensor.py new file mode 100644 index 00000000000..289a50584c9 --- /dev/null +++ b/homeassistant/components/mobile_app/binary_sensor.py @@ -0,0 +1,54 @@ +"""Binary sensor platform for mobile_app.""" +from functools import partial + +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.core import callback +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import (ATTR_SENSOR_STATE, + ATTR_SENSOR_TYPE_BINARY_SENSOR as ENTITY_TYPE, + DATA_DEVICES, DOMAIN) + +from .entity import MobileAppEntity + +DEPENDENCIES = ['mobile_app'] + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up mobile app binary sensor from a config entry.""" + entities = list() + + webhook_id = config_entry.data[CONF_WEBHOOK_ID] + + for config in hass.data[DOMAIN][ENTITY_TYPE].values(): + if config[CONF_WEBHOOK_ID] != webhook_id: + continue + + device = hass.data[DOMAIN][DATA_DEVICES][webhook_id] + + entities.append(MobileAppBinarySensor(config, device, config_entry)) + + async_add_entities(entities) + + @callback + def handle_sensor_registration(webhook_id, data): + if data[CONF_WEBHOOK_ID] != webhook_id: + return + + device = hass.data[DOMAIN][DATA_DEVICES][data[CONF_WEBHOOK_ID]] + + async_add_entities([MobileAppBinarySensor(data, device, config_entry)]) + + async_dispatcher_connect(hass, + '{}_{}_register'.format(DOMAIN, ENTITY_TYPE), + partial(handle_sensor_registration, webhook_id)) + + +class MobileAppBinarySensor(MobileAppEntity, BinarySensorDevice): + """Representation of an mobile app binary sensor.""" + + @property + def is_on(self): + """Return the state of the binary sensor.""" + return self._config[ATTR_SENSOR_STATE] diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 3ba029fec0e..d38df31b214 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -1,6 +1,9 @@ """Constants for mobile_app.""" import voluptuous as vol +from homeassistant.components.binary_sensor import (DEVICE_CLASSES as + BINARY_SENSOR_CLASSES) +from homeassistant.components.sensor import DEVICE_CLASSES as SENSOR_CLASSES from homeassistant.components.device_tracker import (ATTR_BATTERY, ATTR_GPS, ATTR_GPS_ACCURACY, @@ -17,9 +20,11 @@ CONF_CLOUDHOOK_URL = 'cloudhook_url' CONF_SECRET = 'secret' CONF_USER_ID = 'user_id' +DATA_BINARY_SENSOR = 'binary_sensor' DATA_CONFIG_ENTRIES = 'config_entries' DATA_DELETED_IDS = 'deleted_ids' DATA_DEVICES = 'devices' +DATA_SENSOR = 'sensor' DATA_STORE = 'store' ATTR_APP_COMPONENT = 'app_component' @@ -54,16 +59,22 @@ ATTR_WEBHOOK_TYPE = 'type' ERR_ENCRYPTION_REQUIRED = 'encryption_required' ERR_INVALID_COMPONENT = 'invalid_component' +ERR_SENSOR_NOT_REGISTERED = 'not_registered' +ERR_SENSOR_DUPLICATE_UNIQUE_ID = 'duplicate_unique_id' WEBHOOK_TYPE_CALL_SERVICE = 'call_service' WEBHOOK_TYPE_FIRE_EVENT = 'fire_event' +WEBHOOK_TYPE_REGISTER_SENSOR = 'register_sensor' WEBHOOK_TYPE_RENDER_TEMPLATE = 'render_template' WEBHOOK_TYPE_UPDATE_LOCATION = 'update_location' WEBHOOK_TYPE_UPDATE_REGISTRATION = 'update_registration' +WEBHOOK_TYPE_UPDATE_SENSOR_STATES = 'update_sensor_states' WEBHOOK_TYPES = [WEBHOOK_TYPE_CALL_SERVICE, WEBHOOK_TYPE_FIRE_EVENT, - WEBHOOK_TYPE_RENDER_TEMPLATE, WEBHOOK_TYPE_UPDATE_LOCATION, - WEBHOOK_TYPE_UPDATE_REGISTRATION] + WEBHOOK_TYPE_REGISTER_SENSOR, WEBHOOK_TYPE_RENDER_TEMPLATE, + WEBHOOK_TYPE_UPDATE_LOCATION, + WEBHOOK_TYPE_UPDATE_REGISTRATION, + WEBHOOK_TYPE_UPDATE_SENSOR_STATES] REGISTRATION_SCHEMA = vol.Schema({ @@ -91,7 +102,7 @@ UPDATE_REGISTRATION_SCHEMA = vol.Schema({ WEBHOOK_PAYLOAD_SCHEMA = vol.Schema({ vol.Required(ATTR_WEBHOOK_TYPE): cv.string, # vol.In(WEBHOOK_TYPES) - vol.Required(ATTR_WEBHOOK_DATA, default={}): dict, + vol.Required(ATTR_WEBHOOK_DATA, default={}): vol.Any(dict, list), vol.Optional(ATTR_WEBHOOK_ENCRYPTED, default=False): cv.boolean, vol.Optional(ATTR_WEBHOOK_ENCRYPTED_DATA): cv.string, }) @@ -125,10 +136,49 @@ UPDATE_LOCATION_SCHEMA = vol.Schema({ vol.Optional(ATTR_VERTICAL_ACCURACY): cv.positive_int, }) +ATTR_SENSOR_ATTRIBUTES = 'attributes' +ATTR_SENSOR_DEVICE_CLASS = 'device_class' +ATTR_SENSOR_ICON = 'icon' +ATTR_SENSOR_NAME = 'name' +ATTR_SENSOR_STATE = 'state' +ATTR_SENSOR_TYPE = 'type' +ATTR_SENSOR_TYPE_BINARY_SENSOR = 'binary_sensor' +ATTR_SENSOR_TYPE_SENSOR = 'sensor' +ATTR_SENSOR_UNIQUE_ID = 'unique_id' +ATTR_SENSOR_UOM = 'unit_of_measurement' + +SENSOR_TYPES = [ATTR_SENSOR_TYPE_BINARY_SENSOR, ATTR_SENSOR_TYPE_SENSOR] + +COMBINED_CLASSES = sorted(set(BINARY_SENSOR_CLASSES + SENSOR_CLASSES)) + +SIGNAL_SENSOR_UPDATE = DOMAIN + '_sensor_update' + +REGISTER_SENSOR_SCHEMA = vol.Schema({ + vol.Optional(ATTR_SENSOR_ATTRIBUTES, default={}): dict, + vol.Optional(ATTR_SENSOR_DEVICE_CLASS): vol.All(vol.Lower, + vol.In(COMBINED_CLASSES)), + vol.Required(ATTR_SENSOR_NAME): cv.string, + vol.Required(ATTR_SENSOR_TYPE): vol.In(SENSOR_TYPES), + vol.Required(ATTR_SENSOR_UNIQUE_ID): cv.string, + vol.Required(ATTR_SENSOR_UOM): cv.string, + vol.Required(ATTR_SENSOR_STATE): vol.Any(bool, str, int, float), + vol.Optional(ATTR_SENSOR_ICON, default='mdi:cellphone'): cv.icon, +}) + +UPDATE_SENSOR_STATE_SCHEMA = vol.All(cv.ensure_list, [vol.Schema({ + vol.Optional(ATTR_SENSOR_ATTRIBUTES, default={}): dict, + vol.Optional(ATTR_SENSOR_ICON, default='mdi:cellphone'): cv.icon, + vol.Required(ATTR_SENSOR_STATE): vol.Any(bool, str, int, float), + vol.Required(ATTR_SENSOR_TYPE): vol.In(SENSOR_TYPES), + vol.Required(ATTR_SENSOR_UNIQUE_ID): cv.string, +})]) + WEBHOOK_SCHEMAS = { WEBHOOK_TYPE_CALL_SERVICE: CALL_SERVICE_SCHEMA, WEBHOOK_TYPE_FIRE_EVENT: FIRE_EVENT_SCHEMA, + WEBHOOK_TYPE_REGISTER_SENSOR: REGISTER_SENSOR_SCHEMA, WEBHOOK_TYPE_RENDER_TEMPLATE: RENDER_TEMPLATE_SCHEMA, WEBHOOK_TYPE_UPDATE_LOCATION: UPDATE_LOCATION_SCHEMA, WEBHOOK_TYPE_UPDATE_REGISTRATION: UPDATE_REGISTRATION_SCHEMA, + WEBHOOK_TYPE_UPDATE_SENSOR_STATES: UPDATE_SENSOR_STATE_SCHEMA, } diff --git a/homeassistant/components/mobile_app/entity.py b/homeassistant/components/mobile_app/entity.py new file mode 100644 index 00000000000..05736b3a689 --- /dev/null +++ b/homeassistant/components/mobile_app/entity.py @@ -0,0 +1,98 @@ +"""A entity class for mobile_app.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceEntry +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity + +from .const import (ATTR_DEVICE_ID, ATTR_DEVICE_NAME, ATTR_MANUFACTURER, + ATTR_MODEL, ATTR_OS_VERSION, ATTR_SENSOR_ATTRIBUTES, + ATTR_SENSOR_DEVICE_CLASS, ATTR_SENSOR_ICON, + ATTR_SENSOR_NAME, ATTR_SENSOR_TYPE, ATTR_SENSOR_UNIQUE_ID, + DOMAIN, SIGNAL_SENSOR_UPDATE) + + +class MobileAppEntity(Entity): + """Representation of an mobile app entity.""" + + def __init__(self, config: dict, device: DeviceEntry, entry: ConfigEntry): + """Initialize the sensor.""" + self._config = config + self._device = device + self._entry = entry + self._registration = entry.data + self._sensor_id = "{}_{}".format(self._registration[CONF_WEBHOOK_ID], + config[ATTR_SENSOR_UNIQUE_ID]) + self._entity_type = config[ATTR_SENSOR_TYPE] + self.unsub_dispatcher = None + + async def async_added_to_hass(self): + """Register callbacks.""" + self.unsub_dispatcher = async_dispatcher_connect(self.hass, + SIGNAL_SENSOR_UPDATE, + self._handle_update) + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + if self.unsub_dispatcher is not None: + self.unsub_dispatcher() + + @property + def should_poll(self) -> bool: + """Declare that this entity pushes its state to HA.""" + return False + + @property + def name(self): + """Return the name of the mobile app sensor.""" + return self._config[ATTR_SENSOR_NAME] + + @property + def device_class(self): + """Return the device class.""" + return self._config.get(ATTR_SENSOR_DEVICE_CLASS) + + @property + def device_state_attributes(self): + """Return the device state attributes.""" + return self._config[ATTR_SENSOR_ATTRIBUTES] + + @property + def icon(self): + """Return the icon to use in the frontend, if any.""" + return self._config[ATTR_SENSOR_ICON] + + @property + def unique_id(self): + """Return the unique ID of this sensor.""" + return self._sensor_id + + @property + def device_info(self): + """Return device registry information for this entity.""" + return { + 'identifiers': { + (ATTR_DEVICE_ID, self._registration[ATTR_DEVICE_ID]), + (CONF_WEBHOOK_ID, self._registration[CONF_WEBHOOK_ID]) + }, + 'manufacturer': self._registration[ATTR_MANUFACTURER], + 'model': self._registration[ATTR_MODEL], + 'device_name': self._registration[ATTR_DEVICE_NAME], + 'sw_version': self._registration[ATTR_OS_VERSION], + 'config_entries': self._device.config_entries + } + + async def async_update(self): + """Get the latest state of the sensor.""" + data = self.hass.data[DOMAIN] + try: + self._config = data[self._entity_type][self._sensor_id] + except KeyError: + return + + @callback + def _handle_update(self, data): + """Handle async event updates.""" + self._config = data + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/mobile_app/helpers.py b/homeassistant/components/mobile_app/helpers.py index 5ec3b99b291..60bd8b4e1d6 100644 --- a/homeassistant/components/mobile_app/helpers.py +++ b/homeassistant/components/mobile_app/helpers.py @@ -11,7 +11,8 @@ from homeassistant.helpers.typing import HomeAssistantType from .const import (ATTR_APP_DATA, ATTR_APP_ID, ATTR_APP_NAME, ATTR_APP_VERSION, ATTR_DEVICE_NAME, ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, ATTR_SUPPORTS_ENCRYPTION, - CONF_SECRET, CONF_USER_ID, DATA_DELETED_IDS, DOMAIN) + CONF_SECRET, CONF_USER_ID, DATA_BINARY_SENSOR, + DATA_DELETED_IDS, DATA_SENSOR, DOMAIN) _LOGGER = logging.getLogger(__name__) @@ -123,7 +124,9 @@ def safe_registration(registration: Dict) -> Dict: def savable_state(hass: HomeAssistantType) -> Dict: """Return a clean object containing things that should be saved.""" return { + DATA_BINARY_SENSOR: hass.data[DOMAIN][DATA_BINARY_SENSOR], DATA_DELETED_IDS: hass.data[DOMAIN][DATA_DELETED_IDS], + DATA_SENSOR: hass.data[DOMAIN][DATA_SENSOR], } diff --git a/homeassistant/components/mobile_app/sensor.py b/homeassistant/components/mobile_app/sensor.py new file mode 100644 index 00000000000..c6a53ce57ec --- /dev/null +++ b/homeassistant/components/mobile_app/sensor.py @@ -0,0 +1,58 @@ +"""Sensor platform for mobile_app.""" +from functools import partial + +from homeassistant.const import CONF_WEBHOOK_ID +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import (ATTR_SENSOR_STATE, + ATTR_SENSOR_TYPE_SENSOR as ENTITY_TYPE, + ATTR_SENSOR_UOM, DATA_DEVICES, DOMAIN) + +from .entity import MobileAppEntity + +DEPENDENCIES = ['mobile_app'] + + +async def async_setup_entry(hass, config_entry, async_add_entities): + """Set up mobile app sensor from a config entry.""" + entities = list() + + webhook_id = config_entry.data[CONF_WEBHOOK_ID] + + for config in hass.data[DOMAIN][ENTITY_TYPE].values(): + if config[CONF_WEBHOOK_ID] != webhook_id: + continue + + device = hass.data[DOMAIN][DATA_DEVICES][webhook_id] + + entities.append(MobileAppSensor(config, device, config_entry)) + + async_add_entities(entities) + + @callback + def handle_sensor_registration(webhook_id, data): + if data[CONF_WEBHOOK_ID] != webhook_id: + return + + device = hass.data[DOMAIN][DATA_DEVICES][data[CONF_WEBHOOK_ID]] + + async_add_entities([MobileAppSensor(data, device, config_entry)]) + + async_dispatcher_connect(hass, + '{}_{}_register'.format(DOMAIN, ENTITY_TYPE), + partial(handle_sensor_registration, webhook_id)) + + +class MobileAppSensor(MobileAppEntity): + """Representation of an mobile app sensor.""" + + @property + def state(self): + """Return the state of the sensor.""" + return self._config[ATTR_SENSOR_STATE] + + @property + def unit_of_measurement(self): + """Return the unit of measurement this sensor expresses itself in.""" + return self._config[ATTR_SENSOR_UOM] diff --git a/homeassistant/components/mobile_app/webhook.py b/homeassistant/components/mobile_app/webhook.py index 1fab29160b7..aafa6046d11 100644 --- a/homeassistant/components/mobile_app/webhook.py +++ b/homeassistant/components/mobile_app/webhook.py @@ -10,30 +10,38 @@ from homeassistant.components.device_tracker import (ATTR_ATTRIBUTES, SERVICE_SEE as DT_SEE) from homeassistant.const import (ATTR_DOMAIN, ATTR_SERVICE, ATTR_SERVICE_DATA, - CONF_WEBHOOK_ID, HTTP_BAD_REQUEST) + CONF_WEBHOOK_ID, HTTP_BAD_REQUEST, + HTTP_CREATED) from homeassistant.core import EventOrigin -from homeassistant.exceptions import (ServiceNotFound, TemplateError) +from homeassistant.exceptions import (HomeAssistantError, + ServiceNotFound, TemplateError) from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.template import attach from homeassistant.helpers.typing import HomeAssistantType from .const import (ATTR_ALTITUDE, ATTR_BATTERY, ATTR_COURSE, ATTR_DEVICE_ID, ATTR_DEVICE_NAME, ATTR_EVENT_DATA, ATTR_EVENT_TYPE, ATTR_GPS, ATTR_GPS_ACCURACY, ATTR_LOCATION_NAME, - ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, ATTR_SPEED, + ATTR_MANUFACTURER, ATTR_MODEL, ATTR_OS_VERSION, + ATTR_SENSOR_TYPE, ATTR_SENSOR_UNIQUE_ID, ATTR_SPEED, ATTR_SUPPORTS_ENCRYPTION, ATTR_TEMPLATE, ATTR_TEMPLATE_VARIABLES, ATTR_VERTICAL_ACCURACY, ATTR_WEBHOOK_DATA, ATTR_WEBHOOK_ENCRYPTED, ATTR_WEBHOOK_ENCRYPTED_DATA, ATTR_WEBHOOK_TYPE, - CONF_SECRET, DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, DOMAIN, - ERR_ENCRYPTION_REQUIRED, WEBHOOK_PAYLOAD_SCHEMA, + CONF_SECRET, DATA_CONFIG_ENTRIES, DATA_DELETED_IDS, + DATA_STORE, DOMAIN, ERR_ENCRYPTION_REQUIRED, + ERR_SENSOR_DUPLICATE_UNIQUE_ID, ERR_SENSOR_NOT_REGISTERED, + SIGNAL_SENSOR_UPDATE, WEBHOOK_PAYLOAD_SCHEMA, WEBHOOK_SCHEMAS, WEBHOOK_TYPE_CALL_SERVICE, - WEBHOOK_TYPE_FIRE_EVENT, WEBHOOK_TYPE_RENDER_TEMPLATE, - WEBHOOK_TYPE_UPDATE_LOCATION, - WEBHOOK_TYPE_UPDATE_REGISTRATION) + WEBHOOK_TYPE_FIRE_EVENT, WEBHOOK_TYPE_REGISTER_SENSOR, + WEBHOOK_TYPE_RENDER_TEMPLATE, WEBHOOK_TYPE_UPDATE_LOCATION, + WEBHOOK_TYPE_UPDATE_REGISTRATION, + WEBHOOK_TYPE_UPDATE_SENSOR_STATES) + from .helpers import (_decrypt_payload, empty_okay_response, error_response, - registration_context, safe_registration, + registration_context, safe_registration, savable_state, webhook_response) @@ -79,6 +87,10 @@ async def handle_webhook(hass: HomeAssistantType, webhook_id: str, enc_data = req_data[ATTR_WEBHOOK_ENCRYPTED_DATA] webhook_payload = _decrypt_payload(registration[CONF_SECRET], enc_data) + if webhook_type not in WEBHOOK_SCHEMAS: + _LOGGER.error('Received invalid webhook type: %s', webhook_type) + return empty_okay_response() + try: data = WEBHOOK_SCHEMAS[webhook_type](webhook_payload) except vol.Invalid as ex: @@ -172,3 +184,80 @@ async def handle_webhook(hass: HomeAssistantType, webhook_id: str, return webhook_response(safe_registration(new_registration), registration=registration, headers=headers) + + if webhook_type == WEBHOOK_TYPE_REGISTER_SENSOR: + entity_type = data[ATTR_SENSOR_TYPE] + + unique_id = data[ATTR_SENSOR_UNIQUE_ID] + + unique_store_key = "{}_{}".format(webhook_id, unique_id) + + if unique_store_key in hass.data[DOMAIN][entity_type]: + _LOGGER.error("Refusing to re-register existing sensor %s!", + unique_id) + return error_response(ERR_SENSOR_DUPLICATE_UNIQUE_ID, + "{} {} already exists!".format(entity_type, + unique_id), + status=409) + + data[CONF_WEBHOOK_ID] = webhook_id + + hass.data[DOMAIN][entity_type][unique_store_key] = data + + try: + await hass.data[DOMAIN][DATA_STORE].async_save(savable_state(hass)) + except HomeAssistantError as ex: + _LOGGER.error("Error registering sensor: %s", ex) + return empty_okay_response() + + register_signal = '{}_{}_register'.format(DOMAIN, + data[ATTR_SENSOR_TYPE]) + async_dispatcher_send(hass, register_signal, data) + + return webhook_response({"status": "registered"}, + registration=registration, status=HTTP_CREATED, + headers=headers) + + if webhook_type == WEBHOOK_TYPE_UPDATE_SENSOR_STATES: + resp = {} + for sensor in data: + entity_type = sensor[ATTR_SENSOR_TYPE] + + unique_id = sensor[ATTR_SENSOR_UNIQUE_ID] + + unique_store_key = "{}_{}".format(webhook_id, unique_id) + + if unique_store_key not in hass.data[DOMAIN][entity_type]: + _LOGGER.error("Refusing to update non-registered sensor: %s", + unique_store_key) + err_msg = '{} {} is not registered'.format(entity_type, + unique_id) + resp[unique_id] = { + 'success': False, + 'error': { + 'code': ERR_SENSOR_NOT_REGISTERED, + 'message': err_msg + } + } + continue + + entry = hass.data[DOMAIN][entity_type][unique_store_key] + + new_state = {**entry, **sensor} + + hass.data[DOMAIN][entity_type][unique_store_key] = new_state + + safe = savable_state(hass) + + try: + await hass.data[DOMAIN][DATA_STORE].async_save(safe) + except HomeAssistantError as ex: + _LOGGER.error("Error updating mobile_app registration: %s", ex) + return empty_okay_response() + + async_dispatcher_send(hass, SIGNAL_SENSOR_UPDATE, new_state) + + resp[unique_id] = {"status": "okay"} + + return webhook_response(resp, registration=registration, + headers=headers) diff --git a/tests/components/mobile_app/__init__.py b/tests/components/mobile_app/__init__.py index bed275a534d..cf617ff0528 100644 --- a/tests/components/mobile_app/__init__.py +++ b/tests/components/mobile_app/__init__.py @@ -6,9 +6,9 @@ from tests.common import mock_device_registry from homeassistant.setup import async_setup_component -from homeassistant.components.mobile_app.const import (DATA_CONFIG_ENTRIES, +from homeassistant.components.mobile_app.const import (DATA_BINARY_SENSOR, DATA_DELETED_IDS, - DATA_DEVICES, + DATA_SENSOR, DOMAIN, STORAGE_KEY, STORAGE_VERSION) @@ -48,7 +48,9 @@ async def webhook_client(hass, aiohttp_client, hass_storage, hass_admin_user): hass_storage[STORAGE_KEY] = { 'version': STORAGE_VERSION, 'data': { - DATA_CONFIG_ENTRIES: {}, DATA_DELETED_IDS: [], DATA_DEVICES: {}, + DATA_BINARY_SENSOR: {}, + DATA_DELETED_IDS: [], + DATA_SENSOR: {} } } diff --git a/tests/components/mobile_app/test_entity.py b/tests/components/mobile_app/test_entity.py new file mode 100644 index 00000000000..d8cb91a8bc6 --- /dev/null +++ b/tests/components/mobile_app/test_entity.py @@ -0,0 +1,137 @@ +"""Entity tests for mobile_app.""" +# pylint: disable=redefined-outer-name,unused-import +import logging + +from . import (authed_api_client, create_registrations, # noqa: F401 + webhook_client) # noqa: F401 + +_LOGGER = logging.getLogger(__name__) + + +async def test_sensor(hass, create_registrations, webhook_client): # noqa: F401, F811, E501 + """Test that sensors can be registered and updated.""" + webhook_id = create_registrations[1]['webhook_id'] + webhook_url = '/api/webhook/{}'.format(webhook_id) + + reg_resp = await webhook_client.post( + webhook_url, + json={ + 'type': 'register_sensor', + 'data': { + 'attributes': { + 'foo': 'bar' + }, + 'device_class': 'battery', + 'icon': 'mdi:battery', + 'name': 'Battery State', + 'state': 100, + 'type': 'sensor', + 'unique_id': 'battery_state', + 'unit_of_measurement': '%' + } + } + ) + + assert reg_resp.status == 201 + + json = await reg_resp.json() + assert json == {'status': 'registered'} + + # 3 because we require device_tracker which adds zone.home and + # group.all_devices + assert len(hass.states.async_all()) == 3 + + entity = hass.states.async_all()[2] + + assert entity.attributes['device_class'] == 'battery' + assert entity.attributes['icon'] == 'mdi:battery' + assert entity.attributes['unit_of_measurement'] == '%' + assert entity.attributes['foo'] == 'bar' + assert entity.domain == 'sensor' + assert entity.name == 'Battery State' + assert entity.state == '100' + + update_resp = await webhook_client.post( + webhook_url, + json={ + 'type': 'update_sensor_states', + 'data': [ + { + 'icon': 'mdi:battery-unknown', + 'state': 123, + 'type': 'sensor', + 'unique_id': 'battery_state' + } + ] + } + ) + + assert update_resp.status == 200 + + updated_entity = hass.states.async_all()[2] + + assert updated_entity.state == '123' + + +async def test_sensor_must_register(hass, create_registrations, # noqa: F401, F811, E501 + webhook_client): # noqa: F401, F811, E501 + """Test that sensors must be registered before updating.""" + webhook_id = create_registrations[1]['webhook_id'] + webhook_url = '/api/webhook/{}'.format(webhook_id) + resp = await webhook_client.post( + webhook_url, + json={ + 'type': 'update_sensor_states', + 'data': [ + { + 'state': 123, + 'type': 'sensor', + 'unique_id': 'battery_state' + } + ] + } + ) + + assert resp.status == 200 + + json = await resp.json() + assert json['battery_state']['success'] is False + assert json['battery_state']['error']['code'] == 'not_registered' + + +async def test_sensor_id_no_dupes(hass, create_registrations, # noqa: F401, F811, E501 + webhook_client): # noqa: F401, F811, E501 + """Test that sensors must have a unique ID.""" + webhook_id = create_registrations[1]['webhook_id'] + webhook_url = '/api/webhook/{}'.format(webhook_id) + + payload = { + 'type': 'register_sensor', + 'data': { + 'attributes': { + 'foo': 'bar' + }, + 'device_class': 'battery', + 'icon': 'mdi:battery', + 'name': 'Battery State', + 'state': 100, + 'type': 'sensor', + 'unique_id': 'battery_state', + 'unit_of_measurement': '%' + } + } + + reg_resp = await webhook_client.post(webhook_url, json=payload) + + assert reg_resp.status == 201 + + reg_json = await reg_resp.json() + assert reg_json == {'status': 'registered'} + + dupe_resp = await webhook_client.post(webhook_url, json=payload) + + assert dupe_resp.status == 409 + + dupe_json = await dupe_resp.json() + assert dupe_json['success'] is False + assert dupe_json['error']['code'] == 'duplicate_unique_id' From 25a7f71ec27370cafd0abaf6fbc6e352622e60b3 Mon Sep 17 00:00:00 2001 From: Jeff Irion Date: Thu, 14 Mar 2019 02:25:07 -0700 Subject: [PATCH 255/291] Bump androidtv to 0.0.11 (#22025) --- homeassistant/components/androidtv/media_player.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index ab43dc8c6ea..458fdff87fd 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -22,7 +22,7 @@ import homeassistant.helpers.config_validation as cv ANDROIDTV_DOMAIN = 'androidtv' -REQUIREMENTS = ['androidtv==0.0.10'] +REQUIREMENTS = ['androidtv==0.0.11'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 9200b803b8f..c2a75a11d80 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -158,7 +158,7 @@ alpha_vantage==2.1.0 amcrest==1.2.5 # homeassistant.components.androidtv.media_player -androidtv==0.0.10 +androidtv==0.0.11 # homeassistant.components.switch.anel_pwrctrl anel_pwrctrl-homeassistant==0.0.1.dev2 From 4835fb2c57c4937110000eb7e7dca4e7e577ed4d Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 13 Mar 2019 21:55:30 -0700 Subject: [PATCH 256/291] Mobile App: Enable loading via discovery (surprise inside!) (#22027) ![](http://funpeep.com/wp-content/uploads/2014/04/Cute-White-Cat-Wallpaper.jpg) --- homeassistant/components/discovery/__init__.py | 4 +++- requirements_all.txt | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 2f94cf48f4d..6a561e570c5 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -21,7 +21,7 @@ from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.discovery import async_load_platform, async_discover import homeassistant.util.dt as dt_util -REQUIREMENTS = ['netdisco==2.4.0'] +REQUIREMENTS = ['netdisco==2.5.0'] DOMAIN = 'discovery' @@ -40,6 +40,7 @@ SERVICE_HUE = 'philips_hue' SERVICE_IGD = 'igd' SERVICE_IKEA_TRADFRI = 'ikea_tradfri' SERVICE_KONNECTED = 'konnected' +SERVICE_MOBILE_APP = 'hass_mobile_app' SERVICE_NETGEAR = 'netgear_router' SERVICE_OCTOPRINT = 'octoprint' SERVICE_ROKU = 'roku' @@ -63,6 +64,7 @@ CONFIG_ENTRY_HANDLERS = { } SERVICE_HANDLERS = { + SERVICE_MOBILE_APP: ('mobile_app', None), SERVICE_HASS_IOS_APP: ('ios', None), SERVICE_NETGEAR: ('device_tracker', None), SERVICE_WEMO: ('wemo', None), diff --git a/requirements_all.txt b/requirements_all.txt index c2a75a11d80..4f8d598665b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -741,7 +741,7 @@ nessclient==0.9.14 netdata==0.1.2 # homeassistant.components.discovery -netdisco==2.4.0 +netdisco==2.5.0 # homeassistant.components.sensor.neurio_energy neurio==0.3.1 From 11ebb3f24eea916b2a427f4542d37a396f5ed055 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Wed, 13 Mar 2019 22:05:56 -0700 Subject: [PATCH 257/291] Mobile App: Discovery to default configuration.yaml, zeroconf to default_config (#22028) * Move discovery into default configuration.yaml * Add zeroconf to default_config --- homeassistant/components/default_config/__init__.py | 2 +- homeassistant/config.py | 3 +++ 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/default_config/__init__.py b/homeassistant/components/default_config/__init__.py index badc403c7c8..888a4d51c95 100644 --- a/homeassistant/components/default_config/__init__.py +++ b/homeassistant/components/default_config/__init__.py @@ -6,7 +6,6 @@ DEPENDENCIES = ( 'cloud', 'config', 'conversation', - 'discovery', 'frontend', 'history', 'logbook', @@ -17,6 +16,7 @@ DEPENDENCIES = ( 'sun', 'system_health', 'updater', + 'zeroconf', ) diff --git a/homeassistant/config.py b/homeassistant/config.py index db59e2c2744..19b8087e538 100644 --- a/homeassistant/config.py +++ b/homeassistant/config.py @@ -75,6 +75,9 @@ introduction: # http: # base_url: example.duckdns.org:8123 +# Discover some devices automatically +discovery: + # Sensors sensor: # Weather prediction From 0029dc3813b42abdc5b7c642babeced1ad5a9a77 Mon Sep 17 00:00:00 2001 From: Robbie Trencheny Date: Thu, 14 Mar 2019 19:46:59 -0700 Subject: [PATCH 258/291] Mobile App: Expose Cloud Remote UI FQDN in registration response (#22055) * Add a callback to get the cloud remote UI FQDN * Expose Cloud Remote UI FQDN in the registration response * Return a URL instead of FQDN --- homeassistant/components/cloud/__init__.py | 10 ++++++++++ homeassistant/components/mobile_app/const.py | 1 + homeassistant/components/mobile_app/http_api.py | 14 ++++++++++++-- 3 files changed, 23 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 2e324f06738..3e3d6f975e9 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -118,6 +118,16 @@ async def async_delete_cloudhook(hass, webhook_id: str) -> None: await hass.data[DOMAIN].cloudhooks.async_delete(webhook_id) +@bind_hass +@callback +def async_remote_ui_url(hass) -> str: + """Get the remote UI URL.""" + if not async_is_logged_in(hass): + raise CloudNotAvailable + + return "https://" + hass.data[DOMAIN].remote.instance_domain + + def is_cloudhook_request(request): """Test if a request came from a cloudhook. diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index d38df31b214..3aa4626da29 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -17,6 +17,7 @@ STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 CONF_CLOUDHOOK_URL = 'cloudhook_url' +CONF_REMOTE_UI_URL = 'remote_ui_url' CONF_SECRET = 'secret' CONF_USER_ID = 'user_id' diff --git a/homeassistant/components/mobile_app/http_api.py b/homeassistant/components/mobile_app/http_api.py index 8076d217cac..2ae8f441e52 100644 --- a/homeassistant/components/mobile_app/http_api.py +++ b/homeassistant/components/mobile_app/http_api.py @@ -5,7 +5,9 @@ from typing import Dict from aiohttp.web import Response, Request from homeassistant.auth.util import generate_secret -from homeassistant.components.cloud import async_create_cloudhook +from homeassistant.components.cloud import (async_create_cloudhook, + async_remote_ui_url, + CloudNotAvailable) from homeassistant.components.http import HomeAssistantView from homeassistant.components.http.data_validator import RequestDataValidator from homeassistant.const import (HTTP_CREATED, CONF_WEBHOOK_ID) @@ -13,7 +15,8 @@ from homeassistant.const import (HTTP_CREATED, CONF_WEBHOOK_ID) from homeassistant.loader import get_component from .const import (ATTR_APP_COMPONENT, ATTR_DEVICE_ID, - ATTR_SUPPORTS_ENCRYPTION, CONF_CLOUDHOOK_URL, CONF_SECRET, + ATTR_SUPPORTS_ENCRYPTION, CONF_CLOUDHOOK_URL, + CONF_REMOTE_UI_URL, CONF_SECRET, CONF_USER_ID, DOMAIN, ERR_INVALID_COMPONENT, REGISTRATION_SCHEMA) @@ -67,8 +70,15 @@ class RegistrationsView(HomeAssistantView): hass.config_entries.flow.async_init(DOMAIN, context=ctx, data=data)) + remote_ui_url = None + try: + remote_ui_url = async_remote_ui_url(hass) + except CloudNotAvailable: + pass + return self.json({ CONF_CLOUDHOOK_URL: data.get(CONF_CLOUDHOOK_URL), + CONF_REMOTE_UI_URL: remote_ui_url, CONF_SECRET: data.get(CONF_SECRET), CONF_WEBHOOK_ID: data[CONF_WEBHOOK_ID], }, status_code=HTTP_CREATED) From 8f103454687869d2fd0e71e20fb84783efc4032a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 15 Mar 2019 07:41:34 -0700 Subject: [PATCH 259/291] Return config entry ID after creation (#22060) --- .../components/config/config_entries.py | 22 ++++++++++++++++++- .../components/config/test_config_entries.py | 10 +++++++++ 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index 65f65cbcec5..8865ff39cea 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -118,6 +118,16 @@ class ConfigManagerFlowIndexView(FlowManagerIndexView): # pylint: disable=no-value-for-parameter return await super().post(request) + def _prepare_result_json(self, result): + """Convert result to JSON.""" + if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + return super()._prepare_result_json(result) + + data = result.copy() + data['result'] = data['result'].entry_id + data.pop('data') + return data + class ConfigManagerFlowResourceView(FlowManagerResourceView): """View to interact with the flow manager.""" @@ -143,6 +153,16 @@ class ConfigManagerFlowResourceView(FlowManagerResourceView): # pylint: disable=no-value-for-parameter return await super().post(request, flow_id) + def _prepare_result_json(self, result): + """Convert result to JSON.""" + if result['type'] != data_entry_flow.RESULT_TYPE_CREATE_ENTRY: + return super()._prepare_result_json(result) + + data = result.copy() + data['result'] = data['result'].entry_id + data.pop('data') + return data + class ConfigManagerAvailableFlowView(HomeAssistantView): """View to query available flows.""" @@ -175,7 +195,7 @@ class OptionManagerFlowIndexView(FlowManagerIndexView): return await super().post(request) -class OptionManagerFlowResourceView(ConfigManagerFlowResourceView): +class OptionManagerFlowResourceView(FlowManagerResourceView): """View to interact with the option flow manager.""" url = '/api/config/config_entries/options/flow/{flow_id}' diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index d5e4331f7b9..852a5adf6a2 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -255,6 +255,10 @@ def test_create_account(hass, client): json={'handler': 'test'}) assert resp.status == 200 + + entries = hass.config_entries.async_entries('test') + assert len(entries) == 1 + data = yield from resp.json() data.pop('flow_id') assert data == { @@ -262,6 +266,7 @@ def test_create_account(hass, client): 'title': 'Test Entry', 'type': 'create_entry', 'version': 1, + 'result': entries[0].entry_id, 'description': None, 'description_placeholders': None, } @@ -317,6 +322,10 @@ def test_two_step_flow(hass, client): '/api/config/config_entries/flow/{}'.format(flow_id), json={'user_title': 'user-title'}) assert resp.status == 200 + + entries = hass.config_entries.async_entries('test') + assert len(entries) == 1 + data = yield from resp.json() data.pop('flow_id') assert data == { @@ -324,6 +333,7 @@ def test_two_step_flow(hass, client): 'type': 'create_entry', 'title': 'user-title', 'version': 1, + 'result': entries[0].entry_id, 'description': None, 'description_placeholders': None, } From 3ec8b5a170e5f0e85e6f81f92507ad76ce54f229 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Fri, 15 Mar 2019 10:01:15 -0700 Subject: [PATCH 260/291] Correct context (#22061) --- homeassistant/components/alexa/smart_home.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/alexa/smart_home.py b/homeassistant/components/alexa/smart_home.py index a856a3d8e82..c87b2c3f624 100644 --- a/homeassistant/components/alexa/smart_home.py +++ b/homeassistant/components/alexa/smart_home.py @@ -1,21 +1,21 @@ """Support for alexa Smart Home Skill API.""" import asyncio -from collections import OrderedDict -from datetime import datetime import json import logging import math +from collections import OrderedDict +from datetime import datetime from uuid import uuid4 import aiohttp import async_timeout +import homeassistant.core as ha +import homeassistant.util.color as color_util from homeassistant.components import ( alert, automation, binary_sensor, cover, fan, group, http, input_boolean, light, lock, media_player, scene, script, sensor, switch) from homeassistant.components.climate import const as climate -from homeassistant.helpers import aiohttp_client -from homeassistant.helpers.event import async_track_state_change from homeassistant.const import ( ATTR_DEVICE_CLASS, ATTR_ENTITY_ID, ATTR_SUPPORTED_FEATURES, ATTR_TEMPERATURE, ATTR_UNIT_OF_MEASUREMENT, CLOUD_NEVER_EXPOSED_ENTITIES, @@ -25,14 +25,14 @@ from homeassistant.const import ( SERVICE_UNLOCK, SERVICE_VOLUME_DOWN, SERVICE_VOLUME_UP, SERVICE_VOLUME_SET, SERVICE_VOLUME_MUTE, STATE_LOCKED, STATE_ON, STATE_OFF, STATE_UNAVAILABLE, STATE_UNLOCKED, TEMP_CELSIUS, TEMP_FAHRENHEIT, MATCH_ALL) -import homeassistant.core as ha -import homeassistant.util.color as color_util +from homeassistant.helpers import aiohttp_client +from homeassistant.helpers.event import async_track_state_change from homeassistant.util.decorator import Registry from homeassistant.util.temperature import convert as convert_temperature +from .auth import Auth from .const import CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_ENDPOINT, \ CONF_ENTITY_CONFIG, CONF_FILTER, DATE_FORMAT, DEFAULT_TIMEOUT -from .auth import Auth _LOGGER = logging.getLogger(__name__) @@ -1115,12 +1115,15 @@ class SmartHomeView(http.HomeAssistantView): the response. """ hass = request.app['hass'] + user = request[http.KEY_HASS_USER] message = await request.json() _LOGGER.debug("Received Alexa Smart Home request: %s", message) response = await async_handle_message( - hass, self.smart_home_config, message) + hass, self.smart_home_config, message, + context=ha.Context(user_id=user.id) + ) _LOGGER.debug("Sending Alexa Smart Home response: %s", response) return b'' if response is None else self.json(response) From ac1aeb35a6b950f5850f6ca1f2347431b1b79b74 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Fri, 15 Mar 2019 18:39:53 +0100 Subject: [PATCH 261/291] Binary Sensor for Remote UI & Fix timezone (#22076) * Binary Sensor for Remote UI * Fix lint * Revert make hass public * Add tests --- homeassistant/components/cloud/__init__.py | 4 +- .../components/cloud/binary_sensor.py | 73 +++++++++++++++++++ homeassistant/components/cloud/client.py | 15 +++- homeassistant/components/cloud/const.py | 2 + requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/cloud/test_binary_sensor.py | 32 ++++++++ 7 files changed, 124 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/cloud/binary_sensor.py create mode 100644 tests/components/cloud/test_binary_sensor.py diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 3e3d6f975e9..9f6e678e417 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -24,7 +24,7 @@ from .const import ( CONF_USER_POOL_ID, DOMAIN, MODE_DEV, MODE_PROD) from .prefs import CloudPreferences -REQUIREMENTS = ['hass-nabucasa==0.5'] +REQUIREMENTS = ['hass-nabucasa==0.7'] DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) @@ -193,4 +193,6 @@ async def async_setup(hass, config): DOMAIN, SERVICE_REMOTE_DISCONNECT, _service_handler) await http_api.async_setup(hass) + hass.async_create_task(hass.helpers.discovery.async_load_platform( + 'binary_sensor', DOMAIN, {}, config)) return True diff --git a/homeassistant/components/cloud/binary_sensor.py b/homeassistant/components/cloud/binary_sensor.py new file mode 100644 index 00000000000..874c3420c58 --- /dev/null +++ b/homeassistant/components/cloud/binary_sensor.py @@ -0,0 +1,73 @@ +"""Support for Home Assistant Cloud binary sensors.""" +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import DISPATCHER_REMOTE_UPDATE, DOMAIN + +DEPENDENCIES = ['cloud'] + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up the cloud binary sensors.""" + if discovery_info is None: + return + cloud = hass.data[DOMAIN] + + async_add_entities([CloudRemoteBinary(cloud)]) + + +class CloudRemoteBinary(BinarySensorDevice): + """Representation of an Cloud Remote UI Connection binary sensor.""" + + def __init__(self, cloud): + """Initialize the binary sensor.""" + self.cloud = cloud + self._unsub_dispatcher = None + + @property + def name(self) -> str: + """Return the name of the binary sensor, if any.""" + return "Remote UI" + + @property + def unique_id(self) -> str: + """Return a unique ID.""" + return "cloud-remote-ui-connectivity" + + @property + def is_on(self) -> bool: + """Return true if the binary sensor is on.""" + return self.cloud.remote.is_connected + + @property + def device_class(self) -> str: + """Return the class of this device, from component DEVICE_CLASSES.""" + return 'connectivity' + + @property + def available(self) -> bool: + """Return True if entity is available.""" + return self.cloud.remote.certificate is not None + + @property + def should_poll(self) -> bool: + """Return True if entity has to be polled for state.""" + return False + + async def async_added_to_hass(self): + """Register update dispatcher.""" + @callback + def async_state_update(data): + """Update callback.""" + self.async_write_ha_state() + + self._unsub_dispatcher = async_dispatcher_connect( + self.hass, DISPATCHER_REMOTE_UPDATE, async_state_update) + + async def async_will_remove_from_hass(self): + """Register update dispatcher.""" + if self._unsub_dispatcher is not None: + self._unsub_dispatcher() + self._unsub_dispatcher = None diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index f73c16b1904..7fdfc786515 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -6,15 +6,18 @@ from typing import Any, Dict import aiohttp from hass_nabucasa.client import CloudClient as Interface +from homeassistant.core import callback from homeassistant.components.alexa import smart_home as alexa_sh from homeassistant.components.google_assistant import ( helpers as ga_h, smart_home as ga) from homeassistant.const import CLOUD_NEVER_EXPOSED_ENTITIES from homeassistant.helpers.typing import HomeAssistantType +from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.util.aiohttp import MockRequest from . import utils -from .const import CONF_ENTITY_CONFIG, CONF_FILTER, DOMAIN +from .const import ( + CONF_ENTITY_CONFIG, CONF_FILTER, DOMAIN, DISPATCHER_REMOTE_UPDATE) from .prefs import CloudPreferences @@ -115,13 +118,19 @@ class CloudClient(Interface): self._alexa_config = None self._google_config = None - async def async_user_message( - self, identifier: str, title: str, message: str) -> None: + @callback + def user_message(self, identifier: str, title: str, message: str) -> None: """Create a message for user to UI.""" self._hass.components.persistent_notification.async_create( message, title, identifier ) + @callback + def dispatcher_message(self, identifier: str, data: Any = None) -> None: + """Match cloud notification to dispatcher.""" + if identifier.startwith("remote_"): + async_dispatcher_send(self._hass, DISPATCHER_REMOTE_UPDATE, data) + async def async_alexa_message( self, payload: Dict[Any, Any]) -> Dict[Any, Any]: """Process cloud alexa message to client.""" diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index fdedacd6dbb..2816e3f6dc9 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -25,3 +25,5 @@ CONF_ACME_DIRECTORY_SERVER = 'acme_directory_server' MODE_DEV = "development" MODE_PROD = "production" + +DISPATCHER_REMOTE_UPDATE = 'cloud_remote_update' diff --git a/requirements_all.txt b/requirements_all.txt index 4f8d598665b..a5f728ea232 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -524,7 +524,7 @@ habitipy==0.2.0 hangups==0.4.6 # homeassistant.components.cloud -hass-nabucasa==0.5 +hass-nabucasa==0.7 # homeassistant.components.mqtt.server hbmqtt==0.9.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 36f94167565..65993daefa7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -114,7 +114,7 @@ ha-ffmpeg==1.11 hangups==0.4.6 # homeassistant.components.cloud -hass-nabucasa==0.5 +hass-nabucasa==0.7 # homeassistant.components.mqtt.server hbmqtt==0.9.4 diff --git a/tests/components/cloud/test_binary_sensor.py b/tests/components/cloud/test_binary_sensor.py new file mode 100644 index 00000000000..938829b809b --- /dev/null +++ b/tests/components/cloud/test_binary_sensor.py @@ -0,0 +1,32 @@ +"""Tests for the cloud binary sensor.""" +from unittest.mock import Mock + +from homeassistant.setup import async_setup_component +from homeassistant.components.cloud.const import DISPATCHER_REMOTE_UPDATE + + +async def test_remote_connection_sensor(hass): + """Test the remote connection sensor.""" + assert await async_setup_component(hass, 'cloud', {'cloud': {}}) + cloud = hass.data['cloud'] = Mock() + cloud.remote.certificate = None + await hass.async_block_till_done() + + state = hass.states.get('binary_sensor.remote_ui') + assert state is not None + assert state.state == 'unavailable' + + cloud.remote.is_connected = False + cloud.remote.certificate = object() + hass.helpers.dispatcher.async_dispatcher_send(DISPATCHER_REMOTE_UPDATE, {}) + await hass.async_block_till_done() + + state = hass.states.get('binary_sensor.remote_ui') + assert state.state == 'off' + + cloud.remote.is_connected = True + hass.helpers.dispatcher.async_dispatcher_send(DISPATCHER_REMOTE_UPDATE, {}) + await hass.async_block_till_done() + + state = hass.states.get('binary_sensor.remote_ui') + assert state.state == 'on' From b18aef8d317563700482baca9d5ac41670f590ea Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 15 Mar 2019 07:47:13 -0700 Subject: [PATCH 262/291] Fix test --- tests/components/mobile_app/test_entity.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/tests/components/mobile_app/test_entity.py b/tests/components/mobile_app/test_entity.py index d8cb91a8bc6..f399f842745 100644 --- a/tests/components/mobile_app/test_entity.py +++ b/tests/components/mobile_app/test_entity.py @@ -37,11 +37,8 @@ async def test_sensor(hass, create_registrations, webhook_client): # noqa: F401 json = await reg_resp.json() assert json == {'status': 'registered'} - # 3 because we require device_tracker which adds zone.home and - # group.all_devices - assert len(hass.states.async_all()) == 3 - - entity = hass.states.async_all()[2] + entity = hass.states.get('sensor.battery_state') + assert entity is not None assert entity.attributes['device_class'] == 'battery' assert entity.attributes['icon'] == 'mdi:battery' From 3d404c43c800df095bd5a30c25bb5c8fcde01541 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 15 Mar 2019 09:14:20 -0700 Subject: [PATCH 263/291] Fix more test --- tests/components/mobile_app/test_entity.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/components/mobile_app/test_entity.py b/tests/components/mobile_app/test_entity.py index f399f842745..5dc285cfe9e 100644 --- a/tests/components/mobile_app/test_entity.py +++ b/tests/components/mobile_app/test_entity.py @@ -65,8 +65,7 @@ async def test_sensor(hass, create_registrations, webhook_client): # noqa: F401 assert update_resp.status == 200 - updated_entity = hass.states.async_all()[2] - + updated_entity = hass.states.get('sensor.battery_state') assert updated_entity.state == '123' From ff6b86b5a825bee623e88cd2e12fa04c179e1480 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 15 Mar 2019 10:59:55 -0700 Subject: [PATCH 264/291] Bumped version to 0.90.0b2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index b1a0fbb1e4f..8fa218efd86 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 90 -PATCH_VERSION = '0b1' +PATCH_VERSION = '0b2' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From aa81819683110093058058b3625b5579ebfb629d Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 15 Mar 2019 11:11:59 -0700 Subject: [PATCH 265/291] Fix func --- homeassistant/components/cloud/client.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/cloud/client.py b/homeassistant/components/cloud/client.py index 7fdfc786515..da89f8331a9 100644 --- a/homeassistant/components/cloud/client.py +++ b/homeassistant/components/cloud/client.py @@ -128,7 +128,7 @@ class CloudClient(Interface): @callback def dispatcher_message(self, identifier: str, data: Any = None) -> None: """Match cloud notification to dispatcher.""" - if identifier.startwith("remote_"): + if identifier.startswith("remote_"): async_dispatcher_send(self._hass, DISPATCHER_REMOTE_UPDATE, data) async def async_alexa_message( From 592447927258ac196e442e351cb0cc626fcb20d4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 15 Mar 2019 23:17:41 -0700 Subject: [PATCH 266/291] Updated frontend to 20190315.1 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index a8d2cbc35b9..5d5585ddd23 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -21,7 +21,7 @@ from homeassistant.loader import bind_hass from .storage import async_setup_frontend_storage -REQUIREMENTS = ['home-assistant-frontend==20190315.0'] +REQUIREMENTS = ['home-assistant-frontend==20190315.1'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index a5f728ea232..d2cdfad49bb 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -554,7 +554,7 @@ hole==0.3.0 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190315.0 +home-assistant-frontend==20190315.1 # homeassistant.components.zwave homeassistant-pyozw==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 65993daefa7..752ee21d614 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -126,7 +126,7 @@ hdate==0.8.7 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190315.0 +home-assistant-frontend==20190315.1 # homeassistant.components.homekit_controller homekit[IP]==0.13.0 From a46b64d227a0e8635a8fc3c84b1d2697892212d3 Mon Sep 17 00:00:00 2001 From: Jeff Irion Date: Fri, 15 Mar 2019 12:25:09 -0700 Subject: [PATCH 267/291] Bump androidtv to 0.0.12 (#22072) --- homeassistant/components/androidtv/media_player.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/androidtv/media_player.py b/homeassistant/components/androidtv/media_player.py index 458fdff87fd..1282a40cac5 100644 --- a/homeassistant/components/androidtv/media_player.py +++ b/homeassistant/components/androidtv/media_player.py @@ -22,7 +22,7 @@ import homeassistant.helpers.config_validation as cv ANDROIDTV_DOMAIN = 'androidtv' -REQUIREMENTS = ['androidtv==0.0.11'] +REQUIREMENTS = ['androidtv==0.0.12'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index d2cdfad49bb..22509e84f7c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -158,7 +158,7 @@ alpha_vantage==2.1.0 amcrest==1.2.5 # homeassistant.components.androidtv.media_player -androidtv==0.0.11 +androidtv==0.0.12 # homeassistant.components.switch.anel_pwrctrl anel_pwrctrl-homeassistant==0.0.1.dev2 From 68d1a5322a4adfb1ff5e7eb71485a5e7627519bb Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 15 Mar 2019 19:26:10 -0700 Subject: [PATCH 268/291] Prevent cloud remote UI when using 127.0.0.1 as trusted network (#22093) * Prevent cloud remote UI when using trusted networks * Limit to 127.0.0.1 trusted network * Update error msg * Disable ipv6 loopback --- homeassistant/components/cloud/const.py | 4 + homeassistant/components/cloud/http_api.py | 73 +++++++++------- homeassistant/components/cloud/prefs.py | 35 +++++++- tests/components/cloud/test_http_api.py | 99 ++++++++++++++++++++++ 4 files changed, 177 insertions(+), 34 deletions(-) diff --git a/homeassistant/components/cloud/const.py b/homeassistant/components/cloud/const.py index 2816e3f6dc9..1286832c0c7 100644 --- a/homeassistant/components/cloud/const.py +++ b/homeassistant/components/cloud/const.py @@ -27,3 +27,7 @@ MODE_DEV = "development" MODE_PROD = "production" DISPATCHER_REMOTE_UPDATE = 'cloud_remote_update' + + +class InvalidTrustedNetworks(Exception): + """Raised when invalid trusted networks config.""" diff --git a/homeassistant/components/cloud/http_api.py b/homeassistant/components/cloud/http_api.py index 61b3b8576ec..212bdfb4bf8 100644 --- a/homeassistant/components/cloud/http_api.py +++ b/homeassistant/components/cloud/http_api.py @@ -18,7 +18,7 @@ from homeassistant.components.google_assistant import smart_home as google_sh from .const import ( DOMAIN, REQUEST_TIMEOUT, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, - PREF_GOOGLE_ALLOW_UNLOCK) + PREF_GOOGLE_ALLOW_UNLOCK, InvalidTrustedNetworks) _LOGGER = logging.getLogger(__name__) @@ -58,7 +58,11 @@ SCHEMA_WS_HOOK_DELETE = websocket_api.BASE_COMMAND_MESSAGE_SCHEMA.extend({ }) -_CLOUD_ERRORS = {} +_CLOUD_ERRORS = { + InvalidTrustedNetworks: + (500, 'Remote UI not compatible with 127.0.0.1/::1' + ' as a trusted network.') +} async def async_setup(hass): @@ -106,7 +110,9 @@ async def async_setup(hass): auth.PasswordChangeRequired: (400, 'Password change required.'), asyncio.TimeoutError: - (502, 'Unable to reach the Home Assistant cloud.') + (502, 'Unable to reach the Home Assistant cloud.'), + aiohttp.ClientError: + (500, 'Error making internal request'), }) @@ -120,12 +126,7 @@ def _handle_cloud_errors(handler): return result except Exception as err: # pylint: disable=broad-except - err_info = _CLOUD_ERRORS.get(err.__class__) - if err_info is None: - _LOGGER.exception( - "Unexpected error processing request for %s", request.path) - err_info = (502, 'Unexpected error: {}'.format(err)) - status, msg = err_info + status, msg = _process_cloud_exception(err, request.path) return view.json_message( msg, status_code=status, message_code=err.__class__.__name__.lower()) @@ -133,6 +134,31 @@ def _handle_cloud_errors(handler): return error_handler +def _ws_handle_cloud_errors(handler): + """Websocket decorator to handle auth errors.""" + @wraps(handler) + async def error_handler(hass, connection, msg): + """Handle exceptions that raise from the wrapped handler.""" + try: + return await handler(hass, connection, msg) + + except Exception as err: # pylint: disable=broad-except + err_status, err_msg = _process_cloud_exception(err, msg['type']) + connection.send_error(msg['id'], err_status, err_msg) + + return error_handler + + +def _process_cloud_exception(exc, where): + """Process a cloud exception.""" + err_info = _CLOUD_ERRORS.get(exc.__class__) + if err_info is None: + _LOGGER.exception( + "Unexpected error processing request for %s", where) + err_info = (502, 'Unexpected error: {}'.format(exc)) + return err_info + + class GoogleActionsSyncView(HomeAssistantView): """Trigger a Google Actions Smart Home Sync.""" @@ -295,26 +321,6 @@ def _require_cloud_login(handler): return with_cloud_auth -def _handle_aiohttp_errors(handler): - """Websocket decorator that handlers aiohttp errors. - - Can only wrap async handlers. - """ - @wraps(handler) - async def with_error_handling(hass, connection, msg): - """Handle aiohttp errors.""" - try: - await handler(hass, connection, msg) - except asyncio.TimeoutError: - connection.send_message(websocket_api.error_message( - msg['id'], 'timeout', 'Command timed out.')) - except aiohttp.ClientError: - connection.send_message(websocket_api.error_message( - msg['id'], 'unknown', 'Error making request.')) - - return with_error_handling - - @_require_cloud_login @websocket_api.async_response async def websocket_subscription(hass, connection, msg): @@ -363,7 +369,7 @@ async def websocket_update_prefs(hass, connection, msg): @_require_cloud_login @websocket_api.async_response -@_handle_aiohttp_errors +@_ws_handle_cloud_errors async def websocket_hook_create(hass, connection, msg): """Handle request for account info.""" cloud = hass.data[DOMAIN] @@ -373,6 +379,7 @@ async def websocket_hook_create(hass, connection, msg): @_require_cloud_login @websocket_api.async_response +@_ws_handle_cloud_errors async def websocket_hook_delete(hass, connection, msg): """Handle request for account info.""" cloud = hass.data[DOMAIN] @@ -417,25 +424,27 @@ def _account_data(cloud): @_require_cloud_login @websocket_api.async_response +@_ws_handle_cloud_errors @websocket_api.websocket_command({ 'type': 'cloud/remote/connect' }) async def websocket_remote_connect(hass, connection, msg): """Handle request for connect remote.""" cloud = hass.data[DOMAIN] - await cloud.remote.connect() await cloud.client.prefs.async_update(remote_enabled=True) + await cloud.remote.connect() connection.send_result(msg['id'], _account_data(cloud)) @_require_cloud_login @websocket_api.async_response +@_ws_handle_cloud_errors @websocket_api.websocket_command({ 'type': 'cloud/remote/disconnect' }) async def websocket_remote_disconnect(hass, connection, msg): """Handle request for disconnect remote.""" cloud = hass.data[DOMAIN] - await cloud.remote.disconnect() await cloud.client.prefs.async_update(remote_enabled=False) + await cloud.remote.disconnect() connection.send_result(msg['id'], _account_data(cloud)) diff --git a/homeassistant/components/cloud/prefs.py b/homeassistant/components/cloud/prefs.py index 16ff8f0c213..b0244f6b1fb 100644 --- a/homeassistant/components/cloud/prefs.py +++ b/homeassistant/components/cloud/prefs.py @@ -1,7 +1,10 @@ """Preference management for cloud.""" +from ipaddress import ip_address + from .const import ( DOMAIN, PREF_ENABLE_ALEXA, PREF_ENABLE_GOOGLE, PREF_ENABLE_REMOTE, - PREF_GOOGLE_ALLOW_UNLOCK, PREF_CLOUDHOOKS, PREF_CLOUD_USER) + PREF_GOOGLE_ALLOW_UNLOCK, PREF_CLOUDHOOKS, PREF_CLOUD_USER, + InvalidTrustedNetworks) STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 @@ -13,6 +16,7 @@ class CloudPreferences: def __init__(self, hass): """Initialize cloud prefs.""" + self._hass = hass self._store = hass.helpers.storage.Store(STORAGE_VERSION, STORAGE_KEY) self._prefs = None @@ -48,6 +52,9 @@ class CloudPreferences: if value is not _UNDEF: self._prefs[key] = value + if remote_enabled is True and self._has_local_trusted_network: + raise InvalidTrustedNetworks + await self._store.async_save(self._prefs) def as_dict(self): @@ -57,7 +64,15 @@ class CloudPreferences: @property def remote_enabled(self): """Return if remote is enabled on start.""" - return self._prefs.get(PREF_ENABLE_REMOTE, False) + enabled = self._prefs.get(PREF_ENABLE_REMOTE, False) + + if not enabled: + return False + + if self._has_local_trusted_network: + return False + + return True @property def alexa_enabled(self): @@ -83,3 +98,19 @@ class CloudPreferences: def cloud_user(self) -> str: """Return ID from Home Assistant Cloud system user.""" return self._prefs.get(PREF_CLOUD_USER) + + @property + def _has_local_trusted_network(self) -> bool: + """Return if we allow localhost to bypass auth.""" + local4 = ip_address('127.0.0.1') + local6 = ip_address('::1') + + for prv in self._hass.auth.auth_providers: + if prv.type != 'trusted_networks': + continue + + for network in prv.trusted_networks: + if local4 in network or local6 in network: + return True + + return False diff --git a/tests/components/cloud/test_http_api.py b/tests/components/cloud/test_http_api.py index 3ab4b1030fa..6c50a158cad 100644 --- a/tests/components/cloud/test_http_api.py +++ b/tests/components/cloud/test_http_api.py @@ -7,6 +7,7 @@ from jose import jwt from hass_nabucasa.auth import Unauthenticated, UnknownError from hass_nabucasa.const import STATE_CONNECTED +from homeassistant.auth.providers import trusted_networks as tn_auth from homeassistant.components.cloud.const import ( PREF_ENABLE_GOOGLE, PREF_ENABLE_ALEXA, PREF_GOOGLE_ALLOW_UNLOCK, DOMAIN) @@ -589,3 +590,101 @@ async def test_disabling_remote(hass, hass_ws_client, setup_api, assert not cloud.client.remote_autostart assert len(mock_disconnect.mock_calls) == 1 + + +async def test_enabling_remote_trusted_networks_local4( + hass, hass_ws_client, setup_api, mock_cloud_login): + """Test we cannot enable remote UI when trusted networks active.""" + hass.auth._providers[('trusted_networks', None)] = \ + tn_auth.TrustedNetworksAuthProvider( + hass, None, tn_auth.CONFIG_SCHEMA({ + 'type': 'trusted_networks', + 'trusted_networks': [ + '127.0.0.1' + ] + }) + ) + + client = await hass_ws_client(hass) + + with patch( + 'hass_nabucasa.remote.RemoteUI.connect', + side_effect=AssertionError + ) as mock_connect: + await client.send_json({ + 'id': 5, + 'type': 'cloud/remote/connect', + }) + response = await client.receive_json() + + assert not response['success'] + assert response['error']['code'] == 500 + assert response['error']['message'] == \ + 'Remote UI not compatible with 127.0.0.1/::1 as a trusted network.' + + assert len(mock_connect.mock_calls) == 0 + + +async def test_enabling_remote_trusted_networks_local6( + hass, hass_ws_client, setup_api, mock_cloud_login): + """Test we cannot enable remote UI when trusted networks active.""" + hass.auth._providers[('trusted_networks', None)] = \ + tn_auth.TrustedNetworksAuthProvider( + hass, None, tn_auth.CONFIG_SCHEMA({ + 'type': 'trusted_networks', + 'trusted_networks': [ + '::1' + ] + }) + ) + + client = await hass_ws_client(hass) + + with patch( + 'hass_nabucasa.remote.RemoteUI.connect', + side_effect=AssertionError + ) as mock_connect: + await client.send_json({ + 'id': 5, + 'type': 'cloud/remote/connect', + }) + response = await client.receive_json() + + assert not response['success'] + assert response['error']['code'] == 500 + assert response['error']['message'] == \ + 'Remote UI not compatible with 127.0.0.1/::1 as a trusted network.' + + assert len(mock_connect.mock_calls) == 0 + + +async def test_enabling_remote_trusted_networks_other( + hass, hass_ws_client, setup_api, mock_cloud_login): + """Test we cannot enable remote UI when trusted networks active.""" + hass.auth._providers[('trusted_networks', None)] = \ + tn_auth.TrustedNetworksAuthProvider( + hass, None, tn_auth.CONFIG_SCHEMA({ + 'type': 'trusted_networks', + 'trusted_networks': [ + '192.168.0.0/24' + ] + }) + ) + + client = await hass_ws_client(hass) + cloud = hass.data[DOMAIN] + + with patch( + 'hass_nabucasa.remote.RemoteUI.connect', + return_value=mock_coro() + ) as mock_connect: + await client.send_json({ + 'id': 5, + 'type': 'cloud/remote/connect', + }) + response = await client.receive_json() + + assert response['success'] + assert cloud.client.remote_autostart + + assert len(mock_connect.mock_calls) == 1 From 7a88c58ffa54c19965c7726941f8e106849a3ce6 Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Sat, 16 Mar 2019 02:19:32 -0400 Subject: [PATCH 269/291] Beta Fix: FFMPEG and Stream component (#22091) * remove stream_source from ffmpeg and onvif and add to generic ip cam * fix tests --- homeassistant/components/camera/ffmpeg.py | 5 ----- homeassistant/components/camera/generic.py | 8 ++++++++ homeassistant/components/camera/onvif.py | 5 ----- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/camera/ffmpeg.py b/homeassistant/components/camera/ffmpeg.py index 83ffdd499e9..db9e73f3e1b 100644 --- a/homeassistant/components/camera/ffmpeg.py +++ b/homeassistant/components/camera/ffmpeg.py @@ -76,8 +76,3 @@ class FFmpegCamera(Camera): def name(self): """Return the name of this camera.""" return self._name - - @property - def stream_source(self): - """Return the source of the stream.""" - return self._input diff --git a/homeassistant/components/camera/generic.py b/homeassistant/components/camera/generic.py index ae7e849c234..c8d6721ac18 100644 --- a/homeassistant/components/camera/generic.py +++ b/homeassistant/components/camera/generic.py @@ -28,12 +28,14 @@ _LOGGER = logging.getLogger(__name__) CONF_CONTENT_TYPE = 'content_type' CONF_LIMIT_REFETCH_TO_URL_CHANGE = 'limit_refetch_to_url_change' CONF_STILL_IMAGE_URL = 'still_image_url' +CONF_STREAM_SOURCE = 'stream_source' CONF_FRAMERATE = 'framerate' DEFAULT_NAME = 'Generic Camera' PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_STILL_IMAGE_URL): cv.template, + vol.Optional(CONF_STREAM_SOURCE, default=None): vol.Any(None, cv.string), vol.Optional(CONF_AUTHENTICATION, default=HTTP_BASIC_AUTHENTICATION): vol.In([HTTP_BASIC_AUTHENTICATION, HTTP_DIGEST_AUTHENTICATION]), vol.Optional(CONF_LIMIT_REFETCH_TO_URL_CHANGE, default=False): cv.boolean, @@ -62,6 +64,7 @@ class GenericCamera(Camera): self._authentication = device_info.get(CONF_AUTHENTICATION) self._name = device_info.get(CONF_NAME) self._still_image_url = device_info[CONF_STILL_IMAGE_URL] + self._stream_source = device_info[CONF_STREAM_SOURCE] 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] @@ -141,3 +144,8 @@ class GenericCamera(Camera): def name(self): """Return the name of this device.""" return self._name + + @property + def stream_source(self): + """Return the source of the stream.""" + return self._stream_source diff --git a/homeassistant/components/camera/onvif.py b/homeassistant/components/camera/onvif.py index b0bd029a80c..da0bae7c50b 100644 --- a/homeassistant/components/camera/onvif.py +++ b/homeassistant/components/camera/onvif.py @@ -230,8 +230,3 @@ class ONVIFHassCamera(Camera): def name(self): """Return the name of this camera.""" return self._name - - @property - def stream_source(self): - """Return the source of the stream.""" - return self._input From 7b224dde23b12acdc6d75647c49ea1c480693b59 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 15 Mar 2019 23:20:19 -0700 Subject: [PATCH 270/291] Bumped version to 0.90.0b3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 8fa218efd86..d0c6a72f5e5 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 90 -PATCH_VERSION = '0b2' +PATCH_VERSION = '0b3' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 16ac1d4600b5461969457251be3d10303ee24eef Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 16 Mar 2019 23:23:28 -0700 Subject: [PATCH 271/291] Updated frontend to 20190316.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 5d5585ddd23..286ece850a6 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -21,7 +21,7 @@ from homeassistant.loader import bind_hass from .storage import async_setup_frontend_storage -REQUIREMENTS = ['home-assistant-frontend==20190315.1'] +REQUIREMENTS = ['home-assistant-frontend==20190316.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 22509e84f7c..4feca7efeba 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -554,7 +554,7 @@ hole==0.3.0 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190315.1 +home-assistant-frontend==20190316.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 752ee21d614..38b478f78b2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -126,7 +126,7 @@ hdate==0.8.7 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190315.1 +home-assistant-frontend==20190316.0 # homeassistant.components.homekit_controller homekit[IP]==0.13.0 From f21856418518811bb9a002202e49535dfeeb852f Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Sun, 17 Mar 2019 02:16:05 -0400 Subject: [PATCH 272/291] delete previously removed service option from services yaml (#22123) --- homeassistant/components/camera/services.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/homeassistant/components/camera/services.yaml b/homeassistant/components/camera/services.yaml index ec00ce3ef5c..575f1fe76f7 100644 --- a/homeassistant/components/camera/services.yaml +++ b/homeassistant/components/camera/services.yaml @@ -50,9 +50,6 @@ play_stream: format: description: (Optional) Stream format supported by media player. example: 'hls' - keepalive: - description: (Optional) Keep the stream worker alive for fast access. - example: 'true' local_file_update_file_path: description: Update the file_path for a local_file camera. From 872ee3eb210cbb404d5be897ca00cb51ce66ac42 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 16 Mar 2019 23:26:48 -0700 Subject: [PATCH 273/291] Bumped version to 0.90.0b4 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index d0c6a72f5e5..1838e02eb23 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 90 -PATCH_VERSION = '0b3' +PATCH_VERSION = '0b4' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From c37dcacf54f9cf127144712f02d77a5c25bb2faf Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 18 Mar 2019 16:54:31 -0700 Subject: [PATCH 274/291] Updated frontend to 20190318.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 286ece850a6..96619face25 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -21,7 +21,7 @@ from homeassistant.loader import bind_hass from .storage import async_setup_frontend_storage -REQUIREMENTS = ['home-assistant-frontend==20190316.0'] +REQUIREMENTS = ['home-assistant-frontend==20190318.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 4feca7efeba..3138d3754ae 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -554,7 +554,7 @@ hole==0.3.0 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190316.0 +home-assistant-frontend==20190318.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 38b478f78b2..cfdd432baa7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -126,7 +126,7 @@ hdate==0.8.7 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190316.0 +home-assistant-frontend==20190318.0 # homeassistant.components.homekit_controller homekit[IP]==0.13.0 From 22624715a9af7bb2fccda4ecc685f44b26fb3c53 Mon Sep 17 00:00:00 2001 From: Jason Hu Date: Sun, 17 Mar 2019 03:42:49 -0700 Subject: [PATCH 275/291] Remove hass.config from aws_lambda notify payload (#22125) --- homeassistant/components/notify/aws_lambda.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/homeassistant/components/notify/aws_lambda.py b/homeassistant/components/notify/aws_lambda.py index 28fedf6434d..17df1ba8f5a 100644 --- a/homeassistant/components/notify/aws_lambda.py +++ b/homeassistant/components/notify/aws_lambda.py @@ -39,8 +39,7 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def get_service(hass, config, discovery_info=None): """Get the AWS Lambda notification service.""" - context_str = json.dumps({'hass': hass.config.as_dict(), - 'custom': config[CONF_CONTEXT]}, cls=JSONEncoder) + context_str = json.dumps({'custom': config[CONF_CONTEXT]}, cls=JSONEncoder) context_b64 = base64.b64encode(context_str.encode('utf-8')) context = context_b64.decode('utf-8') From cc00f3cd2ec0ae74dd256aa97072374434baf1de Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sun, 17 Mar 2019 19:13:06 -0700 Subject: [PATCH 276/291] Allow non-admins to listen to certain events (#22137) --- .../components/websocket_api/commands.py | 39 +++++++--- .../components/websocket_api/permissions.py | 23 ++++++ .../components/websocket_api/test_commands.py | 73 +++++++++++++++++++ 3 files changed, 125 insertions(+), 10 deletions(-) create mode 100644 homeassistant/components/websocket_api/permissions.py diff --git a/homeassistant/components/websocket_api/commands.py b/homeassistant/components/websocket_api/commands.py index b64fac0ed51..32bbd90aad1 100644 --- a/homeassistant/components/websocket_api/commands.py +++ b/homeassistant/components/websocket_api/commands.py @@ -1,7 +1,9 @@ """Commands part of Websocket API.""" import voluptuous as vol -from homeassistant.const import MATCH_ALL, EVENT_TIME_CHANGED +from homeassistant.auth.permissions.const import POLICY_READ +from homeassistant.const import ( + MATCH_ALL, EVENT_TIME_CHANGED, EVENT_STATE_CHANGED) from homeassistant.core import callback, DOMAIN as HASS_DOMAIN from homeassistant.exceptions import Unauthorized, ServiceNotFound, \ HomeAssistantError @@ -42,20 +44,37 @@ def handle_subscribe_events(hass, connection, msg): Async friendly. """ - if not connection.user.is_admin: + from .permissions import SUBSCRIBE_WHITELIST + + event_type = msg['event_type'] + + if (event_type not in SUBSCRIBE_WHITELIST and + not connection.user.is_admin): raise Unauthorized - async def forward_events(event): - """Forward events to websocket.""" - if event.event_type == EVENT_TIME_CHANGED: - return + if event_type == EVENT_STATE_CHANGED: + @callback + def forward_events(event): + """Forward state changed events to websocket.""" + if not connection.user.permissions.check_entity( + event.data['entity_id'], POLICY_READ): + return - connection.send_message(messages.event_message( - msg['id'], event.as_dict() - )) + connection.send_message(messages.event_message(msg['id'], event)) + + else: + @callback + def forward_events(event): + """Forward events to websocket.""" + if event.event_type == EVENT_TIME_CHANGED: + return + + connection.send_message(messages.event_message( + msg['id'], event.as_dict() + )) connection.subscriptions[msg['id']] = hass.bus.async_listen( - msg['event_type'], forward_events) + event_type, forward_events) connection.send_message(messages.result_message(msg['id'])) diff --git a/homeassistant/components/websocket_api/permissions.py b/homeassistant/components/websocket_api/permissions.py new file mode 100644 index 00000000000..b98b21d184e --- /dev/null +++ b/homeassistant/components/websocket_api/permissions.py @@ -0,0 +1,23 @@ +"""Permission constants for the websocket API. + +Separate file to avoid circular imports. +""" +from homeassistant.const import ( + EVENT_COMPONENT_LOADED, + EVENT_SERVICE_REGISTERED, + EVENT_SERVICE_REMOVED, + EVENT_STATE_CHANGED, + EVENT_THEMES_UPDATED) +from homeassistant.components.persistent_notification import ( + EVENT_PERSISTENT_NOTIFICATIONS_UPDATED) + +# These are events that do not contain any sensitive data +# Except for state_changed, which is handled accordingly. +SUBSCRIBE_WHITELIST = { + EVENT_COMPONENT_LOADED, + EVENT_PERSISTENT_NOTIFICATIONS_UPDATED, + EVENT_SERVICE_REGISTERED, + EVENT_SERVICE_REMOVED, + EVENT_STATE_CHANGED, + EVENT_THEMES_UPDATED, +} diff --git a/tests/components/websocket_api/test_commands.py b/tests/components/websocket_api/test_commands.py index 8e0f751abed..4f3be31b22c 100644 --- a/tests/components/websocket_api/test_commands.py +++ b/tests/components/websocket_api/test_commands.py @@ -333,3 +333,76 @@ async def test_get_states_not_allows_nan(hass, websocket_client): msg = await websocket_client.receive_json() assert not msg['success'] assert msg['error']['code'] == const.ERR_UNKNOWN_ERROR + + +async def test_subscribe_unsubscribe_events_whitelist( + hass, websocket_client, hass_admin_user): + """Test subscribe/unsubscribe events on whitelist.""" + hass_admin_user.groups = [] + + await websocket_client.send_json({ + 'id': 5, + 'type': 'subscribe_events', + 'event_type': 'not-in-whitelist' + }) + + msg = await websocket_client.receive_json() + assert msg['id'] == 5 + assert msg['type'] == const.TYPE_RESULT + assert not msg['success'] + assert msg['error']['code'] == 'unauthorized' + + await websocket_client.send_json({ + 'id': 6, + 'type': 'subscribe_events', + 'event_type': 'themes_updated' + }) + + msg = await websocket_client.receive_json() + assert msg['id'] == 6 + assert msg['type'] == const.TYPE_RESULT + assert msg['success'] + + hass.bus.async_fire('themes_updated') + + with timeout(3, loop=hass.loop): + msg = await websocket_client.receive_json() + + assert msg['id'] == 6 + assert msg['type'] == 'event' + event = msg['event'] + assert event['event_type'] == 'themes_updated' + assert event['origin'] == 'LOCAL' + + +async def test_subscribe_unsubscribe_events_state_changed( + hass, websocket_client, hass_admin_user): + """Test subscribe/unsubscribe state_changed events.""" + hass_admin_user.groups = [] + hass_admin_user.mock_policy({ + 'entities': { + 'entity_ids': { + 'light.permitted': True + } + } + }) + + await websocket_client.send_json({ + 'id': 7, + 'type': 'subscribe_events', + 'event_type': 'state_changed' + }) + + msg = await websocket_client.receive_json() + assert msg['id'] == 7 + assert msg['type'] == const.TYPE_RESULT + assert msg['success'] + + hass.states.async_set('light.not_permitted', 'on') + hass.states.async_set('light.permitted', 'on') + + msg = await websocket_client.receive_json() + assert msg['id'] == 7 + assert msg['type'] == 'event' + assert msg['event']['event_type'] == 'state_changed' + assert msg['event']['data']['entity_id'] == 'light.permitted' From 33a70758838374b437386ffeace62d38dba039d2 Mon Sep 17 00:00:00 2001 From: WebSpider Date: Mon, 18 Mar 2019 13:54:24 +0100 Subject: [PATCH 277/291] Bump tado version (#22145) * Bump python-tado, new API endpoint * Change references of old API endpoint to new * Update REQUIREMENTS --- homeassistant/components/tado/__init__.py | 2 +- homeassistant/components/tado/device_tracker.py | 4 ++-- requirements_all.txt | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/tado/__init__.py b/homeassistant/components/tado/__init__.py index 767e29ba0b9..56fc0cb704c 100644 --- a/homeassistant/components/tado/__init__.py +++ b/homeassistant/components/tado/__init__.py @@ -10,7 +10,7 @@ from homeassistant.helpers import config_validation as cv from homeassistant.const import CONF_USERNAME, CONF_PASSWORD from homeassistant.util import Throttle -REQUIREMENTS = ['python-tado==0.2.3'] +REQUIREMENTS = ['python-tado==0.2.8'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/tado/device_tracker.py b/homeassistant/components/tado/device_tracker.py index 7812bbd812b..8804bef5616 100644 --- a/homeassistant/components/tado/device_tracker.py +++ b/homeassistant/components/tado/device_tracker.py @@ -52,9 +52,9 @@ class TadoDeviceScanner(DeviceScanner): # If there's a home_id, we need a different API URL if self.home_id is None: - self.tadoapiurl = 'https://my.tado.com/api/v2/me' + self.tadoapiurl = 'https://auth.tado.com/api/v2/me' else: - self.tadoapiurl = 'https://my.tado.com/api/v2' \ + self.tadoapiurl = 'https://auth.tado.com/api/v2' \ '/homes/{home_id}/mobileDevices' # The API URL always needs a username and password diff --git a/requirements_all.txt b/requirements_all.txt index 3138d3754ae..d07c4addef4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1394,7 +1394,7 @@ python-songpal==0.0.9.1 python-synology==0.2.0 # homeassistant.components.tado -python-tado==0.2.3 +python-tado==0.2.8 # homeassistant.components.telegram_bot python-telegram-bot==11.1.0 From 1c9b750e36c4141f20b04935840f55a92e902477 Mon Sep 17 00:00:00 2001 From: Jason Hunter Date: Mon, 18 Mar 2019 09:27:34 -0400 Subject: [PATCH 278/291] Fix resetting access token on streams with keepalive (#22148) --- homeassistant/components/stream/__init__.py | 6 ++++++ homeassistant/components/stream/core.py | 16 +++++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/stream/__init__.py b/homeassistant/components/stream/__init__.py index 1d04791a11a..3f715af0e04 100644 --- a/homeassistant/components/stream/__init__.py +++ b/homeassistant/components/stream/__init__.py @@ -120,10 +120,16 @@ class Stream: """Remove provider output stream.""" if provider.format in self._outputs: del self._outputs[provider.format] + self.check_idle() if not self._outputs: self.stop() + def check_idle(self): + """Reset access token if all providers are idle.""" + if all([p.idle for p in self._outputs.values()]): + self.access_token = None + def start(self): """Start a stream.""" if self._thread is None or not self._thread.isAlive(): diff --git a/homeassistant/components/stream/core.py b/homeassistant/components/stream/core.py index 3d6ffa0e20c..665803d38eb 100644 --- a/homeassistant/components/stream/core.py +++ b/homeassistant/components/stream/core.py @@ -43,6 +43,7 @@ class StreamOutput: def __init__(self, stream) -> None: """Initialize a stream output.""" + self.idle = False self._stream = stream self._cursor = None self._event = asyncio.Event() @@ -77,10 +78,11 @@ class StreamOutput: def get_segment(self, sequence: int = None) -> Any: """Retrieve a specific segment, or the whole list.""" + self.idle = False # Reset idle timeout if self._unsub is not None: self._unsub() - self._unsub = async_call_later(self._stream.hass, 300, self._cleanup) + self._unsub = async_call_later(self._stream.hass, 300, self._timeout) if not sequence: return self._segments @@ -109,7 +111,7 @@ class StreamOutput: # Start idle timeout when we start recieving data if self._unsub is None: self._unsub = async_call_later( - self._stream.hass, 300, self._cleanup) + self._stream.hass, 300, self._timeout) if segment is None: self._event.set() @@ -124,7 +126,15 @@ class StreamOutput: self._event.clear() @callback - def _cleanup(self, _now=None): + def _timeout(self, _now=None): + """Handle stream timeout.""" + if self._stream.keepalive: + self.idle = True + self._stream.check_idle() + else: + self._cleanup() + + def _cleanup(self): """Remove provider.""" self._segments = [] self._stream.remove_provider(self) From d75d75e49fd0b68de3deb2697b7eb8b1205085d9 Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 19 Mar 2019 00:58:48 +0100 Subject: [PATCH 279/291] Remove config check over supervisor (#22156) * Remove config check over supervisor * Fix lint * Fix tests --- homeassistant/components/hassio/__init__.py | 28 ++++---------- homeassistant/components/hassio/handler.py | 7 ---- tests/components/hassio/test_handler.py | 11 ------ tests/components/hassio/test_init.py | 43 ++++++--------------- 4 files changed, 19 insertions(+), 70 deletions(-) diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index e070c889f31..7f85c8cfc3f 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.components import SERVICE_CHECK_CONFIG +import homeassistant.config as conf_util from homeassistant.const import ( ATTR_NAME, SERVICE_HOMEASSISTANT_RESTART, SERVICE_HOMEASSISTANT_STOP) from homeassistant.core import DOMAIN as HASS_DOMAIN, callback @@ -130,23 +131,6 @@ def is_hassio(hass): return DOMAIN in hass.config.components -@bind_hass -async def async_check_config(hass): - """Check configuration over Hass.io API.""" - hassio = hass.data[DOMAIN] - - try: - result = await hassio.check_homeassistant_config() - except HassioAPIError as err: - _LOGGER.error("Error on Hass.io API: %s", err) - raise HomeAssistantError() from None - else: - if result['result'] == "error": - return result['message'] - - return None - - async def async_setup(hass, config): """Set up the Hass.io component.""" # Check local setup @@ -259,9 +243,13 @@ async def async_setup(hass, config): await hassio.stop_homeassistant() return - error = await async_check_config(hass) - if error: - _LOGGER.error(error) + try: + errors = await conf_util.async_check_ha_config_file(hass) + except HomeAssistantError: + return + + if errors: + _LOGGER.error(errors) hass.components.persistent_notification.async_create( "Config error. See dev-info panel for details.", "Config validating", "{0}.check_config".format(HASS_DOMAIN)) diff --git a/homeassistant/components/hassio/handler.py b/homeassistant/components/hassio/handler.py index 46e32c9f7c3..7eb3245c0df 100644 --- a/homeassistant/components/hassio/handler.py +++ b/homeassistant/components/hassio/handler.py @@ -97,13 +97,6 @@ class HassIO: """ return self.send_command("/homeassistant/stop") - def check_homeassistant_config(self): - """Check Home-Assistant config with Hass.io API. - - This method return a coroutine. - """ - return self.send_command("/homeassistant/check", timeout=600) - @_api_data def retrieve_discovery_messages(self): """Return all discovery data from Hass.io API. diff --git a/tests/components/hassio/test_handler.py b/tests/components/hassio/test_handler.py index db3917a2201..3e7b9e95d92 100644 --- a/tests/components/hassio/test_handler.py +++ b/tests/components/hassio/test_handler.py @@ -74,17 +74,6 @@ async def test_api_homeassistant_restart(hassio_handler, aioclient_mock): assert aioclient_mock.call_count == 1 -async def test_api_homeassistant_config(hassio_handler, aioclient_mock): - """Test setup with API HomeAssistant config.""" - aioclient_mock.post( - "http://127.0.0.1/homeassistant/check", json={ - 'result': 'ok', 'data': {'test': 'bla'}}) - - data = await hassio_handler.check_homeassistant_config() - assert data['data']['test'] == 'bla' - assert aioclient_mock.call_count == 1 - - async def test_api_addon_info(hassio_handler, aioclient_mock): """Test setup with API Add-on info.""" aioclient_mock.get( diff --git a/tests/components/hassio/test_init.py b/tests/components/hassio/test_init.py index 435e03a1755..1326805fc93 100644 --- a/tests/components/hassio/test_init.py +++ b/tests/components/hassio/test_init.py @@ -7,8 +7,7 @@ import pytest from homeassistant.auth.const import GROUP_ID_ADMIN from homeassistant.setup import async_setup_component -from homeassistant.components.hassio import ( - STORAGE_KEY, async_check_config) +from homeassistant.components.hassio import STORAGE_KEY from tests.common import mock_coro @@ -311,8 +310,6 @@ def test_service_calls_core(hassio_env, hass, aioclient_mock): "http://127.0.0.1/homeassistant/restart", json={'result': 'ok'}) aioclient_mock.post( "http://127.0.0.1/homeassistant/stop", json={'result': 'ok'}) - aioclient_mock.post( - "http://127.0.0.1/homeassistant/check", json={'result': 'ok'}) yield from hass.services.async_call('homeassistant', 'stop') yield from hass.async_block_till_done() @@ -322,32 +319,14 @@ def test_service_calls_core(hassio_env, hass, aioclient_mock): yield from hass.services.async_call('homeassistant', 'check_config') yield from hass.async_block_till_done() + assert aioclient_mock.call_count == 2 + + with patch( + 'homeassistant.config.async_check_ha_config_file', + return_value=mock_coro() + ) as mock_check_config: + yield from hass.services.async_call('homeassistant', 'restart') + yield from hass.async_block_till_done() + assert mock_check_config.called + 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 == 5 - - -@asyncio.coroutine -def test_check_config_ok(hassio_env, hass, aioclient_mock): - """Check Config that is okay.""" - assert (yield from async_setup_component(hass, 'hassio', {})) - - aioclient_mock.post( - "http://127.0.0.1/homeassistant/check", json={'result': 'ok'}) - - assert (yield from async_check_config(hass)) is None - - -@asyncio.coroutine -def test_check_config_fail(hassio_env, hass, aioclient_mock): - """Check Config that is wrong.""" - assert (yield from async_setup_component(hass, 'hassio', {})) - - aioclient_mock.post( - "http://127.0.0.1/homeassistant/check", json={ - 'result': 'error', 'message': "Error"}) - - assert (yield from async_check_config(hass)) == "Error" From 592edd10ef518c6687b15e9f39a31fe9652cd46f Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Tue, 19 Mar 2019 00:56:57 +0100 Subject: [PATCH 280/291] Upgrade toonapilib to 3.2.2 + lower interval (#22160) --- homeassistant/components/toon/__init__.py | 2 +- homeassistant/components/toon/binary_sensor.py | 2 +- homeassistant/components/toon/climate.py | 2 +- homeassistant/components/toon/sensor.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/toon/__init__.py b/homeassistant/components/toon/__init__.py index 0ca0a414fa5..d718b5895e4 100644 --- a/homeassistant/components/toon/__init__.py +++ b/homeassistant/components/toon/__init__.py @@ -16,7 +16,7 @@ from .const import ( CONF_CLIENT_ID, CONF_CLIENT_SECRET, CONF_DISPLAY, CONF_TENANT, DATA_TOON_CLIENT, DATA_TOON_CONFIG, DOMAIN) -REQUIREMENTS = ['toonapilib==3.2.1'] +REQUIREMENTS = ['toonapilib==3.2.2'] _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/toon/binary_sensor.py b/homeassistant/components/toon/binary_sensor.py index a50a67085ec..694b7d1d033 100644 --- a/homeassistant/components/toon/binary_sensor.py +++ b/homeassistant/components/toon/binary_sensor.py @@ -17,7 +17,7 @@ DEPENDENCIES = ['toon'] _LOGGER = logging.getLogger(__name__) MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) -SCAN_INTERVAL = timedelta(seconds=30) +SCAN_INTERVAL = timedelta(seconds=300) async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry, diff --git a/homeassistant/components/toon/climate.py b/homeassistant/components/toon/climate.py index 13f1c1269a1..f09dc010c79 100644 --- a/homeassistant/components/toon/climate.py +++ b/homeassistant/components/toon/climate.py @@ -22,7 +22,7 @@ _LOGGER = logging.getLogger(__name__) SUPPORT_FLAGS = SUPPORT_TARGET_TEMPERATURE | SUPPORT_OPERATION_MODE MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) -SCAN_INTERVAL = timedelta(seconds=30) +SCAN_INTERVAL = timedelta(seconds=300) HA_TOON = { STATE_AUTO: 'Comfort', diff --git a/homeassistant/components/toon/sensor.py b/homeassistant/components/toon/sensor.py index e263bda9fc7..f58c8ef4840 100644 --- a/homeassistant/components/toon/sensor.py +++ b/homeassistant/components/toon/sensor.py @@ -16,7 +16,7 @@ DEPENDENCIES = ['toon'] _LOGGER = logging.getLogger(__name__) MIN_TIME_BETWEEN_UPDATES = timedelta(seconds=5) -SCAN_INTERVAL = timedelta(seconds=30) +SCAN_INTERVAL = timedelta(seconds=300) async def async_setup_entry(hass: HomeAssistantType, entry: ConfigEntry, diff --git a/requirements_all.txt b/requirements_all.txt index d07c4addef4..b13da82945b 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1707,7 +1707,7 @@ tikteck==0.4 todoist-python==7.0.17 # homeassistant.components.toon -toonapilib==3.2.1 +toonapilib==3.2.2 # homeassistant.components.alarm_control_panel.totalconnect total_connect_client==0.22 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index cfdd432baa7..5cf0720d2ad 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -298,7 +298,7 @@ srpenergy==1.0.5 statsd==3.2.1 # homeassistant.components.toon -toonapilib==3.2.1 +toonapilib==3.2.2 # homeassistant.components.camera.uvc uvcclient==0.11.0 From ad0ec663538bc88f0ad0346dfbeaa17084ab7064 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 18 Mar 2019 17:04:49 -0700 Subject: [PATCH 281/291] Bumped version to 0.90.0b5 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 1838e02eb23..5a10155d7df 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 90 -PATCH_VERSION = '0b4' +PATCH_VERSION = '0b5' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From fff6927f9ce98e7034346b5760642a6a640a9f15 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 19 Mar 2019 11:38:05 -0700 Subject: [PATCH 282/291] Updated frontend to 20190319.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 96619face25..ee779589461 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -21,7 +21,7 @@ from homeassistant.loader import bind_hass from .storage import async_setup_frontend_storage -REQUIREMENTS = ['home-assistant-frontend==20190318.0'] +REQUIREMENTS = ['home-assistant-frontend==20190319.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index b13da82945b..18452f1af82 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -554,7 +554,7 @@ hole==0.3.0 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190318.0 +home-assistant-frontend==20190319.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5cf0720d2ad..c2f117997a4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -126,7 +126,7 @@ hdate==0.8.7 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190318.0 +home-assistant-frontend==20190319.0 # homeassistant.components.homekit_controller homekit[IP]==0.13.0 From f202114ead9f29b3ac88fa4a5a03c41f94d2fb01 Mon Sep 17 00:00:00 2001 From: uchagani Date: Tue, 19 Mar 2019 03:51:42 -0400 Subject: [PATCH 283/291] bump total_connect_client to 0.24 (#22166) --- homeassistant/components/alarm_control_panel/totalconnect.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/alarm_control_panel/totalconnect.py b/homeassistant/components/alarm_control_panel/totalconnect.py index 3b0725658d4..ba8155fde93 100644 --- a/homeassistant/components/alarm_control_panel/totalconnect.py +++ b/homeassistant/components/alarm_control_panel/totalconnect.py @@ -18,7 +18,7 @@ from homeassistant.const import ( STATE_ALARM_ARMED_CUSTOM_BYPASS) -REQUIREMENTS = ['total_connect_client==0.22'] +REQUIREMENTS = ['total_connect_client==0.24'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 18452f1af82..ac407958cb8 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1710,7 +1710,7 @@ todoist-python==7.0.17 toonapilib==3.2.2 # homeassistant.components.alarm_control_panel.totalconnect -total_connect_client==0.22 +total_connect_client==0.24 # homeassistant.components.tplink_lte tp-connected==0.0.4 From b85189e6998a1d0bf4fc6ace2997c878c663566d Mon Sep 17 00:00:00 2001 From: Pascal Vizeli Date: Tue, 19 Mar 2019 15:10:30 +0100 Subject: [PATCH 284/291] Update Hass-NabuCasa 0.8 (#22177) --- homeassistant/components/cloud/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/cloud/__init__.py b/homeassistant/components/cloud/__init__.py index 9f6e678e417..ff1b2344ac8 100644 --- a/homeassistant/components/cloud/__init__.py +++ b/homeassistant/components/cloud/__init__.py @@ -24,7 +24,7 @@ from .const import ( CONF_USER_POOL_ID, DOMAIN, MODE_DEV, MODE_PROD) from .prefs import CloudPreferences -REQUIREMENTS = ['hass-nabucasa==0.7'] +REQUIREMENTS = ['hass-nabucasa==0.8'] DEPENDENCIES = ['http'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index ac407958cb8..a6d62468308 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -524,7 +524,7 @@ habitipy==0.2.0 hangups==0.4.6 # homeassistant.components.cloud -hass-nabucasa==0.7 +hass-nabucasa==0.8 # homeassistant.components.mqtt.server hbmqtt==0.9.4 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index c2f117997a4..5a357a5c0b3 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -114,7 +114,7 @@ ha-ffmpeg==1.11 hangups==0.4.6 # homeassistant.components.cloud -hass-nabucasa==0.7 +hass-nabucasa==0.8 # homeassistant.components.mqtt.server hbmqtt==0.9.4 From e6ffc790f2a82dbe0a9f9c486c3bc17bee4c1ab5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 19 Mar 2019 11:33:50 -0700 Subject: [PATCH 285/291] Always load Hass.io component on Hass.io (#22185) * Always load Hass.io component on Hass.io * Lint * Lint --- homeassistant/bootstrap.py | 22 +++++++++++++++---- .../components/discovery/__init__.py | 5 ----- tests/components/discovery/test_init.py | 16 -------------- tests/test_bootstrap.py | 11 +++++++++- 4 files changed, 28 insertions(+), 26 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 444b4a9f855..3d05eb06e6c 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -127,10 +127,7 @@ async def async_from_config_dict(config: Dict[str, Any], hass.config_entries = config_entries.ConfigEntries(hass, config) await hass.config_entries.async_initialize() - # Filter out the repeating and common config section [homeassistant] - components = set(key.split(' ')[0] for key in config.keys() - if key != core.DOMAIN) - components.update(hass.config_entries.async_domains()) + components = _get_components(hass, config) # Resolve all dependencies of all components. for component in list(components): @@ -391,3 +388,20 @@ async def async_mount_local_lib_path(config_dir: str) -> str: if lib_dir not in sys.path: sys.path.insert(0, lib_dir) return deps_dir + + +@core.callback +def _get_components(hass: core.HomeAssistant, config: Dict[str, Any]): + """Get components to set up.""" + # Filter out the repeating and common config section [homeassistant] + components = set(key.split(' ')[0] for key in config.keys() + if key != core.DOMAIN) + + # Add config entry domains + components.update(hass.config_entries.async_domains()) + + # Make sure the Hass.io component is loaded + if 'HASSIO' in os.environ: + components.add('hassio') + + return components diff --git a/homeassistant/components/discovery/__init__.py b/homeassistant/components/discovery/__init__.py index 6a561e570c5..d4816213f50 100644 --- a/homeassistant/components/discovery/__init__.py +++ b/homeassistant/components/discovery/__init__.py @@ -9,7 +9,6 @@ loaded before the EVENT_PLATFORM_DISCOVERED is fired. import json from datetime import timedelta import logging -import os import voluptuous as vol @@ -199,10 +198,6 @@ async def async_setup(hass, config): """Schedule the first discovery when Home Assistant starts up.""" async_track_point_in_utc_time(hass, scan_devices, dt_util.utcnow()) - # Discovery for local services - if 'HASSIO' in os.environ: - hass.async_create_task(new_service_found(SERVICE_HASSIO, {})) - hass.bus.async_listen_once(EVENT_HOMEASSISTANT_START, schedule_first) return True diff --git a/tests/components/discovery/test_init.py b/tests/components/discovery/test_init.py index d4566bc0b03..28d30a9167f 100644 --- a/tests/components/discovery/test_init.py +++ b/tests/components/discovery/test_init.py @@ -1,6 +1,5 @@ """The tests for the discovery component.""" import asyncio -import os from unittest.mock import patch, MagicMock import pytest @@ -142,21 +141,6 @@ def test_discover_duplicates(hass): SERVICE_NO_PLATFORM_COMPONENT, BASE_CONFIG) -@asyncio.coroutine -def test_load_component_hassio(hass): - """Test load hassio component.""" - def discover(netdisco): - """Fake discovery.""" - return [] - - with patch.dict(os.environ, {'HASSIO': "FAKE_HASSIO"}), \ - patch('homeassistant.components.hassio.async_setup', - return_value=mock_coro(return_value=True)) as mock_hassio: - yield from mock_discovery(hass, discover) - - assert mock_hassio.called - - async def test_discover_config_flow(hass): """Test discovery triggering a config flow.""" discovery_info = { diff --git a/tests/test_bootstrap.py b/tests/test_bootstrap.py index 978b0b9d450..1b62c5244e4 100644 --- a/tests/test_bootstrap.py +++ b/tests/test_bootstrap.py @@ -34,7 +34,7 @@ def test_from_config_file(hass): } with patch_yaml_files(files, True): - yield from bootstrap.async_from_config_file('config.yaml') + yield from bootstrap.async_from_config_file('config.yaml', hass) assert components == hass.config.components @@ -103,3 +103,12 @@ async def test_async_from_config_file_not_mount_deps_folder(loop): await bootstrap.async_from_config_file('mock-path', hass) assert len(mock_mount.mock_calls) == 0 + + +async def test_load_hassio(hass): + """Test that we load Hass.io component.""" + with patch.dict(os.environ, {}, clear=True): + assert bootstrap._get_components(hass, {}) == set() + + with patch.dict(os.environ, {'HASSIO': '1'}): + assert bootstrap._get_components(hass, {}) == {'hassio'} From b8f246356aa0a98d853817697a9f4c8c692dc68f Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 19 Mar 2019 11:41:08 -0700 Subject: [PATCH 286/291] Bumped version to 0.90.0b6 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 5a10155d7df..fa841c3f246 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 90 -PATCH_VERSION = '0b5' +PATCH_VERSION = '0b6' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 268d129ea9676800ce56f46105b6b3127e4fe62c Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 19 Mar 2019 14:04:19 -0700 Subject: [PATCH 287/291] Updated frontend to 20190319.1 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index ee779589461..5c061a7f857 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -21,7 +21,7 @@ from homeassistant.loader import bind_hass from .storage import async_setup_frontend_storage -REQUIREMENTS = ['home-assistant-frontend==20190319.0'] +REQUIREMENTS = ['home-assistant-frontend==20190319.1'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index a6d62468308..7aeed6d9278 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -554,7 +554,7 @@ hole==0.3.0 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190319.0 +home-assistant-frontend==20190319.1 # homeassistant.components.zwave homeassistant-pyozw==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5a357a5c0b3..1875248a7a0 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -126,7 +126,7 @@ hdate==0.8.7 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190319.0 +home-assistant-frontend==20190319.1 # homeassistant.components.homekit_controller homekit[IP]==0.13.0 From 7cf1f4f9fe4228645f9f876cfebea2181b6846e4 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 19 Mar 2019 16:48:31 -0700 Subject: [PATCH 288/291] Bumped version to 0.90.0b7 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index fa841c3f246..8a405b9a7bd 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 90 -PATCH_VERSION = '0b6' +PATCH_VERSION = '0b7' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 1bf49ce5a3712ff500904c446fa667764b3913e0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 20 Mar 2019 07:45:09 -0700 Subject: [PATCH 289/291] Updated frontend to 20190320.0 --- homeassistant/components/frontend/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/frontend/__init__.py b/homeassistant/components/frontend/__init__.py index 5c061a7f857..6e05299ec52 100644 --- a/homeassistant/components/frontend/__init__.py +++ b/homeassistant/components/frontend/__init__.py @@ -21,7 +21,7 @@ from homeassistant.loader import bind_hass from .storage import async_setup_frontend_storage -REQUIREMENTS = ['home-assistant-frontend==20190319.1'] +REQUIREMENTS = ['home-assistant-frontend==20190320.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 7aeed6d9278..87e9f53bba9 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -554,7 +554,7 @@ hole==0.3.0 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190319.1 +home-assistant-frontend==20190320.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1875248a7a0..935bc5689e1 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -126,7 +126,7 @@ hdate==0.8.7 holidays==0.9.9 # homeassistant.components.frontend -home-assistant-frontend==20190319.1 +home-assistant-frontend==20190320.0 # homeassistant.components.homekit_controller homekit[IP]==0.13.0 From d4cd39e43efd223ccc4430ae7f63527e8d5c7837 Mon Sep 17 00:00:00 2001 From: Penny Wood Date: Wed, 20 Mar 2019 22:49:27 +0800 Subject: [PATCH 290/291] Fixed typing errors (#22207) --- homeassistant/bootstrap.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 3d05eb06e6c..d532d9cdb86 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -5,7 +5,7 @@ import os import sys from time import time from collections import OrderedDict -from typing import Any, Optional, Dict +from typing import Any, Optional, Dict, Set import voluptuous as vol @@ -391,14 +391,15 @@ async def async_mount_local_lib_path(config_dir: str) -> str: @core.callback -def _get_components(hass: core.HomeAssistant, config: Dict[str, Any]): +def _get_components(hass: core.HomeAssistant, + config: Dict[str, Any]) -> Set[str]: """Get components to set up.""" # Filter out the repeating and common config section [homeassistant] components = set(key.split(' ')[0] for key in config.keys() if key != core.DOMAIN) # Add config entry domains - components.update(hass.config_entries.async_domains()) + components.update(hass.config_entries.async_domains()) # type: ignore # Make sure the Hass.io component is loaded if 'HASSIO' in os.environ: From 9d8054e6e2bc07e513a88c79dee2d135f3124894 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 20 Mar 2019 07:51:23 -0700 Subject: [PATCH 291/291] Bumped version to 0.90.0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 8a405b9a7bd..df0146cde62 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 90 -PATCH_VERSION = '0b7' +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3)