From e404afc0d066d3b0a5853991112c466951337726 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 13 Feb 2019 20:37:46 -0800 Subject: [PATCH 01/45] Bumped version to 0.88.0b0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 1a3d4e2e455..84d1350a80f 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 88 -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 6c574a4eb4d1145d99ff4fec9d7a3d92e81aafd2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 15 Feb 2019 10:08:59 -0800 Subject: [PATCH 02/45] Updated frontend to 20190215.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 be2551457d0..d35514160c9 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==20190213.0'] +REQUIREMENTS = ['home-assistant-frontend==20190215.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index b91036fa1f6..689fe739eba 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==20190213.0 +home-assistant-frontend==20190215.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a4ebc2301e1..a5b45a585b9 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==20190213.0 +home-assistant-frontend==20190215.0 # homeassistant.components.homekit_controller homekit==0.12.2 From 2b6b922e3f18e8211cfefd35bd0b1ab0f95819a4 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Fri, 15 Feb 2019 13:14:58 -0500 Subject: [PATCH 03/45] Set ZHA device availability on new join (#21066) * set availability on device join * fix new join test --- homeassistant/components/zha/core/gateway.py | 3 +++ tests/components/zha/common.py | 3 +-- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 02ed1d73699..ff3c374a850 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -164,6 +164,9 @@ class ZHAGateway: device_entity = _create_device_entity(zha_device) await self._component.async_add_entities([device_entity]) + if is_new_join: + zha_device.update_available(True) + async def _async_process_endpoint( self, endpoint_id, endpoint, discovery_infos, device, zha_device, is_new_join): diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index 1a923849ce5..f0e1aa701e7 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -1,7 +1,6 @@ """Common test objects.""" import time from unittest.mock import patch, Mock -from homeassistant.const import STATE_UNAVAILABLE from homeassistant.components.zha.core.helpers import convert_ieee from homeassistant.components.zha.core.const import ( DATA_ZHA, DATA_ZHA_CONFIG, DATA_ZHA_DISPATCHERS, DATA_ZHA_BRIDGE_ID @@ -191,4 +190,4 @@ async def async_test_device_join( cluster = zigpy_device.endpoints.get(1).in_clusters[cluster_id] entity_id = make_entity_id( domain, zigpy_device, cluster, use_suffix=device_type is None) - assert hass.states.get(entity_id).state == STATE_UNAVAILABLE + assert hass.states.get(entity_id) is not None From 8973852a2e5257bfee5d63a0e2e9450bbf59e5f0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 14 Feb 2019 12:06:25 -0800 Subject: [PATCH 04/45] Fix pushover schema --- homeassistant/components/notify/pushover.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/notify/pushover.py b/homeassistant/components/notify/pushover.py index 3ec0b27e7c4..b249ca804b3 100644 --- a/homeassistant/components/notify/pushover.py +++ b/homeassistant/components/notify/pushover.py @@ -10,7 +10,7 @@ import voluptuous as vol from homeassistant.components.notify import ( ATTR_TITLE, ATTR_TITLE_DEFAULT, ATTR_TARGET, ATTR_DATA, - BaseNotificationService) + BaseNotificationService, PLATFORM_SCHEMA) from homeassistant.const import CONF_API_KEY import homeassistant.helpers.config_validation as cv @@ -20,7 +20,7 @@ _LOGGER = logging.getLogger(__name__) CONF_USER_KEY = 'user_key' -PLATFORM_SCHEMA = cv.PLATFORM_SCHEMA.extend({ +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ vol.Required(CONF_USER_KEY): cv.string, vol.Required(CONF_API_KEY): cv.string, }) From dc606b40ac6661c5e0370d72e6251edfd75e97d5 Mon Sep 17 00:00:00 2001 From: Phil Hawthorne Date: Sat, 16 Feb 2019 05:25:03 +1100 Subject: [PATCH 05/45] Set uvloop version consistent with hass.io (#21080) This sets the uvloop version in Docker containers to 0.11.3, which is the same version that hass.io uses. uvloop might be causing issues with some Docker containers on some host systems, as reported in #20829 --- Dockerfile | 2 +- virtualization/Docker/Dockerfile.dev | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Dockerfile b/Dockerfile index c863ff9433c..aa9415fd1e0 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,7 +27,7 @@ COPY requirements_all.txt requirements_all.txt # Uninstall enum34 because some dependencies install it but breaks Python 3.4+. # See PR #8103 for more info. RUN pip3 install --no-cache-dir -r requirements_all.txt && \ - pip3 install --no-cache-dir mysqlclient psycopg2 uvloop cchardet cython tensorflow + pip3 install --no-cache-dir mysqlclient psycopg2 uvloop==0.11.3 cchardet cython tensorflow # Copy source COPY . . diff --git a/virtualization/Docker/Dockerfile.dev b/virtualization/Docker/Dockerfile.dev index de460319bc2..03d6ab47c24 100644 --- a/virtualization/Docker/Dockerfile.dev +++ b/virtualization/Docker/Dockerfile.dev @@ -29,7 +29,7 @@ COPY requirements_all.txt requirements_all.txt # Uninstall enum34 because some dependencies install it but breaks Python 3.4+. # See PR #8103 for more info. RUN pip3 install --no-cache-dir -r requirements_all.txt && \ - pip3 install --no-cache-dir mysqlclient psycopg2 uvloop cchardet cython + pip3 install --no-cache-dir mysqlclient psycopg2 uvloop==0.11.3 cchardet cython # BEGIN: Development additions From 2de65af3faf68ab335ea7c381f8a8ca826567cbf Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Thu, 14 Feb 2019 15:54:38 -0800 Subject: [PATCH 06/45] Check against unlinked user (#21081) --- homeassistant/components/person/__init__.py | 8 ++++-- tests/components/person/test_init.py | 29 +++++++++++++++++++++ 2 files changed, 35 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index d11d4208dc8..63e588f911b 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -134,22 +134,26 @@ class PersonManager: entities.append(Person(person_conf, False)) + # To make sure IDs don't overlap between config/storage + seen_persons = set(self.config_data) + for person_conf in storage_data.values(): person_id = person_conf[CONF_ID] user_id = person_conf[CONF_USER_ID] - if user_id in self.config_data: + if person_id in seen_persons: _LOGGER.error( "Skipping adding person from storage with same ID as" " configuration.yaml entry: %s", person_id) continue - if user_id in seen_users: + if user_id is not None and user_id in seen_users: _LOGGER.error( "Duplicate user_id %s detected for person %s", user_id, person_id) continue + # To make sure all users have just 1 person linked. seen_users.add(user_id) entities.append(Person(person_conf, True)) diff --git a/tests/components/person/test_init.py b/tests/components/person/test_init.py index 2eacb162f8e..f2d796fb204 100644 --- a/tests/components/person/test_init.py +++ b/tests/components/person/test_init.py @@ -287,6 +287,35 @@ async def test_load_person_storage(hass, hass_admin_user, storage_setup): assert state.attributes.get(ATTR_USER_ID) == hass_admin_user.id +async def test_load_person_storage_two_nonlinked(hass, hass_storage): + """Test loading two users with both not having a user linked.""" + hass_storage[DOMAIN] = { + 'key': DOMAIN, + 'version': 1, + 'data': { + 'persons': [ + { + 'id': '1234', + 'name': 'tracked person 1', + 'user_id': None, + 'device_trackers': [] + }, + { + 'id': '5678', + 'name': 'tracked person 2', + 'user_id': None, + 'device_trackers': [] + }, + ] + } + } + await async_setup_component(hass, DOMAIN, {}) + + assert len(hass.states.async_entity_ids('person')) == 2 + assert hass.states.get('person.tracked_person_1') is not None + assert hass.states.get('person.tracked_person_2') is not None + + async def test_ws_list(hass, hass_ws_client, storage_setup): """Test listing via WS.""" manager = hass.data[DOMAIN] From da672c6593a1374ebf0d0475fcce69ebc719c585 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 15 Feb 2019 08:43:30 -0800 Subject: [PATCH 07/45] Fix hue retry crash (#21083) * Fix Hue retry crash * Fix hue retry crash * Fix tests --- homeassistant/components/hue/__init__.py | 9 ++++++--- tests/components/hue/test_init.py | 4 ++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/hue/__init__.py b/homeassistant/components/hue/__init__.py index 7618e702d04..0871d961a93 100644 --- a/homeassistant/components/hue/__init__.py +++ b/homeassistant/components/hue/__init__.py @@ -28,6 +28,8 @@ CONF_BRIDGES = "bridges" CONF_ALLOW_UNREACHABLE = 'allow_unreachable' DEFAULT_ALLOW_UNREACHABLE = False +DATA_CONFIGS = 'hue_configs' + PHUE_CONFIG_FILE = 'phue.conf' CONF_ALLOW_HUE_GROUPS = "allow_hue_groups" @@ -59,6 +61,7 @@ async def async_setup(hass, config): conf = {} hass.data[DOMAIN] = {} + hass.data[DATA_CONFIGS] = {} configured = configured_hosts(hass) # User has configured bridges @@ -71,7 +74,7 @@ async def async_setup(hass, config): host = bridge_conf[CONF_HOST] # Store config in hass.data so the config entry can find it - hass.data[DOMAIN][host] = bridge_conf + hass.data[DATA_CONFIGS][host] = bridge_conf # If configured, the bridge will be set up during config entry phase if host in configured: @@ -96,7 +99,7 @@ async def async_setup(hass, config): async def async_setup_entry(hass, entry): """Set up a bridge from a config entry.""" host = entry.data['host'] - config = hass.data[DOMAIN].get(host) + config = hass.data[DATA_CONFIGS].get(host) if config is None: allow_unreachable = DEFAULT_ALLOW_UNREACHABLE @@ -106,11 +109,11 @@ async def async_setup_entry(hass, entry): allow_groups = config[CONF_ALLOW_HUE_GROUPS] bridge = HueBridge(hass, entry, allow_unreachable, allow_groups) - hass.data[DOMAIN][host] = bridge if not await bridge.async_setup(): return False + hass.data[DOMAIN][host] = bridge config = bridge.api.config device_registry = await dr.async_get_registry(hass) device_registry.async_get_or_create( diff --git a/tests/components/hue/test_init.py b/tests/components/hue/test_init.py index 1fcc092dd30..6c89995a1a1 100644 --- a/tests/components/hue/test_init.py +++ b/tests/components/hue/test_init.py @@ -39,7 +39,7 @@ async def test_setup_defined_hosts_known_auth(hass): assert len(mock_config_entries.flow.mock_calls) == 0 # Config stored for domain. - assert hass.data[hue.DOMAIN] == { + assert hass.data[hue.DATA_CONFIGS] == { '0.0.0.0': { hue.CONF_HOST: '0.0.0.0', hue.CONF_FILENAME: 'bla.conf', @@ -73,7 +73,7 @@ async def test_setup_defined_hosts_no_known_auth(hass): } # Config stored for domain. - assert hass.data[hue.DOMAIN] == { + assert hass.data[hue.DATA_CONFIGS] == { '0.0.0.0': { hue.CONF_HOST: '0.0.0.0', hue.CONF_FILENAME: 'bla.conf', From 90da9120e83831c22a30eb5bbdebdd2aa4bb1df2 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 15 Feb 2019 09:46:03 -0800 Subject: [PATCH 08/45] Update pychromecast (#21097) --- homeassistant/components/cast/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cast/__init__.py b/homeassistant/components/cast/__init__.py index 94b926795e7..5e6bd720d4b 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.0'] +REQUIREMENTS = ['pychromecast==2.5.1'] DOMAIN = 'cast' diff --git a/requirements_all.txt b/requirements_all.txt index 689fe739eba..cb23edaa232 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -956,7 +956,7 @@ pycfdns==0.0.1 pychannels==1.0.0 # homeassistant.components.cast -pychromecast==2.5.0 +pychromecast==2.5.1 # homeassistant.components.media_player.cmus pycmus==0.1.1 From 91a2c73a2cf169524de06fc1f6c9249c032c1a3d Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Fri, 15 Feb 2019 11:28:23 -0700 Subject: [PATCH 09/45] Bump aioambient to 0.1.2 (#21098) --- homeassistant/components/ambient_station/__init__.py | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 5972660c6e6..4a7864d3f7f 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -20,7 +20,7 @@ from .const import ( ATTR_LAST_DATA, CONF_APP_KEY, DATA_CLIENT, DOMAIN, TOPIC_UPDATE, TYPE_BINARY_SENSOR, TYPE_SENSOR) -REQUIREMENTS = ['aioambient==0.1.1'] +REQUIREMENTS = ['aioambient==0.1.2'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index cb23edaa232..cc4e5d3d896 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -90,7 +90,7 @@ abodepy==0.15.0 afsapi==0.0.4 # homeassistant.components.ambient_station -aioambient==0.1.1 +aioambient==0.1.2 # homeassistant.components.asuswrt aioasuswrt==1.1.20 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a5b45a585b9..fc66a4b72f6 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -31,7 +31,7 @@ PyTransportNSW==0.1.1 YesssSMS==0.2.3 # homeassistant.components.ambient_station -aioambient==0.1.1 +aioambient==0.1.2 # homeassistant.components.device_tracker.automatic aioautomatic==0.6.5 From 5cf936c777a4edb671cf77a1ac4bf50e53f9bf7a Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Fri, 15 Feb 2019 10:32:28 -0800 Subject: [PATCH 10/45] Bumped version to 0.88.0b1 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 84d1350a80f..12f394b9e03 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 88 -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 d34fe106e42e793da1b06faa9772fbafd3373da5 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 16 Feb 2019 12:00:04 -0800 Subject: [PATCH 11/45] Updated frontend to 20190216.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 d35514160c9..3db63e65a6d 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==20190215.0'] +REQUIREMENTS = ['home-assistant-frontend==20190216.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index cc4e5d3d896..af11e32e516 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==20190215.0 +home-assistant-frontend==20190216.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index fc66a4b72f6..5830085e544 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==20190215.0 +home-assistant-frontend==20190216.0 # homeassistant.components.homekit_controller homekit==0.12.2 From c91512d55df89e5b8d6830c20c62a7929ed466c1 Mon Sep 17 00:00:00 2001 From: Nick Horvath Date: Sat, 16 Feb 2019 05:18:13 -0500 Subject: [PATCH 12/45] Bump thermoworks_smoke version to get new pyrebase version (#21100) --- homeassistant/components/sensor/thermoworks_smoke.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/thermoworks_smoke.py b/homeassistant/components/sensor/thermoworks_smoke.py index e81a3974176..0c6cddd9fcd 100644 --- a/homeassistant/components/sensor/thermoworks_smoke.py +++ b/homeassistant/components/sensor/thermoworks_smoke.py @@ -17,7 +17,7 @@ from homeassistant.const import TEMP_FAHRENHEIT, CONF_EMAIL, CONF_PASSWORD,\ CONF_MONITORED_CONDITIONS, CONF_EXCLUDE, ATTR_BATTERY_LEVEL from homeassistant.helpers.entity import Entity -REQUIREMENTS = ['thermoworks_smoke==0.1.7', 'stringcase==1.2.0'] +REQUIREMENTS = ['thermoworks_smoke==0.1.8', 'stringcase==1.2.0'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index af11e32e516..508f7bb2d04 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1656,7 +1656,7 @@ temperusb==1.5.3 teslajsonpy==0.0.23 # homeassistant.components.sensor.thermoworks_smoke -thermoworks_smoke==0.1.7 +thermoworks_smoke==0.1.8 # homeassistant.components.thingspeak thingspeak==0.4.1 From bc17adda8d4e0d2ae3adc633b053c2932a047f4b Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Sun, 17 Feb 2019 04:04:56 +0000 Subject: [PATCH 13/45] Don't expose services in Utility_Meter unless tariffs are available (#20878) * only expose services when tariffs configured * don't register services multiple times --- .../components/utility_meter/__init__.py | 28 +++++++++++-------- .../components/utility_meter/sensor.py | 1 + 2 files changed, 17 insertions(+), 12 deletions(-) diff --git a/homeassistant/components/utility_meter/__init__.py b/homeassistant/components/utility_meter/__init__.py index 7d8e4ddf71b..3cf1b2fea61 100644 --- a/homeassistant/components/utility_meter/__init__.py +++ b/homeassistant/components/utility_meter/__init__.py @@ -51,6 +51,7 @@ async def async_setup(hass, config): """Set up an Utility Meter.""" component = EntityComponent(_LOGGER, DOMAIN, hass) hass.data[DATA_UTILITY] = {} + register_services = False for meter, conf in config.get(DOMAIN).items(): _LOGGER.debug("Setup %s.%s", DOMAIN, meter) @@ -80,21 +81,23 @@ async def async_setup(hass, config): }) hass.async_create_task(discovery.async_load_platform( hass, SENSOR_DOMAIN, DOMAIN, tariff_confs, config)) + register_services = True - component.async_register_entity_service( - SERVICE_RESET, SERVICE_METER_SCHEMA, - 'async_reset_meters' - ) + if register_services: + component.async_register_entity_service( + SERVICE_RESET, SERVICE_METER_SCHEMA, + 'async_reset_meters' + ) - component.async_register_entity_service( - SERVICE_SELECT_TARIFF, SERVICE_SELECT_TARIFF_SCHEMA, - 'async_select_tariff' - ) + component.async_register_entity_service( + SERVICE_SELECT_TARIFF, SERVICE_SELECT_TARIFF_SCHEMA, + 'async_select_tariff' + ) - component.async_register_entity_service( - SERVICE_SELECT_NEXT_TARIFF, SERVICE_METER_SCHEMA, - 'async_next_tariff' - ) + component.async_register_entity_service( + SERVICE_SELECT_NEXT_TARIFF, SERVICE_METER_SCHEMA, + 'async_next_tariff' + ) return True @@ -150,6 +153,7 @@ class TariffSelect(RestoreEntity): async def async_reset_meters(self): """Reset all sensors of this meter.""" + _LOGGER.debug("reset meter %s", self.entity_id) async_dispatcher_send(self.hass, SIGNAL_RESET_METER, self.entity_id) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index d3edf7d501b..a59d51d97e2 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -189,6 +189,7 @@ class UtilityMeterSensor(RestoreEntity): if self._tariff != tariff_entity_state.state: return + _LOGGER.debug("tracking source: %s", self._sensor_source_id) self._collecting = async_track_state_change( self.hass, self._sensor_source_id, self.async_reading) From 84053103f0ff7cfecb77303ea588d89420f626d3 Mon Sep 17 00:00:00 2001 From: Rohan Kapoor Date: Sat, 16 Feb 2019 21:23:09 -0800 Subject: [PATCH 14/45] Deprecate conf_update_interval (#20924) * Deprecate update_interval and replace with scan_interval * Update tests * Fix Darksky tests * Fix Darksky tests correctly This reverts commit a73384a223ba8a93c682042d9351cd5a7a399183. * Provide the default for the non deprecated option * Don't override default schema for sensors --- .../components/fastdotcom/__init__.py | 27 ++++++--- homeassistant/components/freedns/__init__.py | 7 ++- .../components/mythicbeastsdns/__init__.py | 30 +++++++--- homeassistant/components/sensor/broadlink.py | 34 +++++++---- homeassistant/components/sensor/darksky.py | 57 ++++++++++++------- homeassistant/components/sensor/fedex.py | 30 +++++++--- homeassistant/components/sensor/ups.py | 35 ++++++++---- .../components/speedtestdotnet/__init__.py | 35 ++++++++---- .../components/tellduslive/__init__.py | 27 +++++---- .../components/volvooncall/__init__.py | 43 ++++++++------ homeassistant/const.py | 4 ++ homeassistant/helpers/config_validation.py | 3 +- tests/components/freedns/test_init.py | 4 +- tests/components/sensor/test_darksky.py | 15 +++-- tests/helpers/test_config_validation.py | 18 +++++- 15 files changed, 249 insertions(+), 120 deletions(-) diff --git a/homeassistant/components/fastdotcom/__init__.py b/homeassistant/components/fastdotcom/__init__.py index a63fab76861..2e092e527c5 100644 --- a/homeassistant/components/fastdotcom/__init__.py +++ b/homeassistant/components/fastdotcom/__init__.py @@ -5,7 +5,8 @@ from datetime import timedelta import voluptuous as vol import homeassistant.helpers.config_validation as cv -from homeassistant.const import CONF_UPDATE_INTERVAL +from homeassistant.const import CONF_UPDATE_INTERVAL, CONF_SCAN_INTERVAL, \ + CONF_UPDATE_INTERVAL_INVALIDATION_VERSION from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import async_track_time_interval @@ -22,13 +23,21 @@ CONF_MANUAL = 'manual' DEFAULT_INTERVAL = timedelta(hours=1) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Optional(CONF_UPDATE_INTERVAL, default=DEFAULT_INTERVAL): - vol.All( - cv.time_period, cv.positive_timedelta - ), - vol.Optional(CONF_MANUAL, default=False): cv.boolean, - }) + DOMAIN: vol.All( + vol.Schema({ + vol.Optional(CONF_UPDATE_INTERVAL): + vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_INTERVAL): + vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_MANUAL, default=False): cv.boolean, + }), + cv.deprecated( + CONF_UPDATE_INTERVAL, + replacement_key=CONF_SCAN_INTERVAL, + invalidation_version=CONF_UPDATE_INTERVAL_INVALIDATION_VERSION, + default=DEFAULT_INTERVAL + ) + ) }, extra=vol.ALLOW_EXTRA) @@ -39,7 +48,7 @@ async def async_setup(hass, config): if not conf[CONF_MANUAL]: async_track_time_interval( - hass, data.update, conf[CONF_UPDATE_INTERVAL] + hass, data.update, conf[CONF_SCAN_INTERVAL] ) def update(call=None): diff --git a/homeassistant/components/freedns/__init__.py b/homeassistant/components/freedns/__init__.py index 7da51cd42e4..edb3a57c28c 100644 --- a/homeassistant/components/freedns/__init__.py +++ b/homeassistant/components/freedns/__init__.py @@ -13,7 +13,8 @@ import async_timeout import voluptuous as vol from homeassistant.const import (CONF_URL, CONF_ACCESS_TOKEN, - CONF_UPDATE_INTERVAL, CONF_SCAN_INTERVAL) + CONF_UPDATE_INTERVAL, CONF_SCAN_INTERVAL, + CONF_UPDATE_INTERVAL_INVALIDATION_VERSION) import homeassistant.helpers.config_validation as cv _LOGGER = logging.getLogger(__name__) @@ -32,13 +33,13 @@ CONFIG_SCHEMA = vol.Schema({ vol.Exclusive(CONF_ACCESS_TOKEN, DOMAIN): cv.string, vol.Optional(CONF_UPDATE_INTERVAL): vol.All(cv.time_period, cv.positive_timedelta), - vol.Optional(CONF_SCAN_INTERVAL): + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_INTERVAL): vol.All(cv.time_period, cv.positive_timedelta), }), cv.deprecated( CONF_UPDATE_INTERVAL, replacement_key=CONF_SCAN_INTERVAL, - invalidation_version='1.0.0', + invalidation_version=CONF_UPDATE_INTERVAL_INVALIDATION_VERSION, default=DEFAULT_INTERVAL ) ) diff --git a/homeassistant/components/mythicbeastsdns/__init__.py b/homeassistant/components/mythicbeastsdns/__init__.py index f34b2736710..3d0d250557b 100644 --- a/homeassistant/components/mythicbeastsdns/__init__.py +++ b/homeassistant/components/mythicbeastsdns/__init__.py @@ -5,7 +5,9 @@ import logging import voluptuous as vol from homeassistant.const import ( - CONF_DOMAIN, CONF_HOST, CONF_PASSWORD, CONF_UPDATE_INTERVAL) + CONF_HOST, CONF_DOMAIN, CONF_PASSWORD, CONF_UPDATE_INTERVAL, + CONF_SCAN_INTERVAL, CONF_UPDATE_INTERVAL_INVALIDATION_VERSION +) from homeassistant.helpers.aiohttp_client import async_get_clientsession import homeassistant.helpers.config_validation as cv from homeassistant.helpers.event import async_track_time_interval @@ -19,13 +21,23 @@ DOMAIN = 'mythicbeastsdns' DEFAULT_INTERVAL = timedelta(minutes=10) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_DOMAIN): cv.string, - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_UPDATE_INTERVAL, default=DEFAULT_INTERVAL): vol.All( - cv.time_period, cv.positive_timedelta), - }) + DOMAIN: vol.All( + vol.Schema({ + vol.Required(CONF_DOMAIN): cv.string, + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_UPDATE_INTERVAL): + vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_INTERVAL): + vol.All(cv.time_period, cv.positive_timedelta), + }), + cv.deprecated( + CONF_UPDATE_INTERVAL, + replacement_key=CONF_SCAN_INTERVAL, + invalidation_version=CONF_UPDATE_INTERVAL_INVALIDATION_VERSION, + default=DEFAULT_INTERVAL + ) + ) }, extra=vol.ALLOW_EXTRA) @@ -36,7 +48,7 @@ async def async_setup(hass, config): domain = config[DOMAIN][CONF_DOMAIN] password = config[DOMAIN][CONF_PASSWORD] host = config[DOMAIN][CONF_HOST] - update_interval = config[DOMAIN][CONF_UPDATE_INTERVAL] + update_interval = config[DOMAIN][CONF_SCAN_INTERVAL] session = async_get_clientsession(hass) diff --git a/homeassistant/components/sensor/broadlink.py b/homeassistant/components/sensor/broadlink.py index 50f9f955148..5720201b3f2 100644 --- a/homeassistant/components/sensor/broadlink.py +++ b/homeassistant/components/sensor/broadlink.py @@ -14,7 +14,8 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_HOST, CONF_MAC, CONF_MONITORED_CONDITIONS, CONF_NAME, TEMP_CELSIUS, - CONF_TIMEOUT, CONF_UPDATE_INTERVAL) + CONF_TIMEOUT, CONF_UPDATE_INTERVAL, CONF_SCAN_INTERVAL, + CONF_UPDATE_INTERVAL_INVALIDATION_VERSION) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv @@ -25,6 +26,7 @@ _LOGGER = logging.getLogger(__name__) DEVICE_DEFAULT_NAME = 'Broadlink sensor' DEFAULT_TIMEOUT = 10 +SCAN_INTERVAL = timedelta(seconds=300) SENSOR_TYPES = { 'temperature': ['Temperature', TEMP_CELSIUS], @@ -34,16 +36,24 @@ SENSOR_TYPES = { 'noise': ['Noise', ' '], } -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Optional(CONF_NAME, default=DEVICE_DEFAULT_NAME): vol.Coerce(str), - vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), - vol.Optional(CONF_UPDATE_INTERVAL, default=timedelta(seconds=300)): ( - vol.All(cv.time_period, cv.positive_timedelta)), - vol.Required(CONF_HOST): cv.string, - vol.Required(CONF_MAC): cv.string, - vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int -}) +PLATFORM_SCHEMA = vol.All( + PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME, default=DEVICE_DEFAULT_NAME): vol.Coerce(str), + vol.Optional(CONF_MONITORED_CONDITIONS, default=[]): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_UPDATE_INTERVAL): + vol.All(cv.time_period, cv.positive_timedelta), + vol.Required(CONF_HOST): cv.string, + vol.Required(CONF_MAC): cv.string, + vol.Optional(CONF_TIMEOUT, default=DEFAULT_TIMEOUT): cv.positive_int + }), + cv.deprecated( + CONF_UPDATE_INTERVAL, + replacement_key=CONF_SCAN_INTERVAL, + invalidation_version=CONF_UPDATE_INTERVAL_INVALIDATION_VERSION, + default=SCAN_INTERVAL + ) +) def setup_platform(hass, config, add_entities, discovery_info=None): @@ -53,7 +63,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): mac_addr = binascii.unhexlify(mac) name = config.get(CONF_NAME) timeout = config.get(CONF_TIMEOUT) - update_interval = config.get(CONF_UPDATE_INTERVAL) + update_interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) broadlink_data = BroadlinkData(update_interval, host, mac_addr, timeout) dev = [] for variable in config[CONF_MONITORED_CONDITIONS]: diff --git a/homeassistant/components/sensor/darksky.py b/homeassistant/components/sensor/darksky.py index 6e2ca2dc6c5..c68bb2cd3a3 100644 --- a/homeassistant/components/sensor/darksky.py +++ b/homeassistant/components/sensor/darksky.py @@ -14,7 +14,8 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( ATTR_ATTRIBUTION, CONF_API_KEY, CONF_LATITUDE, CONF_LONGITUDE, - CONF_MONITORED_CONDITIONS, CONF_NAME, UNIT_UV_INDEX, CONF_UPDATE_INTERVAL) + CONF_MONITORED_CONDITIONS, CONF_NAME, UNIT_UV_INDEX, CONF_UPDATE_INTERVAL, + CONF_SCAN_INTERVAL, CONF_UPDATE_INTERVAL_INVALIDATION_VERSION) import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle @@ -30,8 +31,8 @@ CONF_LANGUAGE = 'language' CONF_UNITS = 'units' DEFAULT_LANGUAGE = 'en' - DEFAULT_NAME = 'Dark Sky' +SCAN_INTERVAL = timedelta(seconds=300) DEPRECATED_SENSOR_TYPES = { 'apparent_temperature_max', @@ -167,23 +168,39 @@ LANGUAGE_CODES = [ 'tet', 'tr', 'uk', 'x-pig-latin', 'zh', 'zh-tw', ] -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_MONITORED_CONDITIONS): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), - vol.Required(CONF_API_KEY): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_UNITS): vol.In(['auto', 'si', 'us', 'ca', 'uk', 'uk2']), - vol.Optional(CONF_LANGUAGE, - default=DEFAULT_LANGUAGE): vol.In(LANGUAGE_CODES), - vol.Inclusive(CONF_LATITUDE, 'coordinates', - 'Latitude and longitude must exist together'): cv.latitude, - vol.Inclusive(CONF_LONGITUDE, 'coordinates', - 'Latitude and longitude must exist together'): cv.longitude, - vol.Optional(CONF_UPDATE_INTERVAL, default=timedelta(seconds=300)): ( - vol.All(cv.time_period, cv.positive_timedelta)), - vol.Optional(CONF_FORECAST): - vol.All(cv.ensure_list, [vol.Range(min=0, max=7)]), -}) +ALLOWED_UNITS = ['auto', 'si', 'us', 'ca', 'uk', 'uk2'] + +PLATFORM_SCHEMA = vol.All( + PLATFORM_SCHEMA.extend({ + vol.Required(CONF_MONITORED_CONDITIONS): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_UNITS): vol.In(ALLOWED_UNITS), + vol.Optional(CONF_LANGUAGE, + default=DEFAULT_LANGUAGE): vol.In(LANGUAGE_CODES), + vol.Inclusive( + CONF_LATITUDE, + 'coordinates', + 'Latitude and longitude must exist together' + ): cv.latitude, + vol.Inclusive( + CONF_LONGITUDE, + 'coordinates', + 'Latitude and longitude must exist together' + ): cv.longitude, + vol.Optional(CONF_UPDATE_INTERVAL): + vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_FORECAST): + vol.All(cv.ensure_list, [vol.Range(min=0, max=7)]), + }), + cv.deprecated( + CONF_UPDATE_INTERVAL, + replacement_key=CONF_SCAN_INTERVAL, + invalidation_version=CONF_UPDATE_INTERVAL_INVALIDATION_VERSION, + default=SCAN_INTERVAL + ) +) def setup_platform(hass, config, add_entities, discovery_info=None): @@ -191,7 +208,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): latitude = config.get(CONF_LATITUDE, hass.config.latitude) longitude = config.get(CONF_LONGITUDE, hass.config.longitude) language = config.get(CONF_LANGUAGE) - interval = config.get(CONF_UPDATE_INTERVAL) + interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) if CONF_UNITS in config: units = config[CONF_UNITS] diff --git a/homeassistant/components/sensor/fedex.py b/homeassistant/components/sensor/fedex.py index 02938ff837b..54c319e6441 100644 --- a/homeassistant/components/sensor/fedex.py +++ b/homeassistant/components/sensor/fedex.py @@ -12,7 +12,9 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import (CONF_NAME, CONF_USERNAME, CONF_PASSWORD, - ATTR_ATTRIBUTION, CONF_UPDATE_INTERVAL) + ATTR_ATTRIBUTION, CONF_UPDATE_INTERVAL, + CONF_SCAN_INTERVAL, + CONF_UPDATE_INTERVAL_INVALIDATION_VERSION) from homeassistant.helpers.entity import Entity from homeassistant.util import slugify from homeassistant.util import Throttle @@ -31,13 +33,23 @@ ICON = 'mdi:package-variant-closed' STATUS_DELIVERED = 'delivered' -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_UPDATE_INTERVAL, default=timedelta(seconds=1800)): - vol.All(cv.time_period, cv.positive_timedelta), -}) +SCAN_INTERVAL = timedelta(seconds=1800) + +PLATFORM_SCHEMA = vol.All( + PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_UPDATE_INTERVAL): + vol.All(cv.time_period, cv.positive_timedelta), + }), + cv.deprecated( + CONF_UPDATE_INTERVAL, + replacement_key=CONF_SCAN_INTERVAL, + invalidation_version=CONF_UPDATE_INTERVAL_INVALIDATION_VERSION, + default=SCAN_INTERVAL + ) +) def setup_platform(hass, config, add_entities, discovery_info=None): @@ -45,7 +57,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): import fedexdeliverymanager name = config.get(CONF_NAME) - update_interval = config.get(CONF_UPDATE_INTERVAL) + update_interval = config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) try: cookie = hass.config.path(COOKIE) diff --git a/homeassistant/components/sensor/ups.py b/homeassistant/components/sensor/ups.py index 44ecdc433c5..e4aab555050 100644 --- a/homeassistant/components/sensor/ups.py +++ b/homeassistant/components/sensor/ups.py @@ -12,7 +12,9 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import (CONF_NAME, CONF_USERNAME, CONF_PASSWORD, - ATTR_ATTRIBUTION, CONF_UPDATE_INTERVAL) + ATTR_ATTRIBUTION, CONF_UPDATE_INTERVAL, + CONF_SCAN_INTERVAL, + CONF_UPDATE_INTERVAL_INVALIDATION_VERSION) from homeassistant.helpers.entity import Entity from homeassistant.util import slugify from homeassistant.util import Throttle @@ -28,13 +30,23 @@ COOKIE = 'upsmychoice_cookies.pickle' ICON = 'mdi:package-variant-closed' STATUS_DELIVERED = 'delivered' -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_NAME): cv.string, - vol.Optional(CONF_UPDATE_INTERVAL, default=timedelta(seconds=1800)): ( - vol.All(cv.time_period, cv.positive_timedelta)), -}) +SCAN_INTERVAL = timedelta(seconds=1800) + +PLATFORM_SCHEMA = vol.All( + PLATFORM_SCHEMA.extend({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_UPDATE_INTERVAL): ( + vol.All(cv.time_period, cv.positive_timedelta)), + }), + cv.deprecated( + CONF_UPDATE_INTERVAL, + replacement_key=CONF_SCAN_INTERVAL, + invalidation_version=CONF_UPDATE_INTERVAL_INVALIDATION_VERSION, + default=SCAN_INTERVAL + ) +) def setup_platform(hass, config, add_entities, discovery_info=None): @@ -49,8 +61,11 @@ def setup_platform(hass, config, add_entities, discovery_info=None): _LOGGER.exception("Could not connect to UPS My Choice") return False - add_entities([UPSSensor(session, config.get(CONF_NAME), - config.get(CONF_UPDATE_INTERVAL))], True) + add_entities([UPSSensor( + session, + config.get(CONF_NAME), + config.get(CONF_SCAN_INTERVAL, SCAN_INTERVAL) + )], True) class UPSSensor(Entity): diff --git a/homeassistant/components/speedtestdotnet/__init__.py b/homeassistant/components/speedtestdotnet/__init__.py index 3b8d2964f83..4eae738b0d3 100644 --- a/homeassistant/components/speedtestdotnet/__init__.py +++ b/homeassistant/components/speedtestdotnet/__init__.py @@ -8,7 +8,8 @@ import homeassistant.helpers.config_validation as cv from homeassistant.components.speedtestdotnet.const import DOMAIN, \ DATA_UPDATED, SENSOR_TYPES from homeassistant.const import CONF_MONITORED_CONDITIONS, \ - CONF_UPDATE_INTERVAL + CONF_UPDATE_INTERVAL, CONF_SCAN_INTERVAL, \ + CONF_UPDATE_INTERVAL_INVALIDATION_VERSION from homeassistant.helpers.discovery import async_load_platform from homeassistant.helpers.dispatcher import dispatcher_send from homeassistant.helpers.event import async_track_time_interval @@ -24,16 +25,26 @@ CONF_MANUAL = 'manual' DEFAULT_INTERVAL = timedelta(hours=1) CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Optional(CONF_SERVER_ID): cv.positive_int, - vol.Optional(CONF_UPDATE_INTERVAL, default=DEFAULT_INTERVAL): - vol.All( - cv.time_period, cv.positive_timedelta - ), - vol.Optional(CONF_MANUAL, default=False): cv.boolean, - vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): - vol.All(cv.ensure_list, [vol.In(list(SENSOR_TYPES))]) - }) + DOMAIN: vol.All( + vol.Schema({ + vol.Optional(CONF_SERVER_ID): cv.positive_int, + vol.Optional(CONF_UPDATE_INTERVAL): + vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_INTERVAL): + vol.All(cv.time_period, cv.positive_timedelta), + vol.Optional(CONF_MANUAL, default=False): cv.boolean, + vol.Optional( + CONF_MONITORED_CONDITIONS, + default=list(SENSOR_TYPES) + ): vol.All(cv.ensure_list, [vol.In(list(SENSOR_TYPES))]) + }), + cv.deprecated( + CONF_UPDATE_INTERVAL, + replacement_key=CONF_SCAN_INTERVAL, + invalidation_version=CONF_UPDATE_INTERVAL_INVALIDATION_VERSION, + default=DEFAULT_INTERVAL + ) + ) }, extra=vol.ALLOW_EXTRA) @@ -44,7 +55,7 @@ async def async_setup(hass, config): if not conf[CONF_MANUAL]: async_track_time_interval( - hass, data.update, conf[CONF_UPDATE_INTERVAL] + hass, data.update, conf[CONF_SCAN_INTERVAL] ) def update(call=None): diff --git a/homeassistant/components/tellduslive/__init__.py b/homeassistant/components/tellduslive/__init__.py index 1a6f35fe8d8..397e21922d9 100644 --- a/homeassistant/components/tellduslive/__init__.py +++ b/homeassistant/components/tellduslive/__init__.py @@ -6,7 +6,8 @@ import logging import voluptuous as vol from homeassistant import config_entries -from homeassistant.const import CONF_UPDATE_INTERVAL +from homeassistant.const import CONF_UPDATE_INTERVAL, CONF_SCAN_INTERVAL, \ + CONF_UPDATE_INTERVAL_INVALIDATION_VERSION import homeassistant.helpers.config_validation as cv from homeassistant.helpers.dispatcher import async_dispatcher_send from homeassistant.helpers.event import async_call_later @@ -23,17 +24,23 @@ REQUIREMENTS = ['tellduslive==0.10.10'] _LOGGER = logging.getLogger(__name__) -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.All( vol.Schema({ vol.Optional(CONF_HOST, default=DOMAIN): cv.string, - vol.Optional(CONF_UPDATE_INTERVAL, default=SCAN_INTERVAL): - (vol.All(cv.time_period, vol.Clamp(min=MIN_UPDATE_INTERVAL))) + vol.Optional(CONF_UPDATE_INTERVAL): + vol.All(cv.time_period, vol.Clamp(min=MIN_UPDATE_INTERVAL)), + vol.Optional(CONF_SCAN_INTERVAL, default=SCAN_INTERVAL): + vol.All(cv.time_period, vol.Clamp(min=MIN_UPDATE_INTERVAL)), }), - }, - extra=vol.ALLOW_EXTRA, -) + cv.deprecated( + CONF_UPDATE_INTERVAL, + replacement_key=CONF_SCAN_INTERVAL, + invalidation_version=CONF_UPDATE_INTERVAL_INVALIDATION_VERSION, + default=SCAN_INTERVAL + ) + ) +}, extra=vol.ALLOW_EXTRA) DATA_CONFIG_ENTRY_LOCK = 'tellduslive_config_entry_lock' CONFIG_ENTRY_IS_SETUP = 'telldus_config_entry_is_setup' @@ -102,7 +109,7 @@ async def async_setup(hass, config): context={'source': config_entries.SOURCE_IMPORT}, data={ KEY_HOST: config[DOMAIN].get(CONF_HOST), - KEY_SCAN_INTERVAL: config[DOMAIN].get(CONF_UPDATE_INTERVAL), + KEY_SCAN_INTERVAL: config[DOMAIN][CONF_SCAN_INTERVAL], })) return True diff --git a/homeassistant/components/volvooncall/__init__.py b/homeassistant/components/volvooncall/__init__.py index 9dbaadf9bee..7e72607c2f3 100644 --- a/homeassistant/components/volvooncall/__init__.py +++ b/homeassistant/components/volvooncall/__init__.py @@ -6,7 +6,8 @@ import voluptuous as vol from homeassistant.const import (CONF_USERNAME, CONF_PASSWORD, CONF_NAME, CONF_RESOURCES, - CONF_UPDATE_INTERVAL) + CONF_UPDATE_INTERVAL, CONF_SCAN_INTERVAL, + CONF_UPDATE_INTERVAL_INVALIDATION_VERSION) from homeassistant.helpers import discovery import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity import Entity @@ -83,20 +84,30 @@ RESOURCES = [ ] CONFIG_SCHEMA = vol.Schema({ - DOMAIN: vol.Schema({ - vol.Required(CONF_USERNAME): cv.string, - vol.Required(CONF_PASSWORD): cv.string, - vol.Optional(CONF_UPDATE_INTERVAL, default=DEFAULT_UPDATE_INTERVAL): ( - vol.All(cv.time_period, vol.Clamp(min=MIN_UPDATE_INTERVAL))), - vol.Optional(CONF_NAME, default={}): - cv.schema_with_slug_keys(cv.string), - vol.Optional(CONF_RESOURCES): vol.All( - cv.ensure_list, [vol.In(RESOURCES)]), - vol.Optional(CONF_REGION): cv.string, - vol.Optional(CONF_SERVICE_URL): cv.string, - vol.Optional(CONF_MUTABLE, default=True): cv.boolean, - vol.Optional(CONF_SCANDINAVIAN_MILES, default=False): cv.boolean, - }), + DOMAIN: vol.All( + vol.Schema({ + vol.Required(CONF_USERNAME): cv.string, + vol.Required(CONF_PASSWORD): cv.string, + vol.Optional(CONF_UPDATE_INTERVAL): + vol.All(cv.time_period, vol.Clamp(min=MIN_UPDATE_INTERVAL)), + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_UPDATE_INTERVAL): + vol.All(cv.time_period, vol.Clamp(min=MIN_UPDATE_INTERVAL)), + vol.Optional(CONF_NAME, default={}): + cv.schema_with_slug_keys(cv.string), + vol.Optional(CONF_RESOURCES): vol.All( + cv.ensure_list, [vol.In(RESOURCES)]), + vol.Optional(CONF_REGION): cv.string, + vol.Optional(CONF_SERVICE_URL): cv.string, + vol.Optional(CONF_MUTABLE, default=True): cv.boolean, + vol.Optional(CONF_SCANDINAVIAN_MILES, default=False): cv.boolean, + }), + cv.deprecated( + CONF_UPDATE_INTERVAL, + replacement_key=CONF_SCAN_INTERVAL, + invalidation_version=CONF_UPDATE_INTERVAL_INVALIDATION_VERSION, + default=DEFAULT_UPDATE_INTERVAL + ) + ) }, extra=vol.ALLOW_EXTRA) @@ -112,7 +123,7 @@ async def async_setup(hass, config): service_url=config[DOMAIN].get(CONF_SERVICE_URL), region=config[DOMAIN].get(CONF_REGION)) - interval = config[DOMAIN].get(CONF_UPDATE_INTERVAL) + interval = config[DOMAIN][CONF_SCAN_INTERVAL] data = hass.data[DATA_KEY] = VolvoData(config) diff --git a/homeassistant/const.py b/homeassistant/const.py index 12f394b9e03..43caf61aa72 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -147,7 +147,11 @@ CONF_TTL = 'ttl' CONF_TYPE = 'type' CONF_UNIT_OF_MEASUREMENT = 'unit_of_measurement' CONF_UNIT_SYSTEM = 'unit_system' + +# Deprecated in 0.88.0, invalidated in 0.91.0, remove in 0.92.0 CONF_UPDATE_INTERVAL = 'update_interval' +CONF_UPDATE_INTERVAL_INVALIDATION_VERSION = '0.91.0' + CONF_URL = 'url' CONF_USERNAME = 'username' CONF_VALUE_TEMPLATE = 'value_template' diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 3b01a01fc96..ab385019b10 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -606,7 +606,8 @@ def deprecated(key: str, else: value = default if (replacement_key - and replacement_key not in config + and (replacement_key not in config + or default == config.get(replacement_key)) and value is not None): config[replacement_key] = value diff --git a/tests/components/freedns/test_init.py b/tests/components/freedns/test_init.py index 784926912cd..1996b02d8d0 100644 --- a/tests/components/freedns/test_init.py +++ b/tests/components/freedns/test_init.py @@ -24,7 +24,7 @@ def setup_freedns(hass, aioclient_mock): hass.loop.run_until_complete(async_setup_component(hass, freedns.DOMAIN, { freedns.DOMAIN: { 'access_token': ACCESS_TOKEN, - 'update_interval': UPDATE_INTERVAL, + 'scan_interval': UPDATE_INTERVAL, } })) @@ -62,7 +62,7 @@ def test_setup_fails_if_wrong_token(hass, aioclient_mock): result = yield from async_setup_component(hass, freedns.DOMAIN, { freedns.DOMAIN: { 'access_token': ACCESS_TOKEN, - 'update_interval': UPDATE_INTERVAL, + 'scan_interval': UPDATE_INTERVAL, } }) assert not result diff --git a/tests/components/sensor/test_darksky.py b/tests/components/sensor/test_darksky.py index 33a13f013de..58ce932020a 100644 --- a/tests/components/sensor/test_darksky.py +++ b/tests/components/sensor/test_darksky.py @@ -21,7 +21,7 @@ VALID_CONFIG_MINIMAL = { 'api_key': 'foo', 'forecast': [1, 2], 'monitored_conditions': ['summary', 'icon', 'temperature_high'], - 'update_interval': timedelta(seconds=120), + 'scan_interval': timedelta(seconds=120), } } @@ -31,7 +31,7 @@ INVALID_CONFIG_MINIMAL = { 'api_key': 'foo', 'forecast': [1, 2], 'monitored_conditions': ['sumary', 'iocn', 'temperature_high'], - 'update_interval': timedelta(seconds=120), + 'scan_interval': timedelta(seconds=120), } } @@ -45,7 +45,7 @@ VALID_CONFIG_LANG_DE = { 'monitored_conditions': ['summary', 'icon', 'temperature_high', 'minutely_summary', 'hourly_summary', 'daily_summary', 'humidity', ], - 'update_interval': timedelta(seconds=120), + 'scan_interval': timedelta(seconds=120), } } @@ -56,7 +56,7 @@ INVALID_CONFIG_LANG = { 'forecast': [1, 2], 'language': 'yz', 'monitored_conditions': ['summary', 'icon', 'temperature_high'], - 'update_interval': timedelta(seconds=120), + 'scan_interval': timedelta(seconds=120), } } @@ -138,8 +138,11 @@ class TestDarkSkySetup(unittest.TestCase): msg = '400 Client Error: Bad Request for url: {}'.format(url) mock_get_forecast.side_effect = HTTPError(msg,) - response = darksky.setup_platform(self.hass, VALID_CONFIG_MINIMAL, - MagicMock()) + response = darksky.setup_platform( + self.hass, + VALID_CONFIG_MINIMAL['sensor'], + MagicMock() + ) assert not response @requests_mock.Mocker() diff --git a/tests/helpers/test_config_validation.py b/tests/helpers/test_config_validation.py index cefde564035..d83d32c88e3 100644 --- a/tests/helpers/test_config_validation.py +++ b/tests/helpers/test_config_validation.py @@ -713,7 +713,7 @@ def test_deprecated_with_default(caplog, schema): def test_deprecated_with_replacement_key_and_default(caplog, schema): """ - Test deprecation behaves correctly when only a replacement key is provided. + Test deprecation with a replacement key and default. Expected behavior: - Outputs the appropriate deprecation warning if key is detected @@ -748,6 +748,22 @@ def test_deprecated_with_replacement_key_and_default(caplog, schema): assert len(caplog.records) == 0 assert {'venus': True, 'jupiter': False} == output + deprecated_schema_with_default = vol.All( + vol.Schema({ + 'venus': cv.boolean, + vol.Optional('mars', default=False): cv.boolean, + vol.Optional('jupiter', default=False): cv.boolean + }), + cv.deprecated('mars', replacement_key='jupiter', default=False) + ) + + test_data = {'mars': True} + output = deprecated_schema_with_default(test_data.copy()) + assert len(caplog.records) == 1 + assert ("The 'mars' option (with value 'True') is deprecated, " + "please replace it with 'jupiter'") in caplog.text + assert {'jupiter': True} == output + def test_deprecated_with_replacement_key_invalidation_version_default( caplog, schema, version From 0023da778a93fdd7d893a64363c5e58f63054a02 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Thu, 14 Feb 2019 20:55:51 +0100 Subject: [PATCH 15/45] Add legacy PLATFORM_SCHEMA config validation --- homeassistant/helpers/config_validation.py | 51 +++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index ab385019b10..ef9781f480b 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -25,6 +25,8 @@ from homeassistant.helpers import template as template_helper from homeassistant.helpers.logging import KeywordStyleAdapter from homeassistant.util import slugify as util_slugify +_LOGGER = logging.getLogger(__name__) + # pylint: disable=invalid-name TIME_PERIOD_ERROR = "offset {} should be format 'HH:MM' or 'HH:MM:SS'" @@ -633,8 +635,55 @@ def key_dependency(key, dependency): # Schemas +class HASchema(vol.Schema): + """Schema class that allows us to mark PREVENT_EXTRA errors as warnings.""" + def __call__(self, data): + try: + return super().__call__(data) + except vol.Invalid as orig_err: + if self.extra != vol.PREVENT_EXTRA: + raise -PLATFORM_SCHEMA = vol.Schema({ + # orig_error is of type vol.MultipleInvalid (see super __call__) + assert isinstance(orig_err, vol.MultipleInvalid) + # If it fails with PREVENT_EXTRA, try with ALLOW_EXTRA + self.extra = vol.ALLOW_EXTRA + # In case it still fails the following will raise + try: + validated = super().__call__(data) + finally: + self.extra = vol.PREVENT_EXTRA + + # This is a legacy config, print warning + extra_key_errs = [err for err in orig_err.errors + if err.error_message == 'extra keys not allowed'] + if extra_key_errs: + msg = "Your configuration contains extra keys " \ + "that the platform does not support. The keys " + msg += ', '.join('[{}]'.format(err.path[-1]) for err in + extra_key_errs) + msg += ' are 42.' + if hasattr(data, '__config_file__'): + msg += " (See {}, line {}). ".format(data.__config_file__, + data.__line__) + _LOGGER.warning(msg) + else: + # This should not happen (all errors should be extra key + # errors). Let's raise the original error anyway. + raise orig_err + + # Return legacy validated config + return validated + + def extend(self, schema, required=None, extra=None): + """Extend this schema and convert it to HASchema if necessary""" + ret = super().extend(schema, required=required, extra=extra) + if extra is not None: + return ret + return HASchema(ret.schema, required=required, extra=self.extra) + + +PLATFORM_SCHEMA = HASchema({ vol.Required(CONF_PLATFORM): string, vol.Optional(CONF_ENTITY_NAMESPACE): string, vol.Optional(CONF_SCAN_INTERVAL): time_period From 26a79dff99942e3090b0185dbe7973bcd6af869a Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Fri, 15 Feb 2019 10:51:52 +0100 Subject: [PATCH 16/45] Fix tests --- tests/test_setup.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/tests/test_setup.py b/tests/test_setup.py index 8575b023d37..1a60943a72d 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -90,7 +90,7 @@ class TestSetup: } }) - def test_validate_platform_config(self): + def test_validate_platform_config(self, caplog): """Test validating platform configuration.""" platform_schema = PLATFORM_SCHEMA.extend({ 'hello': str, @@ -109,7 +109,7 @@ class TestSetup: MockPlatform('whatever', platform_schema=platform_schema)) - with assert_setup_component(0): + with assert_setup_component(1): assert setup.setup_component(self.hass, 'platform_conf', { 'platform_conf': { 'platform': 'whatever', @@ -117,11 +117,12 @@ class TestSetup: 'invalid': 'extra', } }) + assert caplog.text.count('Your configuration contains extra keys') == 1 self.hass.data.pop(setup.DATA_SETUP) self.hass.config.components.remove('platform_conf') - with assert_setup_component(1): + with assert_setup_component(2): assert setup.setup_component(self.hass, 'platform_conf', { 'platform_conf': { 'platform': 'whatever', @@ -132,6 +133,7 @@ class TestSetup: 'invalid': True } }) + assert caplog.text.count('Your configuration contains extra keys') == 2 self.hass.data.pop(setup.DATA_SETUP) self.hass.config.components.remove('platform_conf') @@ -183,7 +185,7 @@ class TestSetup: assert 'platform_conf' in self.hass.config.components assert not config['platform_conf'] # empty - def test_validate_platform_config_2(self): + def test_validate_platform_config_2(self, caplog): """Test component PLATFORM_SCHEMA_BASE prio over PLATFORM_SCHEMA.""" platform_schema = PLATFORM_SCHEMA.extend({ 'hello': str, @@ -204,7 +206,7 @@ class TestSetup: MockPlatform('whatever', platform_schema=platform_schema)) - with assert_setup_component(0): + with assert_setup_component(1): assert setup.setup_component(self.hass, 'platform_conf', { # fail: no extra keys allowed in platform schema 'platform_conf': { @@ -213,6 +215,7 @@ class TestSetup: 'invalid': 'extra', } }) + assert caplog.text.count('Your configuration contains extra keys') == 1 self.hass.data.pop(setup.DATA_SETUP) self.hass.config.components.remove('platform_conf') @@ -234,7 +237,7 @@ class TestSetup: self.hass.data.pop(setup.DATA_SETUP) self.hass.config.components.remove('platform_conf') - def test_validate_platform_config_3(self): + def test_validate_platform_config_3(self, caplog): """Test fallback to component PLATFORM_SCHEMA.""" component_schema = PLATFORM_SCHEMA_BASE.extend({ 'hello': str, @@ -255,15 +258,15 @@ class TestSetup: MockPlatform('whatever', platform_schema=platform_schema)) - with assert_setup_component(0): + with assert_setup_component(1): assert setup.setup_component(self.hass, 'platform_conf', { 'platform_conf': { - # fail: no extra keys allowed 'platform': 'whatever', 'hello': 'world', 'invalid': 'extra', } }) + assert caplog.text.count('Your configuration contains extra keys') == 1 self.hass.data.pop(setup.DATA_SETUP) self.hass.config.components.remove('platform_conf') From 219ca336a9e49669807c76e4bf6d8fe5001f25c3 Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Fri, 15 Feb 2019 10:57:02 +0100 Subject: [PATCH 17/45] Lint --- homeassistant/helpers/config_validation.py | 5 ++++- tests/test_setup.py | 12 ++++++++---- 2 files changed, 12 insertions(+), 5 deletions(-) diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index ef9781f480b..4b97409dd3d 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -637,7 +637,9 @@ def key_dependency(key, dependency): # Schemas class HASchema(vol.Schema): """Schema class that allows us to mark PREVENT_EXTRA errors as warnings.""" + def __call__(self, data): + """Override __call__ to mark PREVENT_EXTRA as warning.""" try: return super().__call__(data) except vol.Invalid as orig_err: @@ -646,6 +648,7 @@ class HASchema(vol.Schema): # orig_error is of type vol.MultipleInvalid (see super __call__) assert isinstance(orig_err, vol.MultipleInvalid) + # pylint: disable=no-member # If it fails with PREVENT_EXTRA, try with ALLOW_EXTRA self.extra = vol.ALLOW_EXTRA # In case it still fails the following will raise @@ -676,7 +679,7 @@ class HASchema(vol.Schema): return validated def extend(self, schema, required=None, extra=None): - """Extend this schema and convert it to HASchema if necessary""" + """Extend this schema and convert it to HASchema if necessary.""" ret = super().extend(schema, required=required, extra=extra) if extra is not None: return ret diff --git a/tests/test_setup.py b/tests/test_setup.py index 1a60943a72d..c6126bc4a3b 100644 --- a/tests/test_setup.py +++ b/tests/test_setup.py @@ -117,7 +117,8 @@ class TestSetup: 'invalid': 'extra', } }) - assert caplog.text.count('Your configuration contains extra keys') == 1 + assert caplog.text.count('Your configuration contains ' + 'extra keys') == 1 self.hass.data.pop(setup.DATA_SETUP) self.hass.config.components.remove('platform_conf') @@ -133,7 +134,8 @@ class TestSetup: 'invalid': True } }) - assert caplog.text.count('Your configuration contains extra keys') == 2 + assert caplog.text.count('Your configuration contains ' + 'extra keys') == 2 self.hass.data.pop(setup.DATA_SETUP) self.hass.config.components.remove('platform_conf') @@ -215,7 +217,8 @@ class TestSetup: 'invalid': 'extra', } }) - assert caplog.text.count('Your configuration contains extra keys') == 1 + assert caplog.text.count('Your configuration contains ' + 'extra keys') == 1 self.hass.data.pop(setup.DATA_SETUP) self.hass.config.components.remove('platform_conf') @@ -266,7 +269,8 @@ class TestSetup: 'invalid': 'extra', } }) - assert caplog.text.count('Your configuration contains extra keys') == 1 + assert caplog.text.count('Your configuration contains ' + 'extra keys') == 1 self.hass.data.pop(setup.DATA_SETUP) self.hass.config.components.remove('platform_conf') From 9696a8b8a907de28fa31d180c6801d541d6209ad Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Fri, 15 Feb 2019 18:40:46 +0100 Subject: [PATCH 18/45] Add persistent notification --- homeassistant/bootstrap.py | 17 +++++++++++++++++ homeassistant/helpers/config_validation.py | 20 +++++++++++--------- 2 files changed, 28 insertions(+), 9 deletions(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 90a74f23598..7e12a516478 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -192,6 +192,23 @@ async def async_from_config_dict(config: Dict[str, Any], '\n\n'.join(msg), "Config Warning", "config_warning" ) + # TEMP: warn users for invalid slugs + # Remove after 0.92 + if cv.INVALID_EXTRA_KEYS_FOUND: + msg = [] + msg.append( + "Your configuration contains extra keys " + "that the platform does not support (but were silently " + "accepted before 0.88). Please find and remove the following." + "This will become a breaking change." + ) + msg.append('\n'.join('- {}'.format(it) + for it in cv.INVALID_EXTRA_KEYS_FOUND)) + + hass.components.persistent_notification.async_create( + '\n\n'.join(msg), "Config Warning", "config_warning" + ) + return hass diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index 4b97409dd3d..b5716431217 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -25,8 +25,6 @@ from homeassistant.helpers import template as template_helper from homeassistant.helpers.logging import KeywordStyleAdapter from homeassistant.util import slugify as util_slugify -_LOGGER = logging.getLogger(__name__) - # pylint: disable=invalid-name TIME_PERIOD_ERROR = "offset {} should be format 'HH:MM' or 'HH:MM:SS'" @@ -36,6 +34,7 @@ OLD_ENTITY_ID_VALIDATION = r"^(\w+)\.(\w+)$" # persistent notification. Rare temporary exception to use a global. INVALID_SLUGS_FOUND = {} INVALID_ENTITY_IDS_FOUND = {} +INVALID_EXTRA_KEYS_FOUND = [] # Home Assistant types @@ -662,14 +661,17 @@ class HASchema(vol.Schema): if err.error_message == 'extra keys not allowed'] if extra_key_errs: msg = "Your configuration contains extra keys " \ - "that the platform does not support. The keys " - msg += ', '.join('[{}]'.format(err.path[-1]) for err in - extra_key_errs) - msg += ' are 42.' + "that the platform does not support.\n" \ + "Please remove " + submsg = ', '.join('[{}]'.format(err.path[-1]) for err in + extra_key_errs) + submsg += '. ' if hasattr(data, '__config_file__'): - msg += " (See {}, line {}). ".format(data.__config_file__, - data.__line__) - _LOGGER.warning(msg) + submsg += " (See {}, line {}). ".format( + data.__config_file__, data.__line__) + msg += submsg + logging.getLogger(__name__).warning(msg) + INVALID_EXTRA_KEYS_FOUND.append(submsg) else: # This should not happen (all errors should be extra key # errors). Let's raise the original error anyway. From 6f0b8535471c1c89db6e38c1c4852aeb9853d7eb Mon Sep 17 00:00:00 2001 From: Otto Winter Date: Sat, 16 Feb 2019 14:51:30 +0100 Subject: [PATCH 19/45] Update bootstrap.py --- homeassistant/bootstrap.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/bootstrap.py b/homeassistant/bootstrap.py index 7e12a516478..a018d540033 100644 --- a/homeassistant/bootstrap.py +++ b/homeassistant/bootstrap.py @@ -192,7 +192,7 @@ async def async_from_config_dict(config: Dict[str, Any], '\n\n'.join(msg), "Config Warning", "config_warning" ) - # TEMP: warn users for invalid slugs + # TEMP: warn users of invalid extra keys # Remove after 0.92 if cv.INVALID_EXTRA_KEYS_FOUND: msg = [] From f881a3af8271a680d2d81ffbb228ccd10291d09e Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 16 Feb 2019 23:52:04 -0800 Subject: [PATCH 20/45] Bumped version to 0.88.0b2 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 43caf61aa72..c5e3e082e0b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 88 -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 017f34770b998d7a02d49a686d7d478fddad58d4 Mon Sep 17 00:00:00 2001 From: cdce8p <30130371+cdce8p@users.noreply.github.com> Date: Sun, 17 Feb 2019 08:04:29 +0100 Subject: [PATCH 21/45] Fix battery_level error - HomeKit (#21120) --- homeassistant/components/homekit/accessories.py | 2 ++ tests/components/homekit/test_accessories.py | 9 ++++++++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/homekit/accessories.py b/homeassistant/components/homekit/accessories.py index 5baed0294b8..ca1b560e336 100644 --- a/homeassistant/components/homekit/accessories.py +++ b/homeassistant/components/homekit/accessories.py @@ -124,6 +124,8 @@ class HomeAccessory(Accessory): """ battery_level = convert_to_float( new_state.attributes.get(ATTR_BATTERY_LEVEL)) + if battery_level is None: + return self._char_battery.set_value(battery_level) self._char_low_battery.set_value(battery_level < 20) _LOGGER.debug('%s: Updated battery level to %d', self.entity_id, diff --git a/tests/components/homekit/test_accessories.py b/tests/components/homekit/test_accessories.py index 15ab6d7413e..6f3957827eb 100644 --- a/tests/components/homekit/test_accessories.py +++ b/tests/components/homekit/test_accessories.py @@ -100,7 +100,7 @@ async def test_home_accessory(hass, hk_driver): assert serv.get_characteristic(CHAR_MODEL).value == 'Test Model' -async def test_battery_service(hass, hk_driver): +async def test_battery_service(hass, hk_driver, caplog): """Test battery service.""" entity_id = 'homekit.accessory' hass.states.async_set(entity_id, None, {ATTR_BATTERY_LEVEL: 50}) @@ -124,6 +124,13 @@ async def test_battery_service(hass, hk_driver): assert acc._char_low_battery.value == 1 assert acc._char_charging.value == 2 + hass.states.async_set(entity_id, None, {ATTR_BATTERY_LEVEL: 'error'}) + await hass.async_block_till_done() + assert acc._char_battery.value == 15 + assert acc._char_low_battery.value == 1 + assert acc._char_charging.value == 2 + assert 'ERROR' not in caplog.text + # Test charging hass.states.async_set(entity_id, None, { ATTR_BATTERY_LEVEL: 10, ATTR_BATTERY_CHARGING: True}) From 30c8c689d869e74ef52cf193430f83dc3d3be275 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 16 Feb 2019 19:38:52 -0800 Subject: [PATCH 22/45] Handle ValueError (#21126) --- homeassistant/components/person/__init__.py | 30 +++++++++++++-------- 1 file changed, 19 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/person/__init__.py b/homeassistant/components/person/__init__.py index 63e588f911b..6fb7d42e0ee 100644 --- a/homeassistant/components/person/__init__.py +++ b/homeassistant/components/person/__init__.py @@ -247,7 +247,7 @@ class PersonManager: if any(person for person in chain(self.storage_data.values(), self.config_data.values()) - if person[CONF_USER_ID] == user_id): + if person.get(CONF_USER_ID) == user_id): raise ValueError("User already taken") async def _user_removed(self, event: Event): @@ -417,7 +417,7 @@ def ws_list_person(hass: HomeAssistantType, @websocket_api.websocket_command({ vol.Required('type'): 'person/create', - vol.Required('name'): str, + vol.Required('name'): vol.All(str, vol.Length(min=1)), vol.Optional('user_id'): vol.Any(str, None), vol.Optional('device_trackers', default=[]): vol.All( cv.ensure_list, cv.entities_domain(DEVICE_TRACKER_DOMAIN)), @@ -428,18 +428,22 @@ async def ws_create_person(hass: HomeAssistantType, connection: websocket_api.ActiveConnection, msg): """Create a person.""" manager = hass.data[DOMAIN] # type: PersonManager - person = await manager.async_create_person( - name=msg['name'], - user_id=msg.get('user_id'), - device_trackers=msg['device_trackers'] - ) - connection.send_result(msg['id'], person) + try: + person = await manager.async_create_person( + name=msg['name'], + user_id=msg.get('user_id'), + device_trackers=msg['device_trackers'] + ) + connection.send_result(msg['id'], person) + except ValueError as err: + connection.send_error( + msg['id'], websocket_api.const.ERR_INVALID_FORMAT, str(err)) @websocket_api.websocket_command({ vol.Required('type'): 'person/update', vol.Required('person_id'): str, - vol.Optional('name'): str, + vol.Required('name'): vol.All(str, vol.Length(min=1)), vol.Optional('user_id'): vol.Any(str, None), vol.Optional(CONF_DEVICE_TRACKERS, default=[]): vol.All( cv.ensure_list, cv.entities_domain(DEVICE_TRACKER_DOMAIN)), @@ -455,8 +459,12 @@ async def ws_update_person(hass: HomeAssistantType, if key in msg: changes[key] = msg[key] - person = await manager.async_update_person(msg['person_id'], **changes) - connection.send_result(msg['id'], person) + try: + person = await manager.async_update_person(msg['person_id'], **changes) + connection.send_result(msg['id'], person) + except ValueError as err: + connection.send_error( + msg['id'], websocket_api.const.ERR_INVALID_FORMAT, str(err)) @websocket_api.websocket_command({ From 7d111c2b4e6657b5af1a13c3c63f669157efd2db Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Sat, 16 Feb 2019 17:48:43 -0800 Subject: [PATCH 23/45] Bump pychromecast to 2.5.2 (#21127) --- homeassistant/components/cast/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/cast/__init__.py b/homeassistant/components/cast/__init__.py index 5e6bd720d4b..1b3da200540 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.1'] +REQUIREMENTS = ['pychromecast==2.5.2'] DOMAIN = 'cast' diff --git a/requirements_all.txt b/requirements_all.txt index 508f7bb2d04..08f834f2fdf 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -956,7 +956,7 @@ pycfdns==0.0.1 pychannels==1.0.0 # homeassistant.components.cast -pychromecast==2.5.1 +pychromecast==2.5.2 # homeassistant.components.media_player.cmus pycmus==0.1.1 From 933076560b9ea34337b6699e05d4ac797541313b Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 18 Feb 2019 13:25:43 -0800 Subject: [PATCH 24/45] Updated frontend to 20190218.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 3db63e65a6d..3b1d961ebe7 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==20190216.0'] +REQUIREMENTS = ['home-assistant-frontend==20190218.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 08f834f2fdf..64bd64c0aac 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==20190216.0 +home-assistant-frontend==20190218.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 5830085e544..a4e042c9c43 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==20190216.0 +home-assistant-frontend==20190218.0 # homeassistant.components.homekit_controller homekit==0.12.2 From 834d8940a85887b8be76b00b30266fc9591ec4fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ren=C3=A9-Marc=20Simard?= Date: Sun, 17 Feb 2019 05:46:08 -0500 Subject: [PATCH 25/45] Return None if no GTFS departures found (#20919) --- homeassistant/components/sensor/gtfs.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/gtfs.py b/homeassistant/components/sensor/gtfs.py index 081361aa32e..94f21287e39 100644 --- a/homeassistant/components/sensor/gtfs.py +++ b/homeassistant/components/sensor/gtfs.py @@ -205,7 +205,7 @@ class GTFSDepartureSensor(Entity): self._icon = ICON self._name = '' self._unit_of_measurement = 'min' - self._state = 0 + self._state = None self._attributes = {} self.lock = threading.Lock() self.update() @@ -241,7 +241,7 @@ class GTFSDepartureSensor(Entity): self._departure = get_next_departure( self._pygtfs, self.origin, self.destination, self._offset) if not self._departure: - self._state = 0 + self._state = None self._attributes = {'Info': 'No more departures today'} if self._name == '': self._name = (self._custom_name or DEFAULT_NAME) From 7ce18146d434c32805685db127905d3cc1481f6f Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Fri, 15 Feb 2019 10:40:54 -0600 Subject: [PATCH 26/45] SmartThings Component Enhancements/Fixes (#21085) * Improve component setup error logging/notification * Prevent capabilities from being represented my multiple platforms * Improved logging of received updates * Updates based on review feedback --- .../smartthings/.translations/en.json | 3 +- .../components/smartthings/__init__.py | 47 +++++++++++++++++-- .../components/smartthings/binary_sensor.py | 15 ++++-- .../components/smartthings/climate.py | 39 +++++++++------ .../components/smartthings/config_flow.py | 13 ++++- homeassistant/components/smartthings/const.py | 8 ++-- homeassistant/components/smartthings/fan.py | 14 ++++-- homeassistant/components/smartthings/light.py | 26 +++++----- homeassistant/components/smartthings/lock.py | 18 +++---- .../components/smartthings/sensor.py | 21 ++++++--- .../components/smartthings/strings.json | 3 +- .../components/smartthings/switch.py | 25 ++++------ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/smartthings/test_climate.py | 13 ----- .../smartthings/test_config_flow.py | 44 +++++++++++++++-- tests/components/smartthings/test_fan.py | 20 -------- tests/components/smartthings/test_light.py | 19 -------- tests/components/smartthings/test_lock.py | 6 --- tests/components/smartthings/test_switch.py | 17 ------- 20 files changed, 198 insertions(+), 157 deletions(-) diff --git a/homeassistant/components/smartthings/.translations/en.json b/homeassistant/components/smartthings/.translations/en.json index f2775b30ae2..2091ddb00a2 100644 --- a/homeassistant/components/smartthings/.translations/en.json +++ b/homeassistant/components/smartthings/.translations/en.json @@ -7,7 +7,8 @@ "token_already_setup": "The token has already been setup.", "token_forbidden": "The token does not have the required OAuth scopes.", "token_invalid_format": "The token must be in the UID/GUID format", - "token_unauthorized": "The token is invalid or no longer authorized." + "token_unauthorized": "The token is invalid or no longer authorized.", + "webhook_error": "SmartThings could not validate the endpoint configured in `base_url`. Please review the [component requirements]({component_url})." }, "step": { "user": { diff --git a/homeassistant/components/smartthings/__init__.py b/homeassistant/components/smartthings/__init__.py index 04da29aa55e..3cf38c358bc 100644 --- a/homeassistant/components/smartthings/__init__.py +++ b/homeassistant/components/smartthings/__init__.py @@ -1,5 +1,6 @@ """Support for SmartThings Cloud.""" import asyncio +import importlib import logging from typing import Iterable @@ -22,7 +23,7 @@ from .const import ( from .smartapp import ( setup_smartapp, setup_smartapp_endpoint, validate_installed_app) -REQUIREMENTS = ['pysmartapp==0.3.0', 'pysmartthings==0.6.1'] +REQUIREMENTS = ['pysmartapp==0.3.0', 'pysmartthings==0.6.2'] DEPENDENCIES = ['webhook'] _LOGGER = logging.getLogger(__name__) @@ -132,9 +133,41 @@ class DeviceBroker: """Create a new instance of the DeviceBroker.""" self._hass = hass self._installed_app_id = installed_app_id + self.assignments = self._assign_capabilities(devices) self.devices = {device.device_id: device for device in devices} self.event_handler_disconnect = None + def _assign_capabilities(self, devices: Iterable): + """Assign platforms to capabilities.""" + assignments = {} + for device in devices: + capabilities = device.capabilities.copy() + slots = {} + for platform_name in SUPPORTED_PLATFORMS: + platform = importlib.import_module( + '.' + platform_name, self.__module__) + assigned = platform.get_capabilities(capabilities) + if not assigned: + continue + # Draw-down capabilities and set slot assignment + for capability in assigned: + if capability not in capabilities: + continue + capabilities.remove(capability) + slots[capability] = platform_name + assignments[device.device_id] = slots + return assignments + + def get_assigned(self, device_id: str, platform: str): + """Get the capabilities assigned to the platform.""" + slots = self.assignments.get(device_id, {}) + return [key for key, value in slots.items() if value == platform] + + def any_assigned(self, device_id: str, platform: str): + """Return True if the platform has any assigned capabilities.""" + slots = self.assignments.get(device_id, {}) + return any(value for value in slots.values() if value == platform) + async def event_handler(self, req, resp, app): """Broker for incoming events.""" from pysmartapp.event import EVENT_TYPE_DEVICE @@ -167,10 +200,18 @@ class DeviceBroker: } self._hass.bus.async_fire(EVENT_BUTTON, data) _LOGGER.debug("Fired button event: %s", data) + else: + data = { + 'location_id': evt.location_id, + 'device_id': evt.device_id, + 'component_id': evt.component_id, + 'capability': evt.capability, + 'attribute': evt.attribute, + 'value': evt.value, + } + _LOGGER.debug("Push update received: %s", data) updated_devices.add(device.device_id) - _LOGGER.debug("Update received with %s events and updated %s devices", - len(req.events), len(updated_devices)) async_dispatcher_send(self._hass, SIGNAL_SMARTTHINGS_UPDATE, updated_devices) diff --git a/homeassistant/components/smartthings/binary_sensor.py b/homeassistant/components/smartthings/binary_sensor.py index 2fbb6f719da..45101601d5f 100644 --- a/homeassistant/components/smartthings/binary_sensor.py +++ b/homeassistant/components/smartthings/binary_sensor.py @@ -1,4 +1,6 @@ """Support for binary sensors through the SmartThings cloud API.""" +from typing import Optional, Sequence + from homeassistant.components.binary_sensor import BinarySensorDevice from . import SmartThingsEntity @@ -41,12 +43,19 @@ async def async_setup_entry(hass, config_entry, async_add_entities): broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] sensors = [] for device in broker.devices.values(): - for capability, attrib in CAPABILITY_TO_ATTRIB.items(): - if capability in device.capabilities: - sensors.append(SmartThingsBinarySensor(device, attrib)) + for capability in broker.get_assigned( + device.device_id, 'binary_sensor'): + attrib = CAPABILITY_TO_ATTRIB[capability] + sensors.append(SmartThingsBinarySensor(device, attrib)) async_add_entities(sensors) +def get_capabilities(capabilities: Sequence[str]) -> Optional[Sequence[str]]: + """Return all capabilities supported if minimum required are present.""" + return [capability for capability in CAPABILITY_TO_ATTRIB + if capability in capabilities] + + class SmartThingsBinarySensor(SmartThingsEntity, BinarySensorDevice): """Define a SmartThings Binary Sensor.""" diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 9340bcef337..ab7334f1316 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -1,13 +1,15 @@ """Support for climate devices through the SmartThings cloud API.""" import asyncio +from typing import Optional, Sequence from homeassistant.components.climate import ( ATTR_OPERATION_MODE, ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, - ATTR_TEMPERATURE, STATE_AUTO, STATE_COOL, STATE_ECO, STATE_HEAT, STATE_OFF, - SUPPORT_FAN_MODE, SUPPORT_OPERATION_MODE, SUPPORT_TARGET_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, ClimateDevice) -from homeassistant.const import TEMP_CELSIUS, TEMP_FAHRENHEIT +from homeassistant.const import ( + ATTR_TEMPERATURE, STATE_OFF, TEMP_CELSIUS, TEMP_FAHRENHEIT) from . import SmartThingsEntity from .const import DATA_BROKERS, DOMAIN @@ -48,30 +50,37 @@ async def async_setup_entry(hass, config_entry, async_add_entities): broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] async_add_entities( [SmartThingsThermostat(device) for device in broker.devices.values() - if is_climate(device)]) + if broker.any_assigned(device.device_id, 'climate')]) -def is_climate(device): - """Determine if the device should be represented as a climate entity.""" +def get_capabilities(capabilities: Sequence[str]) -> Optional[Sequence[str]]: + """Return all capabilities supported if minimum required are present.""" from pysmartthings import Capability + supported = [ + Capability.thermostat, + Capability.temperature_measurement, + Capability.thermostat_cooling_setpoint, + Capability.thermostat_heating_setpoint, + Capability.thermostat_mode, + Capability.relative_humidity_measurement, + Capability.thermostat_operating_state, + Capability.thermostat_fan_mode + ] # Can have this legacy/deprecated capability - if Capability.thermostat in device.capabilities: - return True + if Capability.thermostat in capabilities: + return supported # Or must have all of these climate_capabilities = [ Capability.temperature_measurement, Capability.thermostat_cooling_setpoint, Capability.thermostat_heating_setpoint, Capability.thermostat_mode] - if all(capability in device.capabilities + if all(capability in capabilities for capability in climate_capabilities): - return True - # Optional capabilities: - # relative_humidity_measurement -> state attribs - # thermostat_operating_state -> state attribs - # thermostat_fan_mode -> SUPPORT_FAN_MODE - return False + return supported + + return None class SmartThingsThermostat(SmartThingsEntity, ClimateDevice): diff --git a/homeassistant/components/smartthings/config_flow.py b/homeassistant/components/smartthings/config_flow.py index b280036a615..4663222c3b4 100644 --- a/homeassistant/components/smartthings/config_flow.py +++ b/homeassistant/components/smartthings/config_flow.py @@ -1,7 +1,7 @@ """Config flow to configure SmartThings.""" import logging -from aiohttp.client_exceptions import ClientResponseError +from aiohttp import ClientResponseError import voluptuous as vol from homeassistant import config_entries @@ -50,7 +50,7 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow): async def async_step_user(self, user_input=None): """Get access token and validate it.""" - from pysmartthings import SmartThings + from pysmartthings import APIResponseError, SmartThings errors = {} if not self.hass.config.api.base_url.lower().startswith('https://'): @@ -87,6 +87,14 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow): app = await create_app(self.hass, self.api) setup_smartapp(self.hass, app) self.app_id = app.app_id + except APIResponseError as ex: + if ex.is_target_error(): + errors['base'] = 'webhook_error' + else: + errors['base'] = "app_setup_error" + _LOGGER.exception("API error setting up the SmartApp: %s", + ex.raw_error_response) + return self._show_step_user(errors) except ClientResponseError as ex: if ex.status == 401: errors[CONF_ACCESS_TOKEN] = "token_unauthorized" @@ -94,6 +102,7 @@ class SmartThingsFlowHandler(config_entries.ConfigFlow): errors[CONF_ACCESS_TOKEN] = "token_forbidden" else: errors['base'] = "app_setup_error" + _LOGGER.exception("Unexpected error setting up the SmartApp") return self._show_step_user(errors) except Exception: # pylint:disable=broad-except errors['base'] = "app_setup_error" diff --git a/homeassistant/components/smartthings/const.py b/homeassistant/components/smartthings/const.py index 25cd9e8305f..27260b155d1 100644 --- a/homeassistant/components/smartthings/const.py +++ b/homeassistant/components/smartthings/const.py @@ -18,14 +18,16 @@ SIGNAL_SMARTAPP_PREFIX = 'smartthings_smartap_' SETTINGS_INSTANCE_ID = "hassInstanceId" STORAGE_KEY = DOMAIN STORAGE_VERSION = 1 +# Ordered 'specific to least-specific platform' in order for capabilities +# to be drawn-down and represented by the appropriate platform. SUPPORTED_PLATFORMS = [ - 'binary_sensor', 'climate', 'fan', 'light', 'lock', - 'sensor', - 'switch' + 'switch', + 'binary_sensor', + 'sensor' ] VAL_UID = "^(?:([0-9a-fA-F]{32})|([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]" \ "{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}))$" diff --git a/homeassistant/components/smartthings/fan.py b/homeassistant/components/smartthings/fan.py index 4de1744c9b8..e722cd21d65 100644 --- a/homeassistant/components/smartthings/fan.py +++ b/homeassistant/components/smartthings/fan.py @@ -1,4 +1,6 @@ """Support for fans through the SmartThings cloud API.""" +from typing import Optional, Sequence + from homeassistant.components.fan import ( SPEED_HIGH, SPEED_LOW, SPEED_MEDIUM, SPEED_OFF, SUPPORT_SET_SPEED, FanEntity) @@ -29,15 +31,17 @@ async def async_setup_entry(hass, config_entry, async_add_entities): broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] async_add_entities( [SmartThingsFan(device) for device in broker.devices.values() - if is_fan(device)]) + if broker.any_assigned(device.device_id, 'fan')]) -def is_fan(device): - """Determine if the device should be represented as a fan.""" +def get_capabilities(capabilities: Sequence[str]) -> Optional[Sequence[str]]: + """Return all capabilities supported if minimum required are present.""" from pysmartthings import Capability + + supported = [Capability.switch, Capability.fan_speed] # Must have switch and fan_speed - return all(capability in device.capabilities - for capability in [Capability.switch, Capability.fan_speed]) + if all(capability in capabilities for capability in supported): + return supported class SmartThingsFan(SmartThingsEntity, FanEntity): diff --git a/homeassistant/components/smartthings/light.py b/homeassistant/components/smartthings/light.py index ce4b00ca1fe..79a5eabc20a 100644 --- a/homeassistant/components/smartthings/light.py +++ b/homeassistant/components/smartthings/light.py @@ -1,5 +1,6 @@ """Support for lights through the SmartThings cloud API.""" import asyncio +from typing import Optional, Sequence from homeassistant.components.light import ( ATTR_BRIGHTNESS, ATTR_COLOR_TEMP, ATTR_HS_COLOR, ATTR_TRANSITION, @@ -24,29 +25,32 @@ async def async_setup_entry(hass, config_entry, async_add_entities): broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] async_add_entities( [SmartThingsLight(device) for device in broker.devices.values() - if is_light(device)], True) + if broker.any_assigned(device.device_id, 'light')], True) -def is_light(device): - """Determine if the device should be represented as a light.""" +def get_capabilities(capabilities: Sequence[str]) -> Optional[Sequence[str]]: + """Return all capabilities supported if minimum required are present.""" from pysmartthings import Capability + supported = [ + Capability.switch, + Capability.switch_level, + Capability.color_control, + Capability.color_temperature, + ] # Must be able to be turned on/off. - if Capability.switch not in device.capabilities: - return False - # Not a fan (which might also have switch_level) - if Capability.fan_speed in device.capabilities: - return False + if Capability.switch not in capabilities: + return None # Must have one of these light_capabilities = [ Capability.color_control, Capability.color_temperature, Capability.switch_level ] - if any(capability in device.capabilities + if any(capability in capabilities for capability in light_capabilities): - return True - return False + return supported + return None def convert_scale(value, value_scale, target_scale, round_digits=4): diff --git a/homeassistant/components/smartthings/lock.py b/homeassistant/components/smartthings/lock.py index 6dfff0bd02c..d3f633ed0e4 100644 --- a/homeassistant/components/smartthings/lock.py +++ b/homeassistant/components/smartthings/lock.py @@ -1,9 +1,6 @@ -""" -Support for locks through the SmartThings cloud API. +"""Support for locks through the SmartThings cloud API.""" +from typing import Optional, Sequence -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/smartthings.lock/ -""" from homeassistant.components.lock import LockDevice from . import SmartThingsEntity @@ -30,13 +27,16 @@ async def async_setup_entry(hass, config_entry, async_add_entities): broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] async_add_entities( [SmartThingsLock(device) for device in broker.devices.values() - if is_lock(device)]) + if broker.any_assigned(device.device_id, 'lock')]) -def is_lock(device): - """Determine if the device supports the lock capability.""" +def get_capabilities(capabilities: Sequence[str]) -> Optional[Sequence[str]]: + """Return all capabilities supported if minimum required are present.""" from pysmartthings import Capability - return Capability.lock in device.capabilities + + if Capability.lock in capabilities: + return [Capability.lock] + return None class SmartThingsLock(SmartThingsEntity, LockDevice): diff --git a/homeassistant/components/smartthings/sensor.py b/homeassistant/components/smartthings/sensor.py index eb83334c6b3..32047c179b4 100644 --- a/homeassistant/components/smartthings/sensor.py +++ b/homeassistant/components/smartthings/sensor.py @@ -1,5 +1,6 @@ """Support for sensors through the SmartThings cloud API.""" from collections import namedtuple +from typing import Optional, Sequence from homeassistant.const import ( DEVICE_CLASS_BATTERY, DEVICE_CLASS_HUMIDITY, DEVICE_CLASS_ILLUMINANCE, @@ -164,16 +165,22 @@ async def async_setup_entry(hass, config_entry, async_add_entities): broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] sensors = [] for device in broker.devices.values(): - for capability, maps in CAPABILITY_TO_SENSORS.items(): - if capability in device.capabilities: - sensors.extend([ - SmartThingsSensor( - device, m.attribute, m.name, m.default_unit, - m.device_class) - for m in maps]) + 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]) async_add_entities(sensors) +def get_capabilities(capabilities: Sequence[str]) -> Optional[Sequence[str]]: + """Return all capabilities supported if minimum required are present.""" + return [capability for capability in CAPABILITY_TO_SENSORS + if capability in capabilities] + + class SmartThingsSensor(SmartThingsEntity): """Define a SmartThings Binary Sensor.""" diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 1fb4e878cb4..bcbe02f6011 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -21,7 +21,8 @@ "token_already_setup": "The token has already been setup.", "app_setup_error": "Unable to setup the SmartApp. Please try again.", "app_not_installed": "Please ensure you have installed and authorized the Home Assistant SmartApp and try again.", - "base_url_not_https": "The `base_url` for the `http` component must be configured and start with `https://`." + "base_url_not_https": "The `base_url` for the `http` component must be configured and start with `https://`.", + "webhook_error": "SmartThings could not validate the endpoint configured in `base_url`. Please review the [component requirements]({component_url})." } } } \ No newline at end of file diff --git a/homeassistant/components/smartthings/switch.py b/homeassistant/components/smartthings/switch.py index 08cdb74ed77..5a1224f4fc2 100644 --- a/homeassistant/components/smartthings/switch.py +++ b/homeassistant/components/smartthings/switch.py @@ -1,4 +1,6 @@ """Support for switches through the SmartThings cloud API.""" +from typing import Optional, Sequence + from homeassistant.components.switch import SwitchDevice from . import SmartThingsEntity @@ -18,28 +20,17 @@ async def async_setup_entry(hass, config_entry, async_add_entities): broker = hass.data[DOMAIN][DATA_BROKERS][config_entry.entry_id] async_add_entities( [SmartThingsSwitch(device) for device in broker.devices.values() - if is_switch(device)]) + if broker.any_assigned(device.device_id, 'switch')]) -def is_switch(device): - """Determine if the device should be represented as a switch.""" +def get_capabilities(capabilities: Sequence[str]) -> Optional[Sequence[str]]: + """Return all capabilities supported if minimum required are present.""" from pysmartthings import Capability # Must be able to be turned on/off. - if Capability.switch not in device.capabilities: - return False - # Must not have a capability represented by other types. - non_switch_capabilities = [ - Capability.color_control, - Capability.color_temperature, - Capability.fan_speed, - Capability.switch_level - ] - if any(capability in device.capabilities - for capability in non_switch_capabilities): - return False - - return True + if Capability.switch in capabilities: + return [Capability.switch] + return None class SmartThingsSwitch(SmartThingsEntity, SwitchDevice): diff --git a/requirements_all.txt b/requirements_all.txt index 64bd64c0aac..b23c98630a1 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1241,7 +1241,7 @@ pysma==0.3.1 pysmartapp==0.3.0 # homeassistant.components.smartthings -pysmartthings==0.6.1 +pysmartthings==0.6.2 # homeassistant.components.device_tracker.snmp # homeassistant.components.sensor.snmp diff --git a/requirements_test_all.txt b/requirements_test_all.txt index a4e042c9c43..7fbe73eac5f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -217,7 +217,7 @@ pyqwikswitch==0.8 pysmartapp==0.3.0 # homeassistant.components.smartthings -pysmartthings==0.6.1 +pysmartthings==0.6.2 # homeassistant.components.sonos pysonos==0.0.6 diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index 0f1102e2ab1..c5646fb400f 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -100,19 +100,6 @@ async def test_async_setup_platform(): await climate.async_setup_platform(None, None, None) -def test_is_climate(device_factory, legacy_thermostat, - basic_thermostat, thermostat): - """Test climate devices are correctly identified.""" - other_devices = [ - device_factory('Unknown', ['Unknown']), - device_factory("Switch 1", [Capability.switch]) - ] - for device in [legacy_thermostat, basic_thermostat, thermostat]: - assert climate.is_climate(device), device.name - for device in other_devices: - assert not climate.is_climate(device), device.name - - async def test_legacy_thermostat_entity_state(hass, legacy_thermostat): """Tests the state attributes properly match the thermostat type.""" await setup_platform(hass, CLIMATE_DOMAIN, legacy_thermostat) diff --git a/tests/components/smartthings/test_config_flow.py b/tests/components/smartthings/test_config_flow.py index 4d2a43a52c7..7d335703131 100644 --- a/tests/components/smartthings/test_config_flow.py +++ b/tests/components/smartthings/test_config_flow.py @@ -1,8 +1,9 @@ """Tests for the SmartThings config flow module.""" -from unittest.mock import patch +from unittest.mock import Mock, patch from uuid import uuid4 -from aiohttp.client_exceptions import ClientResponseError +from aiohttp import ClientResponseError +from pysmartthings import APIResponseError from homeassistant import data_entry_flow from homeassistant.components.smartthings.config_flow import ( @@ -103,13 +104,50 @@ async def test_token_forbidden(hass, smartthings_mock): assert result['errors'] == {'access_token': 'token_forbidden'} +async def test_webhook_error(hass, smartthings_mock): + """Test an error is when there's an error with the webhook endpoint.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + + data = {'error': {}} + error = APIResponseError(None, None, data=data, status=422) + error.is_target_error = Mock(return_value=True) + + smartthings_mock.return_value.apps.return_value = mock_coro( + exception=error) + + result = await flow.async_step_user({'access_token': str(uuid4())}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + assert result['errors'] == {'base': 'webhook_error'} + + +async def test_api_error(hass, smartthings_mock): + """Test an error is shown when other API errors occur.""" + flow = SmartThingsFlowHandler() + flow.hass = hass + + data = {'error': {}} + error = APIResponseError(None, None, data=data, status=400) + + smartthings_mock.return_value.apps.return_value = mock_coro( + exception=error) + + result = await flow.async_step_user({'access_token': str(uuid4())}) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + assert result['errors'] == {'base': 'app_setup_error'} + + async def test_unknown_api_error(hass, smartthings_mock): """Test an error is shown when there is an unknown API error.""" flow = SmartThingsFlowHandler() flow.hass = hass smartthings_mock.return_value.apps.return_value = mock_coro( - exception=ClientResponseError(None, None, status=500)) + exception=ClientResponseError(None, None, status=404)) result = await flow.async_step_user({'access_token': str(uuid4())}) diff --git a/tests/components/smartthings/test_fan.py b/tests/components/smartthings/test_fan.py index 99627e866d9..db8d9b512de 100644 --- a/tests/components/smartthings/test_fan.py +++ b/tests/components/smartthings/test_fan.py @@ -39,26 +39,6 @@ async def test_async_setup_platform(): await fan.async_setup_platform(None, None, None) -def test_is_fan(device_factory): - """Test fans are correctly identified.""" - non_fans = [ - device_factory('Unknown', ['Unknown']), - device_factory("Switch 1", [Capability.switch]), - device_factory("Non-Switchable Fan", [Capability.fan_speed]), - device_factory("Color Light", - [Capability.switch, Capability.switch_level, - Capability.color_control, - Capability.color_temperature]) - ] - fan_device = device_factory( - "Fan 1", [Capability.switch, Capability.switch_level, - Capability.fan_speed]) - - assert fan.is_fan(fan_device), fan_device.name - for device in non_fans: - assert not fan.is_fan(device), device.name - - async def test_entity_state(hass, device_factory): """Tests the state attributes properly match the fan types.""" device = device_factory( diff --git a/tests/components/smartthings/test_light.py b/tests/components/smartthings/test_light.py index a4f1103f270..72bc5da9063 100644 --- a/tests/components/smartthings/test_light.py +++ b/tests/components/smartthings/test_light.py @@ -65,25 +65,6 @@ async def test_async_setup_platform(): await light.async_setup_platform(None, None, None) -def test_is_light(device_factory, light_devices): - """Test lights are correctly identified.""" - non_lights = [ - device_factory('Unknown', ['Unknown']), - device_factory("Fan 1", - [Capability.switch, Capability.switch_level, - Capability.fan_speed]), - device_factory("Switch 1", [Capability.switch]), - device_factory("Can't be turned off", - [Capability.switch_level, Capability.color_control, - Capability.color_temperature]) - ] - - for device in light_devices: - assert light.is_light(device), device.name - for device in non_lights: - assert not light.is_light(device), device.name - - async def test_entity_state(hass, light_devices): """Tests the state attributes properly match the light types.""" await _setup_platform(hass, *light_devices) diff --git a/tests/components/smartthings/test_lock.py b/tests/components/smartthings/test_lock.py index c73f4ff549e..3739a2dc9b5 100644 --- a/tests/components/smartthings/test_lock.py +++ b/tests/components/smartthings/test_lock.py @@ -20,12 +20,6 @@ async def test_async_setup_platform(): await lock.async_setup_platform(None, None, None) -def test_is_lock(device_factory): - """Test locks are correctly identified.""" - lock_device = device_factory('Lock', [Capability.lock]) - assert lock.is_lock(lock_device) - - async def test_entity_and_device_attributes(hass, device_factory): """Test the attributes of the entity are correct.""" # Arrange diff --git a/tests/components/smartthings/test_switch.py b/tests/components/smartthings/test_switch.py index 15ff3adce86..3f2bedd4f13 100644 --- a/tests/components/smartthings/test_switch.py +++ b/tests/components/smartthings/test_switch.py @@ -35,23 +35,6 @@ async def test_async_setup_platform(): await switch.async_setup_platform(None, None, None) -def test_is_switch(device_factory): - """Test switches are correctly identified.""" - switch_device = device_factory('Switch', [Capability.switch]) - non_switch_devices = [ - device_factory('Light', [Capability.switch, Capability.switch_level]), - device_factory('Fan', [Capability.switch, Capability.fan_speed]), - device_factory('Color Light', [Capability.switch, - Capability.color_control]), - device_factory('Temp Light', [Capability.switch, - Capability.color_temperature]), - device_factory('Unknown', ['Unknown']), - ] - assert switch.is_switch(switch_device) - for non_switch_device in non_switch_devices: - assert not switch.is_switch(non_switch_device) - - async def test_entity_and_device_attributes(hass, device_factory): """Test the attributes of the entity are correct.""" # Arrange From d55693762e367cd0db895c6a0d6fe34e3d85131f Mon Sep 17 00:00:00 2001 From: Andrew Sayre <6730289+andrewsayre@users.noreply.github.com> Date: Sat, 16 Feb 2019 03:49:24 -0600 Subject: [PATCH 27/45] Fix SmartThings Translation Error (#21103) --- homeassistant/components/smartthings/.translations/en.json | 2 +- homeassistant/components/smartthings/strings.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/smartthings/.translations/en.json b/homeassistant/components/smartthings/.translations/en.json index 2091ddb00a2..e35035b8fa0 100644 --- a/homeassistant/components/smartthings/.translations/en.json +++ b/homeassistant/components/smartthings/.translations/en.json @@ -8,7 +8,7 @@ "token_forbidden": "The token does not have the required OAuth scopes.", "token_invalid_format": "The token must be in the UID/GUID format", "token_unauthorized": "The token is invalid or no longer authorized.", - "webhook_error": "SmartThings could not validate the endpoint configured in `base_url`. Please review the [component requirements]({component_url})." + "webhook_error": "SmartThings could not validate the endpoint configured in `base_url`. Please review the component requirements." }, "step": { "user": { diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index bcbe02f6011..3578bcd5138 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -22,7 +22,7 @@ "app_setup_error": "Unable to setup the SmartApp. Please try again.", "app_not_installed": "Please ensure you have installed and authorized the Home Assistant SmartApp and try again.", "base_url_not_https": "The `base_url` for the `http` component must be configured and start with `https://`.", - "webhook_error": "SmartThings could not validate the endpoint configured in `base_url`. Please review the [component requirements]({component_url})." + "webhook_error": "SmartThings could not validate the endpoint configured in `base_url`. Please review the component requirements." } } } \ No newline at end of file From c544845e29fe85e801f1e73277aa47924e7059a5 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Mon, 18 Feb 2019 04:40:51 +0000 Subject: [PATCH 28/45] Fix track_change error in utility_meter (#21134) * split validation * remove any() --- homeassistant/components/utility_meter/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/utility_meter/sensor.py b/homeassistant/components/utility_meter/sensor.py index a59d51d97e2..a01c53b20e3 100644 --- a/homeassistant/components/utility_meter/sensor.py +++ b/homeassistant/components/utility_meter/sensor.py @@ -83,9 +83,9 @@ class UtilityMeterSensor(RestoreEntity): @callback def async_reading(self, entity, old_state, new_state): """Handle the sensor state changes.""" - if any([old_state is None, - old_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE], - new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]]): + if old_state is None or new_state is None or\ + old_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE] or\ + new_state.state in [STATE_UNKNOWN, STATE_UNAVAILABLE]: return if self._unit_of_measurement is None and\ From 8b5aff63aea2b29886242ec71dfb3d440c7950b9 Mon Sep 17 00:00:00 2001 From: John Mihalic <2854333+mezz64@users.noreply.github.com> Date: Mon, 18 Feb 2019 05:20:31 -0500 Subject: [PATCH 29/45] Update pyEight for Python 3.7 Compatability (#21161) --- homeassistant/components/eight_sleep/__init__.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/eight_sleep/__init__.py b/homeassistant/components/eight_sleep/__init__.py index 851fd3d1c31..ca6c8a5a5c6 100644 --- a/homeassistant/components/eight_sleep/__init__.py +++ b/homeassistant/components/eight_sleep/__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 = ['pyeight==0.1.0'] +REQUIREMENTS = ['pyeight==0.1.1'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index b23c98630a1..b83ef7ae084 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1001,7 +1001,7 @@ pyeconet==0.0.6 pyedimax==0.1 # homeassistant.components.eight_sleep -pyeight==0.1.0 +pyeight==0.1.1 # homeassistant.components.media_player.emby pyemby==1.6 From d1fa341a78a022f63c70dbeea4df51eaa922b2f0 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Mon, 18 Feb 2019 10:55:41 -0500 Subject: [PATCH 30/45] Add power source to device and clean up zha listeners (#21174) check available and add comments ensure order on API test --- homeassistant/components/zha/core/const.py | 3 +- homeassistant/components/zha/core/device.py | 12 +- homeassistant/components/zha/core/gateway.py | 64 +++++++--- .../components/zha/core/listeners.py | 111 ++++++++++++++---- homeassistant/components/zha/sensor.py | 5 +- tests/components/zha/common.py | 4 +- tests/components/zha/test_api.py | 14 ++- tests/components/zha/test_binary_sensor.py | 8 +- tests/components/zha/test_fan.py | 3 +- tests/components/zha/test_light.py | 6 +- tests/components/zha/test_sensor.py | 3 +- tests/components/zha/test_switch.py | 4 +- 12 files changed, 179 insertions(+), 58 deletions(-) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index 5edcadc7fce..faa423d8ac4 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -62,7 +62,6 @@ ILLUMINANCE = 'illuminance' PRESSURE = 'pressure' METERING = 'metering' ELECTRICAL_MEASUREMENT = 'electrical_measurement' -POWER_CONFIGURATION = 'power_configuration' GENERIC = 'generic' UNKNOWN = 'unknown' OPENING = 'opening' @@ -73,6 +72,7 @@ ATTR_LEVEL = 'level' LISTENER_ON_OFF = 'on_off' LISTENER_ATTRIBUTE = 'attribute' +LISTENER_BASIC = 'basic' LISTENER_COLOR = 'color' LISTENER_FAN = 'fan' LISTENER_LEVEL = ATTR_LEVEL @@ -113,6 +113,7 @@ CLUSTER_REPORT_CONFIGS = {} CUSTOM_CLUSTER_MAPPINGS = {} COMPONENT_CLUSTERS = {} EVENT_RELAY_CLUSTERS = [] +NO_SENSOR_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 7c972988e9c..7bb39f943f6 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -15,9 +15,9 @@ 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 + QUIRK_CLASS, LISTENER_BASIC ) -from .listeners import EventRelayListener +from .listeners import EventRelayListener, BasicListener _LOGGER = logging.getLogger(__name__) @@ -59,6 +59,7 @@ class ZHADevice: self._zigpy_device.__class__.__module__, self._zigpy_device.__class__.__name__ ) + self.power_source = None @property def name(self): @@ -177,6 +178,13 @@ class ZHADevice: """Initialize listeners.""" _LOGGER.debug('%s: started initialization', self.name) await self._execute_listener_tasks('async_initialize', from_cache) + self.power_source = self.cluster_listeners.get( + LISTENER_BASIC).get_power_source() + _LOGGER.debug( + '%s: power source: %s', + self.name, + BasicListener.POWER_SOURCES.get(self.power_source) + ) _LOGGER.debug('%s: completed initialization', self.name) async def _execute_listener_tasks(self, task_name, *args): diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index ff3c374a850..391b12189cf 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -18,15 +18,15 @@ 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, - POWER_CONFIGURATION, GENERIC, SENSOR_TYPE, EVENT_RELAY_CLUSTERS, - LISTENER_BATTERY, 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) + GENERIC, SENSOR_TYPE, EVENT_RELAY_CLUSTERS, LISTENER_BATTERY, 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) from .device import ZHADevice from ..device_entity import ZhaDeviceEntity from .listeners import ( - LISTENER_REGISTRY, AttributeListener, EventRelayListener, ZDOListener) + LISTENER_REGISTRY, AttributeListener, EventRelayListener, ZDOListener, + BasicListener) from .helpers import convert_ieee _LOGGER = logging.getLogger(__name__) @@ -165,7 +165,21 @@ class ZHAGateway: 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 and we already loaded fresh state above zha_device.update_available(True) + elif not zha_device.available and zha_device.power_source is not None\ + and zha_device.power_source != BasicListener.BATTERY: + # 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( + BasicListener.POWER_SOURCES.get(zha_device.power_source) + ) + ) + await zha_device.async_initialize(from_cache=False) async def _async_process_endpoint( self, endpoint_id, endpoint, discovery_infos, device, zha_device, @@ -312,6 +326,13 @@ async def _handle_single_cluster_matches(hass, endpoint, zha_device, is_new_join, )) + if cluster.cluster_id in NO_SENSOR_CLUSTERS: + cluster_match_tasks.append(_handle_listener_only_cluster_match( + zha_device, + cluster, + is_new_join, + )) + for cluster in endpoint.out_clusters.values(): if cluster.cluster_id not in profile_clusters[1]: cluster_match_tasks.append(_handle_single_cluster_match( @@ -338,6 +359,12 @@ async def _handle_single_cluster_matches(hass, endpoint, zha_device, return cluster_matches +async def _handle_listener_only_cluster_match( + zha_device, cluster, is_new_join): + """Handle a listener only cluster match.""" + await _create_cluster_listener(cluster, zha_device, is_new_join) + + async def _handle_single_cluster_match(hass, zha_device, cluster, device_key, device_classes, is_new_join): """Dispatch a single cluster match to a HA component.""" @@ -352,11 +379,6 @@ async def _handle_single_cluster_match(hass, zha_device, cluster, device_key, listeners = [] await _create_cluster_listener(cluster, zha_device, is_new_join, listeners=listeners) - # don't actually create entities for PowerConfiguration - # find a better way to do this without abusing single cluster reg - from zigpy.zcl.clusters.general import PowerConfiguration - if cluster.cluster_id == PowerConfiguration.cluster_id: - return cluster_key = "{}-{}".format(device_key, cluster.cluster_id) discovery_info = { @@ -405,6 +427,10 @@ def establish_device_mappings(): EVENT_RELAY_CLUSTERS.append(zcl.clusters.general.LevelControl.cluster_id) EVENT_RELAY_CLUSTERS.append(zcl.clusters.general.OnOff.cluster_id) + NO_SENSOR_CLUSTERS.append(zcl.clusters.general.Basic.cluster_id) + NO_SENSOR_CLUSTERS.append( + zcl.clusters.general.PowerConfiguration.cluster_id) + DEVICE_CLASS[zha.PROFILE_ID].update({ zha.DeviceType.ON_OFF_SWITCH: 'binary_sensor', zha.DeviceType.LEVEL_CONTROL_SWITCH: 'binary_sensor', @@ -442,7 +468,6 @@ def establish_device_mappings(): zcl.clusters.measurement.IlluminanceMeasurement: 'sensor', zcl.clusters.smartenergy.Metering: 'sensor', zcl.clusters.homeautomation.ElectricalMeasurement: 'sensor', - zcl.clusters.general.PowerConfiguration: 'sensor', zcl.clusters.security.IasZone: 'binary_sensor', zcl.clusters.measurement.OccupancySensing: 'binary_sensor', zcl.clusters.hvac.Fan: 'fan', @@ -462,8 +487,6 @@ def establish_device_mappings(): zcl.clusters.smartenergy.Metering.cluster_id: METERING, zcl.clusters.homeautomation.ElectricalMeasurement.cluster_id: ELECTRICAL_MEASUREMENT, - zcl.clusters.general.PowerConfiguration.cluster_id: - POWER_CONFIGURATION, }) BINARY_SENSOR_TYPES.update({ @@ -473,6 +496,19 @@ def establish_device_mappings(): }) CLUSTER_REPORT_CONFIGS.update({ + zcl.clusters.general.Alarms.cluster_id: [], + zcl.clusters.general.Basic.cluster_id: [], + zcl.clusters.general.Commissioning.cluster_id: [], + zcl.clusters.general.Identify.cluster_id: [], + zcl.clusters.general.Groups.cluster_id: [], + zcl.clusters.general.Scenes.cluster_id: [], + zcl.clusters.general.Partition.cluster_id: [], + zcl.clusters.general.Ota.cluster_id: [], + zcl.clusters.general.PowerProfile.cluster_id: [], + zcl.clusters.general.ApplianceControl.cluster_id: [], + zcl.clusters.general.PollControl.cluster_id: [], + zcl.clusters.general.GreenPowerProxy.cluster_id: [], + zcl.clusters.general.OnOffConfiguration.cluster_id: [], zcl.clusters.general.OnOff.cluster_id: [{ 'attr': 'on_off', 'config': REPORT_CONFIG_IMMEDIATE diff --git a/homeassistant/components/zha/core/listeners.py b/homeassistant/components/zha/core/listeners.py index 1b240d499b4..f8d24ce903c 100644 --- a/homeassistant/components/zha/core/listeners.py +++ b/homeassistant/components/zha/core/listeners.py @@ -18,7 +18,10 @@ from .helpers import ( safe_read, get_attr_id_by_name, bind_cluster) from .const import ( CLUSTER_REPORT_CONFIGS, REPORT_CONFIG_DEFAULT, SIGNAL_ATTR_UPDATED, - SIGNAL_MOVE_LEVEL, SIGNAL_SET_LEVEL, SIGNAL_STATE_ATTR, ATTR_LEVEL + SIGNAL_MOVE_LEVEL, SIGNAL_SET_LEVEL, SIGNAL_STATE_ATTR, LISTENER_BASIC, + LISTENER_ATTRIBUTE, LISTENER_ON_OFF, LISTENER_COLOR, LISTENER_FAN, + LISTENER_LEVEL, LISTENER_ZONE, LISTENER_ACTIVE_POWER, LISTENER_BATTERY, + LISTENER_EVENT_RELAY ) LISTENER_REGISTRY = {} @@ -30,12 +33,25 @@ def populate_listener_registry(): """Populate the listener registry.""" from zigpy import zcl LISTENER_REGISTRY.update({ + zcl.clusters.general.Alarms.cluster_id: ClusterListener, + zcl.clusters.general.Commissioning.cluster_id: ClusterListener, + zcl.clusters.general.Identify.cluster_id: ClusterListener, + zcl.clusters.general.Groups.cluster_id: ClusterListener, + zcl.clusters.general.Scenes.cluster_id: ClusterListener, + zcl.clusters.general.Partition.cluster_id: ClusterListener, + zcl.clusters.general.Ota.cluster_id: ClusterListener, + zcl.clusters.general.PowerProfile.cluster_id: ClusterListener, + zcl.clusters.general.ApplianceControl.cluster_id: ClusterListener, + zcl.clusters.general.PollControl.cluster_id: ClusterListener, + zcl.clusters.general.GreenPowerProxy.cluster_id: ClusterListener, + zcl.clusters.general.OnOffConfiguration.cluster_id: ClusterListener, zcl.clusters.general.OnOff.cluster_id: OnOffListener, zcl.clusters.general.LevelControl.cluster_id: LevelListener, zcl.clusters.lighting.Color.cluster_id: ColorListener, zcl.clusters.homeautomation.ElectricalMeasurement.cluster_id: ActivePowerListener, zcl.clusters.general.PowerConfiguration.cluster_id: BatteryListener, + zcl.clusters.general.Basic.cluster_id: BasicListener, zcl.clusters.security.IasZone.cluster_id: IASZoneListener, zcl.clusters.hvac.Fan.cluster_id: FanListener, }) @@ -92,6 +108,7 @@ class ClusterListener: def __init__(self, cluster, device): """Initialize ClusterListener.""" + self.name = 'cluster_{}'.format(cluster.cluster_id) self._cluster = cluster self._zha_device = device self._unique_id = construct_unique_id(cluster) @@ -216,11 +233,10 @@ class ClusterListener: class AttributeListener(ClusterListener): """Listener for the attribute reports cluster.""" - name = 'attribute' - def __init__(self, cluster, device): """Initialize AttributeListener.""" super().__init__(cluster, device) + self.name = LISTENER_ATTRIBUTE attr = self._report_config[0].get('attr') if isinstance(attr, str): self._value_attribute = get_attr_id_by_name(self.cluster, attr) @@ -247,13 +263,12 @@ class AttributeListener(ClusterListener): class OnOffListener(ClusterListener): """Listener for the OnOff Zigbee cluster.""" - name = 'on_off' - ON_OFF = 0 def __init__(self, cluster, device): - """Initialize ClusterListener.""" + """Initialize OnOffListener.""" super().__init__(cluster, device) + self.name = LISTENER_ON_OFF self._state = None @callback @@ -295,10 +310,13 @@ class OnOffListener(ClusterListener): class LevelListener(ClusterListener): """Listener for the LevelControl Zigbee cluster.""" - name = ATTR_LEVEL - CURRENT_LEVEL = 0 + def __init__(self, cluster, device): + """Initialize LevelListener.""" + super().__init__(cluster, device) + self.name = LISTENER_LEVEL + @callback def cluster_command(self, tsn, command_id, args): """Handle commands received to this cluster.""" @@ -350,7 +368,10 @@ class LevelListener(ClusterListener): class IASZoneListener(ClusterListener): """Listener for the IASZone Zigbee cluster.""" - name = 'zone' + def __init__(self, cluster, device): + """Initialize LevelListener.""" + super().__init__(cluster, device) + self.name = LISTENER_ZONE @callback def cluster_command(self, tsn, command_id, args): @@ -415,7 +436,10 @@ class IASZoneListener(ClusterListener): class ActivePowerListener(AttributeListener): """Listener that polls active power level.""" - name = 'active_power' + def __init__(self, cluster, device): + """Initialize ActivePowerListener.""" + super().__init__(cluster, device) + self.name = LISTENER_ACTIVE_POWER async def async_update(self): """Retrieve latest state.""" @@ -423,7 +447,7 @@ class ActivePowerListener(AttributeListener): # This is a polling listener. Don't allow cache. result = await self.get_attribute_value( - 'active_power', from_cache=False) + LISTENER_ACTIVE_POWER, from_cache=False) async_dispatcher_send( self._zha_device.hass, "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), @@ -433,14 +457,53 @@ class ActivePowerListener(AttributeListener): async def async_initialize(self, from_cache): """Initialize listener.""" await self.get_attribute_value( - 'active_power', from_cache=from_cache) + LISTENER_ACTIVE_POWER, from_cache=from_cache) await super().async_initialize(from_cache) +class BasicListener(ClusterListener): + """Listener to interact with the basic cluster.""" + + BATTERY = 3 + POWER_SOURCES = { + 0: 'Unknown', + 1: 'Mains (single phase)', + 2: 'Mains (3 phase)', + BATTERY: 'Battery', + 4: 'DC source', + 5: 'Emergency mains constantly powered', + 6: 'Emergency mains and transfer switch' + } + + def __init__(self, cluster, device): + """Initialize BasicListener.""" + super().__init__(cluster, device) + self.name = LISTENER_BASIC + self._power_source = None + + async def async_configure(self): + """Configure this listener.""" + await super().async_configure() + await self.async_initialize(False) + + async def async_initialize(self, from_cache): + """Initialize listener.""" + self._power_source = await self.get_attribute_value( + 'power_source', from_cache=from_cache) + await super().async_initialize(from_cache) + + def get_power_source(self): + """Get the power source.""" + return self._power_source + + class BatteryListener(ClusterListener): """Listener that polls active power level.""" - name = 'battery' + def __init__(self, cluster, device): + """Initialize BatteryListener.""" + super().__init__(cluster, device) + self.name = LISTENER_BATTERY @callback def attribute_updated(self, attrid, value): @@ -480,7 +543,10 @@ class BatteryListener(ClusterListener): class EventRelayListener(ClusterListener): """Event relay that can be attached to zigbee clusters.""" - name = 'event_relay' + def __init__(self, cluster, device): + """Initialize EventRelayListener.""" + super().__init__(cluster, device) + self.name = LISTENER_EVENT_RELAY @callback def attribute_updated(self, attrid, value): @@ -512,15 +578,14 @@ class EventRelayListener(ClusterListener): class ColorListener(ClusterListener): """Color listener.""" - name = 'color' - CAPABILITIES_COLOR_XY = 0x08 CAPABILITIES_COLOR_TEMP = 0x10 UNSUPPORTED_ATTRIBUTE = 0x86 def __init__(self, cluster, device): - """Initialize ClusterListener.""" + """Initialize ColorListener.""" super().__init__(cluster, device) + self.name = LISTENER_COLOR self._color_capabilities = None def get_color_capabilities(self): @@ -550,10 +615,13 @@ class ColorListener(ClusterListener): class FanListener(ClusterListener): """Fan listener.""" - name = 'fan' - _value_attribute = 0 + def __init__(self, cluster, device): + """Initialize FanListener.""" + super().__init__(cluster, device) + self.name = LISTENER_FAN + async def async_set_speed(self, value) -> None: """Set the speed of the fan.""" from zigpy.exceptions import DeliveryError @@ -595,10 +663,9 @@ class FanListener(ClusterListener): class ZDOListener: """Listener for ZDO events.""" - name = 'zdo' - def __init__(self, cluster, device): - """Initialize ClusterListener.""" + """Initialize ZDOListener.""" + self.name = 'zdo' self._cluster = cluster self._zha_device = device self._status = ListenerStatus.CREATED diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index ad566df00f4..9c00d8124bb 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -12,8 +12,8 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from .core.const import ( DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, HUMIDITY, TEMPERATURE, ILLUMINANCE, PRESSURE, METERING, ELECTRICAL_MEASUREMENT, - POWER_CONFIGURATION, GENERIC, SENSOR_TYPE, LISTENER_ATTRIBUTE, - LISTENER_ACTIVE_POWER, SIGNAL_ATTR_UPDATED, SIGNAL_STATE_ATTR) + GENERIC, SENSOR_TYPE, LISTENER_ATTRIBUTE, LISTENER_ACTIVE_POWER, + SIGNAL_ATTR_UPDATED, SIGNAL_STATE_ATTR) from .entity import ZhaEntity _LOGGER = logging.getLogger(__name__) @@ -71,7 +71,6 @@ UNIT_REGISTRY = { ILLUMINANCE: 'lx', METERING: 'W', ELECTRICAL_MEASUREMENT: 'W', - POWER_CONFIGURATION: '%', GENERIC: None } diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index f0e1aa701e7..cd2eb53c3fe 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -174,6 +174,7 @@ async def async_test_device_join( only trigger during device joins can be tested. """ from zigpy.zcl.foundation import Status + from zigpy.zcl.clusters.general import Basic # create zigpy device mocking out the zigbee network operations with patch( 'zigpy.zcl.Cluster.configure_reporting', @@ -182,7 +183,8 @@ async def async_test_device_join( 'zigpy.zcl.Cluster.bind', return_value=mock_coro([Status.SUCCESS, Status.SUCCESS])): zigpy_device = await async_init_zigpy_device( - hass, [cluster_id], [], device_type, zha_gateway, + hass, [cluster_id, Basic.cluster_id], [], device_type, + zha_gateway, ieee="00:0d:6f:00:0a:90:69:f7", manufacturer="FakeMan{}".format(cluster_id), model="FakeMod{}".format(cluster_id), diff --git a/tests/components/zha/test_api.py b/tests/components/zha/test_api.py index ad139d81ddf..616a94e8b89 100644 --- a/tests/components/zha/test_api.py +++ b/tests/components/zha/test_api.py @@ -17,14 +17,14 @@ from .common import async_init_zigpy_device @pytest.fixture async def zha_client(hass, config_entry, zha_gateway, hass_ws_client): """Test zha switch platform.""" - from zigpy.zcl.clusters.general import OnOff + from zigpy.zcl.clusters.general import OnOff, Basic # load the ZHA API async_load_api(hass, Mock(), zha_gateway) # create zigpy device await async_init_zigpy_device( - hass, [OnOff.cluster_id], [], None, zha_gateway) + hass, [OnOff.cluster_id, Basic.cluster_id], [], None, zha_gateway) # load up switch domain await hass.config_entries.async_forward_entry_setup( @@ -44,10 +44,16 @@ async def test_device_clusters(hass, config_entry, zha_gateway, zha_client): msg = await zha_client.receive_json() - assert len(msg['result']) == 1 + assert len(msg['result']) == 2 - cluster_info = msg['result'][0] + cluster_infos = sorted(msg['result'], key=lambda k: k[ID]) + cluster_info = cluster_infos[0] + assert cluster_info[TYPE] == IN + assert cluster_info[ID] == 0 + assert cluster_info[NAME] == 'Basic' + + cluster_info = cluster_infos[1] assert cluster_info[TYPE] == IN assert cluster_info[ID] == 6 assert cluster_info[NAME] == 'OnOff' diff --git a/tests/components/zha/test_binary_sensor.py b/tests/components/zha/test_binary_sensor.py index c81f96468ce..d0763b8fb10 100644 --- a/tests/components/zha/test_binary_sensor.py +++ b/tests/components/zha/test_binary_sensor.py @@ -11,13 +11,13 @@ async def test_binary_sensor(hass, config_entry, zha_gateway): """Test zha binary_sensor platform.""" from zigpy.zcl.clusters.security import IasZone from zigpy.zcl.clusters.measurement import OccupancySensing - from zigpy.zcl.clusters.general import OnOff, LevelControl + from zigpy.zcl.clusters.general import OnOff, LevelControl, Basic from zigpy.profiles.zha import DeviceType # create zigpy devices zigpy_device_zone = await async_init_zigpy_device( hass, - [IasZone.cluster_id], + [IasZone.cluster_id, Basic.cluster_id], [], None, zha_gateway @@ -25,7 +25,7 @@ async def test_binary_sensor(hass, config_entry, zha_gateway): zigpy_device_remote = await async_init_zigpy_device( hass, - [], + [Basic.cluster_id], [OnOff.cluster_id, LevelControl.cluster_id], DeviceType.LEVEL_CONTROL_SWITCH, zha_gateway, @@ -36,7 +36,7 @@ async def test_binary_sensor(hass, config_entry, zha_gateway): zigpy_device_occupancy = await async_init_zigpy_device( hass, - [OccupancySensing.cluster_id], + [OccupancySensing.cluster_id, Basic.cluster_id], [], None, zha_gateway, diff --git a/tests/components/zha/test_fan.py b/tests/components/zha/test_fan.py index 6beafc6ca8e..a70e0e5ea40 100644 --- a/tests/components/zha/test_fan.py +++ b/tests/components/zha/test_fan.py @@ -17,11 +17,12 @@ from .common import ( async def test_fan(hass, config_entry, zha_gateway): """Test zha fan platform.""" from zigpy.zcl.clusters.hvac import Fan + from zigpy.zcl.clusters.general import Basic from zigpy.zcl.foundation import Status # create zigpy device zigpy_device = await async_init_zigpy_device( - hass, [Fan.cluster_id], [], None, zha_gateway) + hass, [Fan.cluster_id, Basic.cluster_id], [], None, zha_gateway) # load up fan domain await hass.config_entries.async_forward_entry_setup( diff --git a/tests/components/zha/test_light.py b/tests/components/zha/test_light.py index 9c5e69d1347..38d7caedaad 100644 --- a/tests/components/zha/test_light.py +++ b/tests/components/zha/test_light.py @@ -14,13 +14,13 @@ OFF = 0 async def test_light(hass, config_entry, zha_gateway): """Test zha light platform.""" - from zigpy.zcl.clusters.general import OnOff, LevelControl + from zigpy.zcl.clusters.general import OnOff, LevelControl, Basic from zigpy.profiles.zha import DeviceType # create zigpy devices zigpy_device_on_off = await async_init_zigpy_device( hass, - [OnOff.cluster_id], + [OnOff.cluster_id, Basic.cluster_id], [], DeviceType.ON_OFF_LIGHT, zha_gateway @@ -28,7 +28,7 @@ async def test_light(hass, config_entry, zha_gateway): zigpy_device_level = await async_init_zigpy_device( hass, - [OnOff.cluster_id, LevelControl.cluster_id], + [OnOff.cluster_id, LevelControl.cluster_id, Basic.cluster_id], [], DeviceType.ON_OFF_LIGHT, zha_gateway, diff --git a/tests/components/zha/test_sensor.py b/tests/components/zha/test_sensor.py index d16cafb7df8..c348ef0d0a7 100644 --- a/tests/components/zha/test_sensor.py +++ b/tests/components/zha/test_sensor.py @@ -86,6 +86,7 @@ async def async_build_devices(hass, zha_gateway, config_entry, cluster_ids): A dict containing relevant device info for testing is returned. It contains the entity id, zigpy device, and the zigbee cluster for the sensor. """ + from zigpy.zcl.clusters.general import Basic device_infos = {} counter = 0 for cluster_id in cluster_ids: @@ -93,7 +94,7 @@ async def async_build_devices(hass, zha_gateway, config_entry, cluster_ids): device_infos[cluster_id] = {"zigpy_device": None} device_infos[cluster_id]["zigpy_device"] = await \ async_init_zigpy_device( - hass, [cluster_id], [], None, zha_gateway, + hass, [cluster_id, Basic.cluster_id], [], None, zha_gateway, ieee="{}0:15:8d:00:02:32:4f:32".format(counter), manufacturer="Fake{}".format(cluster_id), model="FakeModel{}".format(cluster_id)) diff --git a/tests/components/zha/test_switch.py b/tests/components/zha/test_switch.py index 32c8ee64e67..1fc21e34cd8 100644 --- a/tests/components/zha/test_switch.py +++ b/tests/components/zha/test_switch.py @@ -14,12 +14,12 @@ OFF = 0 async def test_switch(hass, config_entry, zha_gateway): """Test zha switch platform.""" - from zigpy.zcl.clusters.general import OnOff + from zigpy.zcl.clusters.general import OnOff, Basic from zigpy.zcl.foundation import Status # create zigpy device zigpy_device = await async_init_zigpy_device( - hass, [OnOff.cluster_id], [], None, zha_gateway) + hass, [OnOff.cluster_id, Basic.cluster_id], [], None, zha_gateway) # load up switch domain await hass.config_entries.async_forward_entry_setup( From 1768c2b447cbf1a1e37b0bdec6a4bb995dbdc6b0 Mon Sep 17 00:00:00 2001 From: sjabby Date: Mon, 18 Feb 2019 22:05:46 +0100 Subject: [PATCH 31/45] Fix for #19072 (#21175) * Fix for #19072 PR #19072 introduced the custom_effect feature but it didnt make it optional as the documentation states. This causes error on startup and the component does not work. ``` Error while setting up platform flux_led Traceback (most recent call last): File "/srv/homeassistant/lib/python3.5/site-packages/homeassistant/helpers/entity_platform.py", line 128, in _async_setup_platform SLOW_SETUP_MAX_WAIT, loop=hass.loop) File "/usr/lib/python3.5/asyncio/tasks.py", line 400, in wait_for return fut.result() File "/usr/lib/python3.5/asyncio/futures.py", line 293, in result raise self._exception File "/usr/lib/python3.5/concurrent/futures/thread.py", line 55, in run result = self.fn(*self.args, **self.kwargs) File "/srv/homeassistant/lib/python3.5/site-packages/homeassistant/components/light/flux_led.py", line 135, in setup_platform device[CONF_CUSTOM_EFFECT] = device_config[CONF_CUSTOM_EFFECT] KeyError: 'custom_effect' ``` Changing this line to make the custom_effect optional as the original intention. * Update flux_led.py --- homeassistant/components/light/flux_led.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/light/flux_led.py b/homeassistant/components/light/flux_led.py index 088fc871fc1..5ecf3f55e10 100644 --- a/homeassistant/components/light/flux_led.py +++ b/homeassistant/components/light/flux_led.py @@ -132,7 +132,7 @@ def setup_platform(hass, config, add_entities, discovery_info=None): device['ipaddr'] = ipaddr device[CONF_PROTOCOL] = device_config.get(CONF_PROTOCOL) device[ATTR_MODE] = device_config[ATTR_MODE] - device[CONF_CUSTOM_EFFECT] = device_config[CONF_CUSTOM_EFFECT] + device[CONF_CUSTOM_EFFECT] = device_config.get(CONF_CUSTOM_EFFECT) light = FluxLight(device) lights.append(light) light_ips.append(ipaddr) From 5ad252bd3b0626aa268bdd7e5576766a20926575 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Mon, 18 Feb 2019 13:31:21 -0800 Subject: [PATCH 32/45] Bumped version to 0.88.0b3 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index c5e3e082e0b..5885bf6acfe 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 88 -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 dfb45c03e4f6aefced403e46f831b1b6dfc3f010 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 19 Feb 2019 10:14:33 -0800 Subject: [PATCH 33/45] Updated frontend to 20190219.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 3b1d961ebe7..dce5b78bb6d 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==20190218.0'] +REQUIREMENTS = ['home-assistant-frontend==20190219.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index b83ef7ae084..766d699aedc 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==20190218.0 +home-assistant-frontend==20190219.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7fbe73eac5f..e706a1428de 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==20190218.0 +home-assistant-frontend==20190219.0 # homeassistant.components.homekit_controller homekit==0.12.2 From 4562bdc69f00fc702a536d47643038a8bee160fb Mon Sep 17 00:00:00 2001 From: Anders Melchiorsen Date: Tue, 19 Feb 2019 06:11:56 +0100 Subject: [PATCH 34/45] Upgrade aioimaplib for Python 3.7 compatibility (#21197) --- homeassistant/components/sensor/imap.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/sensor/imap.py b/homeassistant/components/sensor/imap.py index b8d363417c2..571d05e78e9 100644 --- a/homeassistant/components/sensor/imap.py +++ b/homeassistant/components/sensor/imap.py @@ -20,7 +20,7 @@ from homeassistant.helpers.entity import Entity _LOGGER = logging.getLogger(__name__) -REQUIREMENTS = ['aioimaplib==0.7.13'] +REQUIREMENTS = ['aioimaplib==0.7.15'] CONF_SERVER = 'server' CONF_FOLDER = 'folder' diff --git a/requirements_all.txt b/requirements_all.txt index 766d699aedc..68b5c971f18 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -124,7 +124,7 @@ aiohue==1.9.0 aioiliad==0.1.1 # homeassistant.components.sensor.imap -aioimaplib==0.7.13 +aioimaplib==0.7.15 # homeassistant.components.lifx aiolifx==0.6.7 From 620f23d433537d4f6b95741ab5070d2e98458048 Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 19 Feb 2019 16:45:21 +0000 Subject: [PATCH 35/45] ordered by last occurence (#21200) --- homeassistant/components/system_log/__init__.py | 10 +++++++--- tests/components/system_log/test_init.py | 5 +++++ 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/system_log/__init__.py b/homeassistant/components/system_log/__init__.py index 9e968111c9c..16786bdeba4 100644 --- a/homeassistant/components/system_log/__init__.py +++ b/homeassistant/components/system_log/__init__.py @@ -88,7 +88,7 @@ class LogEntry: def __init__(self, record, stack, source): """Initialize a log entry.""" - self.timestamp = record.created + self.first_occured = self.timestamp = record.created self.level = record.levelname self.message = record.getMessage() if record.exc_info: @@ -125,9 +125,13 @@ class DedupStore(OrderedDict): key = str(entry.hash()) if key in self: - entry.count = self[key].count + 1 + # Update stored entry + self[key].count += 1 + self[key].timestamp = entry.timestamp - self[key] = entry + self.move_to_end(key) + else: + self[key] = entry if len(self) > self.maxlen: # Removes the first record which should also be the oldest diff --git a/tests/components/system_log/test_init.py b/tests/components/system_log/test_init.py index c1d79c9f33f..14047399aff 100644 --- a/tests/components/system_log/test_init.py +++ b/tests/components/system_log/test_init.py @@ -152,6 +152,11 @@ async def test_dedup_logs(hass, hass_client): assert log[1]["count"] == 2 assert_log(log[1], '', 'error message 2', 'ERROR') + _LOGGER.error('error message 2') + log = await get_error_log(hass, hass_client, 2) + assert_log(log[0], '', 'error message 2', 'ERROR') + assert log[0]["timestamp"] > log[0]["first_occured"] + async def test_clear_logs(hass, hass_client): """Test that the log can be cleared via a service call.""" From 4500760b52ccc911a5f3b1e5a0402bea7303306d Mon Sep 17 00:00:00 2001 From: ehendrix23 Date: Tue, 19 Feb 2019 09:44:42 -0700 Subject: [PATCH 36/45] Set aioharmony version to 0.1.8 (#21213) Update aioharmony version to support latest HUB firmware (4.15.250). --- homeassistant/components/harmony/remote.py | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/harmony/remote.py b/homeassistant/components/harmony/remote.py index a5e4f5a8528..489fe9144f2 100644 --- a/homeassistant/components/harmony/remote.py +++ b/homeassistant/components/harmony/remote.py @@ -22,7 +22,7 @@ import homeassistant.helpers.config_validation as cv from homeassistant.exceptions import PlatformNotReady from homeassistant.util import slugify -REQUIREMENTS = ['aioharmony==0.1.5'] +REQUIREMENTS = ['aioharmony==0.1.8'] _LOGGER = logging.getLogger(__name__) diff --git a/requirements_all.txt b/requirements_all.txt index 68b5c971f18..b8d46143716 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -111,7 +111,7 @@ aiofreepybox==0.0.6 aioftp==0.12.0 # homeassistant.components.harmony.remote -aioharmony==0.1.5 +aioharmony==0.1.8 # homeassistant.components.emulated_hue # homeassistant.components.http From c0f83b41648188b679123d884300c1855fcf5f2e Mon Sep 17 00:00:00 2001 From: carstenschroeder Date: Tue, 19 Feb 2019 18:42:00 +0100 Subject: [PATCH 37/45] Push pyads to 3.0.7 (#21216) * Push to pyads 3.0.7 * Correct too long line --- homeassistant/components/ads/__init__.py | 9 +++++---- requirements_all.txt | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/ads/__init__.py b/homeassistant/components/ads/__init__.py index 48b5ea21cbc..cfd0f37caa0 100644 --- a/homeassistant/components/ads/__init__.py +++ b/homeassistant/components/ads/__init__.py @@ -9,7 +9,7 @@ from homeassistant.const import CONF_DEVICE, CONF_PORT, CONF_IP_ADDRESS, \ EVENT_HOMEASSISTANT_STOP import homeassistant.helpers.config_validation as cv -REQUIREMENTS = ['pyads==2.2.6'] +REQUIREMENTS = ['pyads==3.0.7'] _LOGGER = logging.getLogger(__name__) @@ -73,9 +73,10 @@ def setup(hass, config): try: ads = AdsHub(client) - except pyads.pyads.ADSError: + except pyads.ADSError: _LOGGER.error( - "Could not connect to ADS host (netid=%s, port=%s)", net_id, port) + "Could not connect to ADS host (netid=%s, ip=%s, port=%s)", + net_id, ip_address, port) return False hass.data[DATA_ADS] = ads @@ -168,7 +169,7 @@ class AdsHub: self._notification_items[hnotify] = NotificationItem( hnotify, huser, name, plc_datatype, callback) - def _device_notification_callback(self, addr, notification, huser): + def _device_notification_callback(self, notification, name): """Handle device notifications.""" contents = notification.contents diff --git a/requirements_all.txt b/requirements_all.txt index b8d46143716..30f48e6587c 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -916,7 +916,7 @@ pyW800rf32==0.1 # py_noaa==0.3.0 # homeassistant.components.ads -pyads==2.2.6 +pyads==3.0.7 # homeassistant.components.sensor.aftership pyaftership==0.1.2 From 4cc90c437f8100d44d1ddd988a44eef5e269eed0 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 19 Feb 2019 10:31:47 -0800 Subject: [PATCH 38/45] Bumped version to 0.88.0b4 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 5885bf6acfe..1924d145529 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 88 -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 d09cf8dd17278a515fb1d0b927a2197f30f33ee6 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 20 Feb 2019 08:55:42 -0800 Subject: [PATCH 39/45] Updated frontend to 20190220.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 dce5b78bb6d..caf6bbccb5c 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==20190219.0'] +REQUIREMENTS = ['home-assistant-frontend==20190220.0'] DOMAIN = 'frontend' DEPENDENCIES = ['api', 'websocket_api', 'http', 'system_log', diff --git a/requirements_all.txt b/requirements_all.txt index 30f48e6587c..ac85efe3be7 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==20190219.0 +home-assistant-frontend==20190220.0 # homeassistant.components.zwave homeassistant-pyozw==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e706a1428de..1ee80607150 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==20190219.0 +home-assistant-frontend==20190220.0 # homeassistant.components.homekit_controller homekit==0.12.2 From 9057da01bdc8faed1bde57b7d8b64cf06455286b Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Tue, 19 Feb 2019 12:58:22 -0500 Subject: [PATCH 40/45] Refactor ZHA listeners into channels (#21196) * refactor listeners to channels * update coveragerc --- .coveragerc | 2 +- homeassistant/components/zha/__init__.py | 4 +- homeassistant/components/zha/binary_sensor.py | 46 +- homeassistant/components/zha/core/__init__.py | 3 - .../components/zha/core/channels/__init__.py | 308 ++++++++ .../components/zha/core/channels/closures.py | 9 + .../components/zha/core/channels/general.py | 202 +++++ .../zha/core/channels/homeautomation.py | 40 + .../components/zha/core/channels/hvac.py | 62 ++ .../components/zha/core/channels/lighting.py | 48 ++ .../components/zha/core/channels/lightlink.py | 9 + .../zha/core/channels/manufacturerspecific.py | 9 + .../zha/core/channels/measurement.py | 9 + .../components/zha/core/channels/protocol.py | 9 + .../components/zha/core/channels/registry.py | 46 ++ .../components/zha/core/channels/security.py | 82 ++ .../zha/core/channels/smartenergy.py | 9 + homeassistant/components/zha/core/const.py | 20 +- homeassistant/components/zha/core/device.py | 77 +- homeassistant/components/zha/core/gateway.py | 95 +-- .../components/zha/core/listeners.py | 706 ------------------ homeassistant/components/zha/device_entity.py | 25 +- homeassistant/components/zha/entity.py | 26 +- homeassistant/components/zha/fan.py | 14 +- homeassistant/components/zha/light.py | 38 +- homeassistant/components/zha/sensor.py | 20 +- homeassistant/components/zha/switch.py | 12 +- tests/components/zha/conftest.py | 6 +- 28 files changed, 1037 insertions(+), 899 deletions(-) create mode 100644 homeassistant/components/zha/core/channels/__init__.py create mode 100644 homeassistant/components/zha/core/channels/closures.py create mode 100644 homeassistant/components/zha/core/channels/general.py create mode 100644 homeassistant/components/zha/core/channels/homeautomation.py create mode 100644 homeassistant/components/zha/core/channels/hvac.py create mode 100644 homeassistant/components/zha/core/channels/lighting.py create mode 100644 homeassistant/components/zha/core/channels/lightlink.py create mode 100644 homeassistant/components/zha/core/channels/manufacturerspecific.py create mode 100644 homeassistant/components/zha/core/channels/measurement.py create mode 100644 homeassistant/components/zha/core/channels/protocol.py create mode 100644 homeassistant/components/zha/core/channels/registry.py create mode 100644 homeassistant/components/zha/core/channels/security.py create mode 100644 homeassistant/components/zha/core/channels/smartenergy.py delete mode 100644 homeassistant/components/zha/core/listeners.py diff --git a/.coveragerc b/.coveragerc index 5931322f80b..8e5b61136c0 100644 --- a/.coveragerc +++ b/.coveragerc @@ -665,11 +665,11 @@ omit = homeassistant/components/zha/__init__.py homeassistant/components/zha/api.py homeassistant/components/zha/const.py + homeassistant/components/zha/core/channels/* homeassistant/components/zha/core/const.py homeassistant/components/zha/core/device.py homeassistant/components/zha/core/gateway.py homeassistant/components/zha/core/helpers.py - homeassistant/components/zha/core/listeners.py homeassistant/components/zha/device_entity.py homeassistant/components/zha/entity.py homeassistant/components/zha/light.py diff --git a/homeassistant/components/zha/__init__.py b/homeassistant/components/zha/__init__.py index b8ef5c40838..6c7e83689ad 100644 --- a/homeassistant/components/zha/__init__.py +++ b/homeassistant/components/zha/__init__.py @@ -26,7 +26,7 @@ from .core.const import ( DATA_ZHA_RADIO, DEFAULT_BAUDRATE, DEFAULT_DATABASE_NAME, DEFAULT_RADIO_TYPE, DOMAIN, RadioType, DATA_ZHA_CORE_EVENTS, ENABLE_QUIRKS) from .core.gateway import establish_device_mappings -from .core.listeners import populate_listener_registry +from .core.channels.registry import populate_channel_registry REQUIREMENTS = [ 'bellows==0.7.0', @@ -90,7 +90,7 @@ async def async_setup_entry(hass, config_entry): Will automatically load components to support devices found on the network. """ establish_device_mappings() - populate_listener_registry() + populate_channel_registry() for component in COMPONENTS: hass.data[DATA_ZHA][component] = ( diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index 1f85373eecc..a46ffdd305d 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -9,9 +9,9 @@ import logging from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice from homeassistant.helpers.dispatcher import async_dispatcher_connect from .core.const import ( - DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, LISTENER_ON_OFF, - LISTENER_LEVEL, LISTENER_ZONE, SIGNAL_ATTR_UPDATED, SIGNAL_MOVE_LEVEL, - SIGNAL_SET_LEVEL, LISTENER_ATTRIBUTE, UNKNOWN, OPENING, ZONE, OCCUPANCY, + 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) from .entity import ZhaEntity @@ -30,9 +30,9 @@ CLASS_MAPPING = { } -async def get_ias_device_class(listener): - """Get the HA device class from the listener.""" - zone_type = await listener.get_attribute_value('zone_type') +async def get_ias_device_class(channel): + """Get the HA device class from the channel.""" + zone_type = await channel.get_attribute_value('zone_type') return CLASS_MAPPING.get(zone_type) @@ -87,10 +87,10 @@ class BinarySensor(ZhaEntity, BinarySensorDevice): """Initialize the ZHA binary sensor.""" super().__init__(**kwargs) self._device_state_attributes = {} - self._zone_listener = self.cluster_listeners.get(LISTENER_ZONE) - self._on_off_listener = self.cluster_listeners.get(LISTENER_ON_OFF) - self._level_listener = self.cluster_listeners.get(LISTENER_LEVEL) - self._attr_listener = self.cluster_listeners.get(LISTENER_ATTRIBUTE) + self._zone_channel = self.cluster_channels.get(ZONE_CHANNEL) + self._on_off_channel = self.cluster_channels.get(ON_OFF_CHANNEL) + self._level_channel = self.cluster_channels.get(LEVEL_CHANNEL) + self._attr_channel = self.cluster_channels.get(ATTRIBUTE_CHANNEL) self._zha_sensor_type = kwargs[SENSOR_TYPE] self._level = None @@ -99,31 +99,31 @@ class BinarySensor(ZhaEntity, BinarySensorDevice): device_class_supplier = DEVICE_CLASS_REGISTRY.get( self._zha_sensor_type) if callable(device_class_supplier): - listener = self.cluster_listeners.get(self._zha_sensor_type) - if listener is None: + channel = self.cluster_channels.get(self._zha_sensor_type) + if channel is None: return None - return await device_class_supplier(listener) + return await device_class_supplier(channel) return device_class_supplier async def async_added_to_hass(self): """Run when about to be added to hass.""" self._device_class = await self._determine_device_class() await super().async_added_to_hass() - if self._level_listener: + if self._level_channel: await self.async_accept_signal( - self._level_listener, SIGNAL_SET_LEVEL, self.set_level) + self._level_channel, SIGNAL_SET_LEVEL, self.set_level) await self.async_accept_signal( - self._level_listener, SIGNAL_MOVE_LEVEL, self.move_level) - if self._on_off_listener: + self._level_channel, SIGNAL_MOVE_LEVEL, self.move_level) + if self._on_off_channel: await self.async_accept_signal( - self._on_off_listener, SIGNAL_ATTR_UPDATED, + self._on_off_channel, SIGNAL_ATTR_UPDATED, self.async_set_state) - if self._zone_listener: + if self._zone_channel: await self.async_accept_signal( - self._zone_listener, SIGNAL_ATTR_UPDATED, self.async_set_state) - if self._attr_listener: + self._zone_channel, SIGNAL_ATTR_UPDATED, self.async_set_state) + if self._attr_channel: await self.async_accept_signal( - self._attr_listener, SIGNAL_ATTR_UPDATED, self.async_set_state) + self._attr_channel, SIGNAL_ATTR_UPDATED, self.async_set_state) @property def is_on(self) -> bool: @@ -160,7 +160,7 @@ class BinarySensor(ZhaEntity, BinarySensorDevice): @property def device_state_attributes(self): """Return the device state attributes.""" - if self._level_listener is not None: + if self._level_channel is not None: self._device_state_attributes.update({ ATTR_LEVEL: self._state and self._level or 0 }) diff --git a/homeassistant/components/zha/core/__init__.py b/homeassistant/components/zha/core/__init__.py index e7443e7e0b7..145b725fc79 100644 --- a/homeassistant/components/zha/core/__init__.py +++ b/homeassistant/components/zha/core/__init__.py @@ -8,6 +8,3 @@ https://home-assistant.io/components/zha/ # flake8: noqa from .device import ZHADevice from .gateway import ZHAGateway -from .listeners import ( - ClusterListener, AttributeListener, OnOffListener, LevelListener, - IASZoneListener, ActivePowerListener, BatteryListener, EventRelayListener) diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py new file mode 100644 index 00000000000..0c0e1ed2173 --- /dev/null +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -0,0 +1,308 @@ +""" +Channels module for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" +import asyncio +from enum import Enum +from functools import wraps +import logging +from random import uniform + +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_send +from ..helpers import ( + bind_configure_reporting, construct_unique_id, + safe_read, get_attr_id_by_name) +from ..const import ( + CLUSTER_REPORT_CONFIGS, REPORT_CONFIG_DEFAULT, SIGNAL_ATTR_UPDATED, + ATTRIBUTE_CHANNEL, EVENT_RELAY_CHANNEL +) + +ZIGBEE_CHANNEL_REGISTRY = {} +_LOGGER = logging.getLogger(__name__) + + +def parse_and_log_command(unique_id, cluster, tsn, command_id, args): + """Parse and log a zigbee cluster command.""" + cmd = cluster.server_commands.get(command_id, [command_id])[0] + _LOGGER.debug( + "%s: received '%s' command with %s args on cluster_id '%s' tsn '%s'", + unique_id, + cmd, + args, + cluster.cluster_id, + tsn + ) + return cmd + + +def decorate_command(channel, command): + """Wrap a cluster command to make it safe.""" + @wraps(command) + async def wrapper(*args, **kwds): + from zigpy.zcl.foundation import Status + from zigpy.exceptions import DeliveryError + try: + result = await command(*args, **kwds) + _LOGGER.debug("%s: executed command: %s %s %s %s", + channel.unique_id, + command.__name__, + "{}: {}".format("with args", args), + "{}: {}".format("with kwargs", kwds), + "{}: {}".format("and result", result)) + if isinstance(result, bool): + return result + return result[1] is Status.SUCCESS + except DeliveryError: + _LOGGER.debug("%s: command failed: %s", channel.unique_id, + command.__name__) + return False + return wrapper + + +class ChannelStatus(Enum): + """Status of a channel.""" + + CREATED = 1 + CONFIGURED = 2 + INITIALIZED = 3 + + +class ZigbeeChannel: + """Base channel for a Zigbee cluster.""" + + def __init__(self, cluster, device): + """Initialize ZigbeeChannel.""" + self.name = 'channel_{}'.format(cluster.cluster_id) + self._cluster = cluster + self._zha_device = device + self._unique_id = construct_unique_id(cluster) + self._report_config = CLUSTER_REPORT_CONFIGS.get( + self._cluster.cluster_id, + [{'attr': 0, 'config': REPORT_CONFIG_DEFAULT}] + ) + self._status = ChannelStatus.CREATED + self._cluster.add_listener(self) + + @property + def unique_id(self): + """Return the unique id for this channel.""" + return self._unique_id + + @property + def cluster(self): + """Return the zigpy cluster for this channel.""" + return self._cluster + + @property + def device(self): + """Return the device this channel is linked to.""" + return self._zha_device + + @property + def status(self): + """Return the status of the channel.""" + return self._status + + def set_report_config(self, report_config): + """Set the reporting configuration.""" + self._report_config = report_config + + async def async_configure(self): + """Set cluster binding and attribute reporting.""" + manufacturer = None + manufacturer_code = self._zha_device.manufacturer_code + if self.cluster.cluster_id >= 0xfc00 and manufacturer_code: + manufacturer = manufacturer_code + + skip_bind = False # bind cluster only for the 1st configured attr + for report_config in self._report_config: + attr = report_config.get('attr') + min_report_interval, max_report_interval, change = \ + report_config.get('config') + await bind_configure_reporting( + self._unique_id, self.cluster, attr, + min_report=min_report_interval, + max_report=max_report_interval, + reportable_change=change, + skip_bind=skip_bind, + manufacturer=manufacturer + ) + skip_bind = True + await asyncio.sleep(uniform(0.1, 0.5)) + _LOGGER.debug( + "%s: finished channel configuration", + self._unique_id + ) + self._status = ChannelStatus.CONFIGURED + + async def async_initialize(self, from_cache): + """Initialize channel.""" + self._status = ChannelStatus.INITIALIZED + + @callback + def cluster_command(self, tsn, command_id, args): + """Handle commands received to this cluster.""" + pass + + @callback + def attribute_updated(self, attrid, value): + """Handle attribute updates on this cluster.""" + pass + + @callback + def zdo_command(self, *args, **kwargs): + """Handle ZDO commands on this cluster.""" + pass + + @callback + def zha_send_event(self, cluster, command, args): + """Relay events to hass.""" + self._zha_device.hass.bus.async_fire( + 'zha_event', + { + 'unique_id': self._unique_id, + 'device_ieee': str(self._zha_device.ieee), + 'command': command, + 'args': args + } + ) + + async def async_update(self): + """Retrieve latest state from cluster.""" + pass + + async def get_attribute_value(self, attribute, from_cache=True): + """Get the value for an attribute.""" + result = await safe_read( + self._cluster, + [attribute], + allow_cache=from_cache, + only_cache=from_cache + ) + return result.get(attribute) + + def __getattr__(self, name): + """Get attribute or a decorated cluster command.""" + if hasattr(self._cluster, name) and callable( + getattr(self._cluster, name)): + command = getattr(self._cluster, name) + command.__name__ = name + return decorate_command( + self, + command + ) + return self.__getattribute__(name) + + +class AttributeListeningChannel(ZigbeeChannel): + """Channel for attribute reports from the cluster.""" + + def __init__(self, cluster, device): + """Initialize AttributeListeningChannel.""" + super().__init__(cluster, device) + 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) + else: + self._value_attribute = attr + + @callback + def attribute_updated(self, attrid, value): + """Handle attribute updates on this cluster.""" + if attrid == self._value_attribute: + async_dispatcher_send( + self._zha_device.hass, + "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), + value + ) + + async def async_initialize(self, from_cache): + """Initialize listener.""" + await self.get_attribute_value( + self._report_config[0].get('attr'), from_cache=from_cache) + await super().async_initialize(from_cache) + + +class ZDOChannel: + """Channel for ZDO events.""" + + def __init__(self, cluster, device): + """Initialize ZDOChannel.""" + self.name = 'zdo' + self._cluster = cluster + self._zha_device = device + self._status = ChannelStatus.CREATED + self._unique_id = "{}_ZDO".format(device.name) + self._cluster.add_listener(self) + + @property + def unique_id(self): + """Return the unique id for this channel.""" + return self._unique_id + + @property + def cluster(self): + """Return the aigpy cluster for this channel.""" + return self._cluster + + @property + def status(self): + """Return the status of the channel.""" + return self._status + + @callback + def device_announce(self, zigpy_device): + """Device announce handler.""" + pass + + @callback + def permit_duration(self, duration): + """Permit handler.""" + pass + + async def async_initialize(self, from_cache): + """Initialize channel.""" + self._status = ChannelStatus.INITIALIZED + + async def async_configure(self): + """Configure channel.""" + self._status = ChannelStatus.CONFIGURED + + +class EventRelayChannel(ZigbeeChannel): + """Event relay that can be attached to zigbee clusters.""" + + def __init__(self, cluster, device): + """Initialize EventRelayChannel.""" + super().__init__(cluster, device) + self.name = EVENT_RELAY_CHANNEL + + @callback + def attribute_updated(self, attrid, value): + """Handle an attribute updated on this cluster.""" + self.zha_send_event( + self._cluster, + SIGNAL_ATTR_UPDATED, + { + 'attribute_id': attrid, + 'attribute_name': self._cluster.attributes.get( + attrid, + ['Unknown'])[0], + 'value': value + } + ) + + @callback + def cluster_command(self, tsn, command_id, args): + """Handle a cluster command received on this cluster.""" + if self._cluster.server_commands is not None and \ + self._cluster.server_commands.get(command_id) is not None: + self.zha_send_event( + self._cluster, + self._cluster.server_commands.get(command_id)[0], + args + ) diff --git a/homeassistant/components/zha/core/channels/closures.py b/homeassistant/components/zha/core/channels/closures.py new file mode 100644 index 00000000000..ba3b6b2e716 --- /dev/null +++ b/homeassistant/components/zha/core/channels/closures.py @@ -0,0 +1,9 @@ +""" +Closures channels module for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" +import logging + +_LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py new file mode 100644 index 00000000000..bc015ae47f0 --- /dev/null +++ b/homeassistant/components/zha/core/channels/general.py @@ -0,0 +1,202 @@ +""" +General channels module for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" +import logging +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_send +from . import ZigbeeChannel, parse_and_log_command +from ..helpers import get_attr_id_by_name +from ..const import ( + SIGNAL_ATTR_UPDATED, + SIGNAL_MOVE_LEVEL, SIGNAL_SET_LEVEL, SIGNAL_STATE_ATTR, BASIC_CHANNEL, + ON_OFF_CHANNEL, LEVEL_CHANNEL, POWER_CONFIGURATION_CHANNEL +) + +_LOGGER = logging.getLogger(__name__) + + +class OnOffChannel(ZigbeeChannel): + """Channel for the OnOff Zigbee cluster.""" + + ON_OFF = 0 + + def __init__(self, cluster, device): + """Initialize OnOffChannel.""" + super().__init__(cluster, device) + self.name = ON_OFF_CHANNEL + self._state = None + + @callback + def cluster_command(self, tsn, command_id, args): + """Handle commands received to this cluster.""" + cmd = parse_and_log_command( + self.unique_id, + self._cluster, + tsn, + command_id, + args + ) + + if cmd in ('off', 'off_with_effect'): + self.attribute_updated(self.ON_OFF, False) + elif cmd in ('on', 'on_with_recall_global_scene', 'on_with_timed_off'): + self.attribute_updated(self.ON_OFF, True) + elif cmd == 'toggle': + self.attribute_updated(self.ON_OFF, not bool(self._state)) + + @callback + def attribute_updated(self, attrid, value): + """Handle attribute updates on this cluster.""" + if attrid == self.ON_OFF: + async_dispatcher_send( + self._zha_device.hass, + "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), + value + ) + self._state = bool(value) + + async def async_initialize(self, from_cache): + """Initialize channel.""" + self._state = bool( + await self.get_attribute_value(self.ON_OFF, from_cache=from_cache)) + await super().async_initialize(from_cache) + + +class LevelControlChannel(ZigbeeChannel): + """Channel for the LevelControl Zigbee cluster.""" + + CURRENT_LEVEL = 0 + + def __init__(self, cluster, device): + """Initialize LevelControlChannel.""" + super().__init__(cluster, device) + self.name = LEVEL_CHANNEL + + @callback + def cluster_command(self, tsn, command_id, args): + """Handle commands received to this cluster.""" + cmd = parse_and_log_command( + self.unique_id, + self._cluster, + tsn, + command_id, + args + ) + + if cmd in ('move_to_level', 'move_to_level_with_on_off'): + self.dispatch_level_change(SIGNAL_SET_LEVEL, args[0]) + elif cmd in ('move', 'move_with_on_off'): + # We should dim slowly -- for now, just step once + rate = args[1] + if args[0] == 0xff: + rate = 10 # Should read default move rate + self.dispatch_level_change( + SIGNAL_MOVE_LEVEL, -rate if args[0] else rate) + elif cmd in ('step', 'step_with_on_off'): + # Step (technically may change on/off) + self.dispatch_level_change( + SIGNAL_MOVE_LEVEL, -args[1] if args[0] else args[1]) + + @callback + def attribute_updated(self, attrid, value): + """Handle attribute updates on this cluster.""" + _LOGGER.debug("%s: received attribute: %s update with value: %i", + self.unique_id, attrid, value) + if attrid == self.CURRENT_LEVEL: + self.dispatch_level_change(SIGNAL_SET_LEVEL, value) + + def dispatch_level_change(self, command, level): + """Dispatch level change.""" + async_dispatcher_send( + self._zha_device.hass, + "{}_{}".format(self.unique_id, command), + level + ) + + async def async_initialize(self, from_cache): + """Initialize channel.""" + await self.get_attribute_value( + self.CURRENT_LEVEL, from_cache=from_cache) + await super().async_initialize(from_cache) + + +class BasicChannel(ZigbeeChannel): + """Channel to interact with the basic cluster.""" + + BATTERY = 3 + POWER_SOURCES = { + 0: 'Unknown', + 1: 'Mains (single phase)', + 2: 'Mains (3 phase)', + BATTERY: 'Battery', + 4: 'DC source', + 5: 'Emergency mains constantly powered', + 6: 'Emergency mains and transfer switch' + } + + def __init__(self, cluster, device): + """Initialize BasicChannel.""" + super().__init__(cluster, device) + self.name = BASIC_CHANNEL + self._power_source = None + + async def async_configure(self): + """Configure this channel.""" + await super().async_configure() + await self.async_initialize(False) + + async def async_initialize(self, from_cache): + """Initialize channel.""" + self._power_source = await self.get_attribute_value( + 'power_source', from_cache=from_cache) + await super().async_initialize(from_cache) + + def get_power_source(self): + """Get the power source.""" + return self._power_source + + +class PowerConfigurationChannel(ZigbeeChannel): + """Channel for the zigbee power configuration cluster.""" + + def __init__(self, cluster, device): + """Initialize PowerConfigurationChannel.""" + super().__init__(cluster, device) + self.name = POWER_CONFIGURATION_CHANNEL + + @callback + def attribute_updated(self, attrid, value): + """Handle attribute updates on this cluster.""" + attr = self._report_config[1].get('attr') + if isinstance(attr, str): + attr_id = get_attr_id_by_name(self.cluster, attr) + else: + attr_id = attr + if attrid == attr_id: + async_dispatcher_send( + self._zha_device.hass, + "{}_{}".format(self.unique_id, SIGNAL_STATE_ATTR), + 'battery_level', + value + ) + + async def async_initialize(self, from_cache): + """Initialize channel.""" + await self.async_read_state(from_cache) + await super().async_initialize(from_cache) + + async def async_update(self): + """Retrieve latest state.""" + await self.async_read_state(True) + + async def async_read_state(self, from_cache): + """Read data from the cluster.""" + await self.get_attribute_value( + 'battery_size', from_cache=from_cache) + await self.get_attribute_value( + 'battery_percentage_remaining', from_cache=from_cache) + await self.get_attribute_value( + 'active_power', from_cache=from_cache) diff --git a/homeassistant/components/zha/core/channels/homeautomation.py b/homeassistant/components/zha/core/channels/homeautomation.py new file mode 100644 index 00000000000..2518889fcb1 --- /dev/null +++ b/homeassistant/components/zha/core/channels/homeautomation.py @@ -0,0 +1,40 @@ +""" +Home automation channels module for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" +import logging +from homeassistant.helpers.dispatcher import async_dispatcher_send +from . import AttributeListeningChannel +from ..const import SIGNAL_ATTR_UPDATED, ELECTRICAL_MEASUREMENT_CHANNEL + +_LOGGER = logging.getLogger(__name__) + + +class ElectricalMeasurementChannel(AttributeListeningChannel): + """Channel that polls active power level.""" + + def __init__(self, cluster, device): + """Initialize ElectricalMeasurementChannel.""" + super().__init__(cluster, device) + self.name = ELECTRICAL_MEASUREMENT_CHANNEL + + async def async_update(self): + """Retrieve latest state.""" + _LOGGER.debug("%s async_update", self.unique_id) + + # This is a polling channel. Don't allow cache. + result = await self.get_attribute_value( + ELECTRICAL_MEASUREMENT_CHANNEL, from_cache=False) + async_dispatcher_send( + self._zha_device.hass, + "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), + result + ) + + async def async_initialize(self, from_cache): + """Initialize channel.""" + await self.get_attribute_value( + ELECTRICAL_MEASUREMENT_CHANNEL, from_cache=from_cache) + await super().async_initialize(from_cache) diff --git a/homeassistant/components/zha/core/channels/hvac.py b/homeassistant/components/zha/core/channels/hvac.py new file mode 100644 index 00000000000..c62ec66588e --- /dev/null +++ b/homeassistant/components/zha/core/channels/hvac.py @@ -0,0 +1,62 @@ +""" +HVAC channels module for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" +import logging +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_send +from . import ZigbeeChannel +from ..const import FAN_CHANNEL, SIGNAL_ATTR_UPDATED + +_LOGGER = logging.getLogger(__name__) + + +class FanChannel(ZigbeeChannel): + """Fan channel.""" + + _value_attribute = 0 + + def __init__(self, cluster, device): + """Initialize FanChannel.""" + super().__init__(cluster, device) + self.name = FAN_CHANNEL + + async def async_set_speed(self, value) -> None: + """Set the speed of the fan.""" + from zigpy.exceptions import DeliveryError + try: + await self.cluster.write_attributes({'fan_mode': value}) + except DeliveryError as ex: + _LOGGER.error("%s: Could not set speed: %s", self.unique_id, ex) + return + + async def async_update(self): + """Retrieve latest state.""" + result = await self.get_attribute_value('fan_mode', from_cache=True) + + async_dispatcher_send( + self._zha_device.hass, + "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), + result + ) + + @callback + def attribute_updated(self, attrid, value): + """Handle attribute update from fan cluster.""" + attr_name = self.cluster.attributes.get(attrid, [attrid])[0] + _LOGGER.debug("%s: Attribute report '%s'[%s] = %s", + self.unique_id, self.cluster.name, attr_name, value) + if attrid == self._value_attribute: + async_dispatcher_send( + self._zha_device.hass, + "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), + value + ) + + async def async_initialize(self, from_cache): + """Initialize channel.""" + await self.get_attribute_value( + self._value_attribute, from_cache=from_cache) + await super().async_initialize(from_cache) diff --git a/homeassistant/components/zha/core/channels/lighting.py b/homeassistant/components/zha/core/channels/lighting.py new file mode 100644 index 00000000000..ee88a30e828 --- /dev/null +++ b/homeassistant/components/zha/core/channels/lighting.py @@ -0,0 +1,48 @@ +""" +Lighting channels module for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" +import logging +from . import ZigbeeChannel +from ..const import COLOR_CHANNEL + +_LOGGER = logging.getLogger(__name__) + + +class ColorChannel(ZigbeeChannel): + """Color channel.""" + + CAPABILITIES_COLOR_XY = 0x08 + CAPABILITIES_COLOR_TEMP = 0x10 + UNSUPPORTED_ATTRIBUTE = 0x86 + + def __init__(self, cluster, device): + """Initialize ColorChannel.""" + super().__init__(cluster, device) + self.name = COLOR_CHANNEL + self._color_capabilities = None + + def get_color_capabilities(self): + """Return the color capabilities.""" + return self._color_capabilities + + async def async_initialize(self, from_cache): + """Initialize channel.""" + capabilities = await self.get_attribute_value( + 'color_capabilities', from_cache=from_cache) + + if capabilities is None: + # ZCL Version 4 devices don't support the color_capabilities + # attribute. In this version XY support is mandatory, but we + # need to probe to determine if the device supports color + # temperature. + capabilities = self.CAPABILITIES_COLOR_XY + result = await self.get_attribute_value( + 'color_temperature', from_cache=from_cache) + + if result is not self.UNSUPPORTED_ATTRIBUTE: + capabilities |= self.CAPABILITIES_COLOR_TEMP + self._color_capabilities = capabilities + await super().async_initialize(from_cache) diff --git a/homeassistant/components/zha/core/channels/lightlink.py b/homeassistant/components/zha/core/channels/lightlink.py new file mode 100644 index 00000000000..83fca6e80c2 --- /dev/null +++ b/homeassistant/components/zha/core/channels/lightlink.py @@ -0,0 +1,9 @@ +""" +Lightlink channels module for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" +import logging + +_LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zha/core/channels/manufacturerspecific.py b/homeassistant/components/zha/core/channels/manufacturerspecific.py new file mode 100644 index 00000000000..a0eebd78343 --- /dev/null +++ b/homeassistant/components/zha/core/channels/manufacturerspecific.py @@ -0,0 +1,9 @@ +""" +Manufacturer specific channels module for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" +import logging + +_LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zha/core/channels/measurement.py b/homeassistant/components/zha/core/channels/measurement.py new file mode 100644 index 00000000000..51146289e69 --- /dev/null +++ b/homeassistant/components/zha/core/channels/measurement.py @@ -0,0 +1,9 @@ +""" +Measurement channels module for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" +import logging + +_LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zha/core/channels/protocol.py b/homeassistant/components/zha/core/channels/protocol.py new file mode 100644 index 00000000000..2cae156aec5 --- /dev/null +++ b/homeassistant/components/zha/core/channels/protocol.py @@ -0,0 +1,9 @@ +""" +Protocol channels module for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" +import logging + +_LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zha/core/channels/registry.py b/homeassistant/components/zha/core/channels/registry.py new file mode 100644 index 00000000000..f0363ac8330 --- /dev/null +++ b/homeassistant/components/zha/core/channels/registry.py @@ -0,0 +1,46 @@ +""" +Channel registry module for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" +from . import ZigbeeChannel +from .general import ( + OnOffChannel, LevelControlChannel, PowerConfigurationChannel, BasicChannel +) +from .homeautomation import ElectricalMeasurementChannel +from .hvac import FanChannel +from .lighting import ColorChannel +from .security import IASZoneChannel + + +ZIGBEE_CHANNEL_REGISTRY = {} + + +def populate_channel_registry(): + """Populate the channel registry.""" + from zigpy import zcl + ZIGBEE_CHANNEL_REGISTRY.update({ + zcl.clusters.general.Alarms.cluster_id: ZigbeeChannel, + zcl.clusters.general.Commissioning.cluster_id: ZigbeeChannel, + zcl.clusters.general.Identify.cluster_id: ZigbeeChannel, + zcl.clusters.general.Groups.cluster_id: ZigbeeChannel, + zcl.clusters.general.Scenes.cluster_id: ZigbeeChannel, + zcl.clusters.general.Partition.cluster_id: ZigbeeChannel, + zcl.clusters.general.Ota.cluster_id: ZigbeeChannel, + zcl.clusters.general.PowerProfile.cluster_id: ZigbeeChannel, + zcl.clusters.general.ApplianceControl.cluster_id: ZigbeeChannel, + zcl.clusters.general.PollControl.cluster_id: ZigbeeChannel, + zcl.clusters.general.GreenPowerProxy.cluster_id: ZigbeeChannel, + zcl.clusters.general.OnOffConfiguration.cluster_id: ZigbeeChannel, + zcl.clusters.general.OnOff.cluster_id: OnOffChannel, + zcl.clusters.general.LevelControl.cluster_id: LevelControlChannel, + zcl.clusters.lighting.Color.cluster_id: ColorChannel, + zcl.clusters.homeautomation.ElectricalMeasurement.cluster_id: + ElectricalMeasurementChannel, + zcl.clusters.general.PowerConfiguration.cluster_id: + PowerConfigurationChannel, + zcl.clusters.general.Basic.cluster_id: BasicChannel, + zcl.clusters.security.IasZone.cluster_id: IASZoneChannel, + zcl.clusters.hvac.Fan.cluster_id: FanChannel, + }) diff --git a/homeassistant/components/zha/core/channels/security.py b/homeassistant/components/zha/core/channels/security.py new file mode 100644 index 00000000000..e8c0e71a263 --- /dev/null +++ b/homeassistant/components/zha/core/channels/security.py @@ -0,0 +1,82 @@ +""" +Security channels module for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" +import logging +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_send +from . import ZigbeeChannel +from ..helpers import bind_cluster +from ..const import SIGNAL_ATTR_UPDATED, ZONE_CHANNEL + +_LOGGER = logging.getLogger(__name__) + + +class IASZoneChannel(ZigbeeChannel): + """Channel for the IASZone Zigbee cluster.""" + + def __init__(self, cluster, device): + """Initialize IASZoneChannel.""" + super().__init__(cluster, device) + self.name = ZONE_CHANNEL + + @callback + def cluster_command(self, tsn, command_id, args): + """Handle commands received to this cluster.""" + if command_id == 0: + state = args[0] & 3 + async_dispatcher_send( + self._zha_device.hass, + "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), + state + ) + _LOGGER.debug("Updated alarm state: %s", state) + elif command_id == 1: + _LOGGER.debug("Enroll requested") + res = self._cluster.enroll_response(0, 0) + self._zha_device.hass.async_create_task(res) + + async def async_configure(self): + """Configure IAS device.""" + from zigpy.exceptions import DeliveryError + _LOGGER.debug("%s: started IASZoneChannel configuration", + self._unique_id) + + await bind_cluster(self.unique_id, self._cluster) + ieee = self._cluster.endpoint.device.application.ieee + + try: + res = await self._cluster.write_attributes({'cie_addr': ieee}) + _LOGGER.debug( + "%s: wrote cie_addr: %s to '%s' cluster: %s", + self.unique_id, str(ieee), self._cluster.ep_attribute, + res[0] + ) + except DeliveryError as ex: + _LOGGER.debug( + "%s: Failed to write cie_addr: %s to '%s' cluster: %s", + self.unique_id, str(ieee), self._cluster.ep_attribute, str(ex) + ) + _LOGGER.debug("%s: finished IASZoneChannel configuration", + self._unique_id) + + await self.get_attribute_value('zone_type', from_cache=False) + + @callback + def attribute_updated(self, attrid, value): + """Handle attribute updates on this cluster.""" + if attrid == 2: + value = value & 3 + async_dispatcher_send( + self._zha_device.hass, + "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), + value + ) + + async def async_initialize(self, from_cache): + """Initialize channel.""" + await self.get_attribute_value('zone_status', from_cache=from_cache) + await self.get_attribute_value('zone_state', from_cache=from_cache) + await super().async_initialize(from_cache) diff --git a/homeassistant/components/zha/core/channels/smartenergy.py b/homeassistant/components/zha/core/channels/smartenergy.py new file mode 100644 index 00000000000..d17eae30a96 --- /dev/null +++ b/homeassistant/components/zha/core/channels/smartenergy.py @@ -0,0 +1,9 @@ +""" +Smart energy channels module for Zigbee Home Automation. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/zha/ +""" +import logging + +_LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/zha/core/const.py b/homeassistant/components/zha/core/const.py index faa423d8ac4..d1001682c7b 100644 --- a/homeassistant/components/zha/core/const.py +++ b/homeassistant/components/zha/core/const.py @@ -70,16 +70,16 @@ OCCUPANCY = 'occupancy' ATTR_LEVEL = 'level' -LISTENER_ON_OFF = 'on_off' -LISTENER_ATTRIBUTE = 'attribute' -LISTENER_BASIC = 'basic' -LISTENER_COLOR = 'color' -LISTENER_FAN = 'fan' -LISTENER_LEVEL = ATTR_LEVEL -LISTENER_ZONE = 'zone' -LISTENER_ACTIVE_POWER = 'active_power' -LISTENER_BATTERY = 'battery' -LISTENER_EVENT_RELAY = 'event_relay' +ON_OFF_CHANNEL = 'on_off' +ATTRIBUTE_CHANNEL = 'attribute' +BASIC_CHANNEL = 'basic' +COLOR_CHANNEL = 'color' +FAN_CHANNEL = 'fan' +LEVEL_CHANNEL = ATTR_LEVEL +ZONE_CHANNEL = 'zone' +ELECTRICAL_MEASUREMENT_CHANNEL = 'active_power' +POWER_CONFIGURATION_CHANNEL = 'battery' +EVENT_RELAY_CHANNEL = 'event_relay' SIGNAL_ATTR_UPDATED = 'attribute_updated' SIGNAL_MOVE_LEVEL = "move_level" diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 7bb39f943f6..3a012ed7895 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.py @@ -11,13 +11,14 @@ from homeassistant.helpers.dispatcher import ( async_dispatcher_connect, async_dispatcher_send ) from .const import ( - ATTR_MANUFACTURER, LISTENER_BATTERY, SIGNAL_AVAILABLE, IN, OUT, + ATTR_MANUFACTURER, POWER_CONFIGURATION_CHANNEL, SIGNAL_AVAILABLE, IN, OUT, 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, LISTENER_BASIC + QUIRK_CLASS, BASIC_CHANNEL ) -from .listeners import EventRelayListener, BasicListener +from .channels import EventRelayChannel +from .channels.general import BasicChannel _LOGGER = logging.getLogger(__name__) @@ -38,9 +39,9 @@ class ZHADevice: self._manufacturer = zigpy_device.endpoints[ept_id].manufacturer self._model = zigpy_device.endpoints[ept_id].model self._zha_gateway = zha_gateway - self.cluster_listeners = {} - self._relay_listeners = [] - self._all_listeners = [] + self.cluster_channels = {} + self._relay_channels = [] + self._all_channels = [] self._name = "{} {}".format( self.manufacturer, self.model @@ -113,9 +114,9 @@ class ZHADevice: return self._zha_gateway @property - def all_listeners(self): - """Return cluster listeners and relay listeners for device.""" - return self._all_listeners + def all_channels(self): + """Return cluster channels and relay channels for device.""" + return self._all_channels @property def available_signal(self): @@ -156,59 +157,59 @@ class ZHADevice: QUIRK_CLASS: self.quirk_class } - def add_cluster_listener(self, cluster_listener): - """Add cluster listener to device.""" - # only keep 1 power listener - if cluster_listener.name is LISTENER_BATTERY and \ - LISTENER_BATTERY in self.cluster_listeners: + def add_cluster_channel(self, cluster_channel): + """Add cluster channel to device.""" + # only keep 1 power configuration channel + if cluster_channel.name is POWER_CONFIGURATION_CHANNEL and \ + POWER_CONFIGURATION_CHANNEL in self.cluster_channels: return - self._all_listeners.append(cluster_listener) - if isinstance(cluster_listener, EventRelayListener): - self._relay_listeners.append(cluster_listener) + self._all_channels.append(cluster_channel) + if isinstance(cluster_channel, EventRelayChannel): + self._relay_channels.append(cluster_channel) else: - self.cluster_listeners[cluster_listener.name] = cluster_listener + self.cluster_channels[cluster_channel.name] = cluster_channel async def async_configure(self): """Configure the device.""" _LOGGER.debug('%s: started configuration', self.name) - await self._execute_listener_tasks('async_configure') + await self._execute_channel_tasks('async_configure') _LOGGER.debug('%s: completed configuration', self.name) async def async_initialize(self, from_cache=False): - """Initialize listeners.""" + """Initialize channels.""" _LOGGER.debug('%s: started initialization', self.name) - await self._execute_listener_tasks('async_initialize', from_cache) - self.power_source = self.cluster_listeners.get( - LISTENER_BASIC).get_power_source() + await self._execute_channel_tasks('async_initialize', from_cache) + self.power_source = self.cluster_channels.get( + BASIC_CHANNEL).get_power_source() _LOGGER.debug( '%s: power source: %s', self.name, - BasicListener.POWER_SOURCES.get(self.power_source) + BasicChannel.POWER_SOURCES.get(self.power_source) ) _LOGGER.debug('%s: completed initialization', self.name) - async def _execute_listener_tasks(self, task_name, *args): - """Gather and execute a set of listener tasks.""" - listener_tasks = [] - for listener in self.all_listeners: - listener_tasks.append( - self._async_create_task(listener, task_name, *args)) - await asyncio.gather(*listener_tasks) + async def _execute_channel_tasks(self, task_name, *args): + """Gather and execute a set of CHANNEL tasks.""" + channel_tasks = [] + for channel in self.all_channels: + channel_tasks.append( + self._async_create_task(channel, task_name, *args)) + await asyncio.gather(*channel_tasks) - async def _async_create_task(self, listener, func_name, *args): - """Configure a single listener on this device.""" + async def _async_create_task(self, channel, func_name, *args): + """Configure a single channel on this device.""" try: - await getattr(listener, func_name)(*args) - _LOGGER.debug('%s: listener: %s %s stage succeeded', + await getattr(channel, func_name)(*args) + _LOGGER.debug('%s: channel: %s %s stage succeeded', self.name, "{}-{}".format( - listener.name, listener.unique_id), + channel.name, channel.unique_id), func_name) except Exception as ex: # pylint: disable=broad-except _LOGGER.warning( - '%s listener: %s %s stage failed ex: %s', + '%s channel: %s %s stage failed ex: %s', self.name, - "{}-{}".format(listener.name, listener.unique_id), + "{}-{}".format(channel.name, channel.unique_id), func_name, ex ) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 391b12189cf..4fbf96a22b6 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -18,15 +18,18 @@ 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, LISTENER_BATTERY, UNKNOWN, + 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) + REPORT_CONFIG_MAX_INT, REPORT_CONFIG_OP, SIGNAL_REMOVE, NO_SENSOR_CLUSTERS, + POWER_CONFIGURATION_CHANNEL) from .device import ZHADevice from ..device_entity import ZhaDeviceEntity -from .listeners import ( - LISTENER_REGISTRY, AttributeListener, EventRelayListener, ZDOListener, - BasicListener) +from .channels import ( + AttributeListeningChannel, EventRelayChannel, ZDOChannel +) +from .channels.general import BasicChannel +from .channels.registry import ZIGBEE_CHANNEL_REGISTRY from .helpers import convert_ieee _LOGGER = logging.getLogger(__name__) @@ -34,7 +37,7 @@ _LOGGER = logging.getLogger(__name__) SENSOR_TYPES = {} BINARY_SENSOR_TYPES = {} EntityReference = collections.namedtuple( - 'EntityReference', 'reference_id zha_device cluster_listeners device_info') + 'EntityReference', 'reference_id zha_device cluster_channels device_info') class ZHAGateway: @@ -106,14 +109,14 @@ class ZHAGateway: return self._device_registry def register_entity_reference( - self, ieee, reference_id, zha_device, cluster_listeners, + self, ieee, reference_id, zha_device, cluster_channels, device_info): """Record the creation of a hass entity associated with ieee.""" self._device_registry[ieee].append( EntityReference( reference_id=reference_id, zha_device=zha_device, - cluster_listeners=cluster_listeners, + cluster_channels=cluster_channels, device_info=device_info ) ) @@ -169,14 +172,14 @@ class ZHAGateway: # available and we already loaded fresh state above zha_device.update_available(True) elif not zha_device.available and zha_device.power_source is not None\ - and zha_device.power_source != BasicListener.BATTERY: + and zha_device.power_source != BasicChannel.BATTERY: # 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( - BasicListener.POWER_SOURCES.get(zha_device.power_source) + BasicChannel.POWER_SOURCES.get(zha_device.power_source) ) ) await zha_device.async_initialize(from_cache=False) @@ -188,11 +191,11 @@ class ZHAGateway: import zigpy.profiles if endpoint_id == 0: # ZDO - await _create_cluster_listener( + await _create_cluster_channel( endpoint, zha_device, is_new_join, - listener_class=ZDOListener + channel_class=ZDOChannel ) return @@ -234,18 +237,18 @@ class ZHAGateway: )) -async def _create_cluster_listener(cluster, zha_device, is_new_join, - listeners=None, listener_class=None): - """Create a cluster listener and attach it to a device.""" - if listener_class is None: - listener_class = LISTENER_REGISTRY.get(cluster.cluster_id, - AttributeListener) - listener = listener_class(cluster, zha_device) +async def _create_cluster_channel(cluster, zha_device, is_new_join, + channels=None, channel_class=None): + """Create a cluster channel and attach it to a device.""" + if channel_class is None: + channel_class = ZIGBEE_CHANNEL_REGISTRY.get(cluster.cluster_id, + AttributeListeningChannel) + channel = channel_class(cluster, zha_device) if is_new_join: - await listener.async_configure() - zha_device.add_cluster_listener(listener) - if listeners is not None: - listeners.append(listener) + await channel.async_configure() + zha_device.add_cluster_channel(channel) + if channels is not None: + channels.append(channel) async def _dispatch_discovery_info(hass, is_new_join, discovery_info): @@ -272,23 +275,23 @@ async def _handle_profile_match(hass, endpoint, profile_clusters, zha_device, for c in profile_clusters[1] if c in endpoint.out_clusters] - listeners = [] + channels = [] cluster_tasks = [] for cluster in in_clusters: - cluster_tasks.append(_create_cluster_listener( - cluster, zha_device, is_new_join, listeners=listeners)) + cluster_tasks.append(_create_cluster_channel( + cluster, zha_device, is_new_join, channels=channels)) for cluster in out_clusters: - cluster_tasks.append(_create_cluster_listener( - cluster, zha_device, is_new_join, listeners=listeners)) + cluster_tasks.append(_create_cluster_channel( + cluster, zha_device, is_new_join, channels=channels)) await asyncio.gather(*cluster_tasks) discovery_info = { 'unique_id': device_key, 'zha_device': zha_device, - 'listeners': listeners, + 'channels': channels, 'component': component } @@ -314,7 +317,7 @@ async def _handle_single_cluster_matches(hass, endpoint, zha_device, """Dispatch single cluster matches to HA components.""" cluster_matches = [] cluster_match_tasks = [] - event_listener_tasks = [] + event_channel_tasks = [] for cluster in endpoint.in_clusters.values(): if cluster.cluster_id not in profile_clusters[0]: cluster_match_tasks.append(_handle_single_cluster_match( @@ -327,7 +330,7 @@ async def _handle_single_cluster_matches(hass, endpoint, zha_device, )) if cluster.cluster_id in NO_SENSOR_CLUSTERS: - cluster_match_tasks.append(_handle_listener_only_cluster_match( + cluster_match_tasks.append(_handle_channel_only_cluster_match( zha_device, cluster, is_new_join, @@ -345,13 +348,13 @@ async def _handle_single_cluster_matches(hass, endpoint, zha_device, )) if cluster.cluster_id in EVENT_RELAY_CLUSTERS: - event_listener_tasks.append(_create_cluster_listener( + event_channel_tasks.append(_create_cluster_channel( cluster, zha_device, is_new_join, - listener_class=EventRelayListener + channel_class=EventRelayChannel )) - await asyncio.gather(*event_listener_tasks) + await asyncio.gather(*event_channel_tasks) cluster_match_results = await asyncio.gather(*cluster_match_tasks) for cluster_match in cluster_match_results: if cluster_match is not None: @@ -359,10 +362,10 @@ async def _handle_single_cluster_matches(hass, endpoint, zha_device, return cluster_matches -async def _handle_listener_only_cluster_match( +async def _handle_channel_only_cluster_match( zha_device, cluster, is_new_join): - """Handle a listener only cluster match.""" - await _create_cluster_listener(cluster, zha_device, is_new_join) + """Handle a channel only cluster match.""" + await _create_cluster_channel(cluster, zha_device, is_new_join) async def _handle_single_cluster_match(hass, zha_device, cluster, device_key, @@ -376,15 +379,15 @@ async def _handle_single_cluster_match(hass, zha_device, cluster, device_key, if component is None or component not in COMPONENTS: return - listeners = [] - await _create_cluster_listener(cluster, zha_device, is_new_join, - listeners=listeners) + channels = [] + await _create_cluster_channel(cluster, zha_device, is_new_join, + channels=channels) cluster_key = "{}-{}".format(device_key, cluster.cluster_id) discovery_info = { 'unique_id': cluster_key, 'zha_device': zha_device, - 'listeners': listeners, + 'channels': channels, 'entity_suffix': '_{}'.format(cluster.cluster_id), 'component': component } @@ -403,11 +406,11 @@ async def _handle_single_cluster_match(hass, zha_device, cluster, device_key, def _create_device_entity(zha_device): """Create ZHADeviceEntity.""" - device_entity_listeners = [] - if LISTENER_BATTERY in zha_device.cluster_listeners: - listener = zha_device.cluster_listeners.get(LISTENER_BATTERY) - device_entity_listeners.append(listener) - return ZhaDeviceEntity(zha_device, device_entity_listeners) + device_entity_channels = [] + if POWER_CONFIGURATION_CHANNEL in zha_device.cluster_channels: + channel = zha_device.cluster_channels.get(POWER_CONFIGURATION_CHANNEL) + device_entity_channels.append(channel) + return ZhaDeviceEntity(zha_device, device_entity_channels) def establish_device_mappings(): diff --git a/homeassistant/components/zha/core/listeners.py b/homeassistant/components/zha/core/listeners.py deleted file mode 100644 index f8d24ce903c..00000000000 --- a/homeassistant/components/zha/core/listeners.py +++ /dev/null @@ -1,706 +0,0 @@ -""" -Cluster listeners for Zigbee Home Automation. - -For more details about this component, please refer to the documentation at -https://home-assistant.io/components/zha/ -""" - -import asyncio -from enum import Enum -from functools import wraps -import logging -from random import uniform - -from homeassistant.core import callback -from homeassistant.helpers.dispatcher import async_dispatcher_send -from .helpers import ( - bind_configure_reporting, construct_unique_id, - safe_read, get_attr_id_by_name, bind_cluster) -from .const import ( - CLUSTER_REPORT_CONFIGS, REPORT_CONFIG_DEFAULT, SIGNAL_ATTR_UPDATED, - SIGNAL_MOVE_LEVEL, SIGNAL_SET_LEVEL, SIGNAL_STATE_ATTR, LISTENER_BASIC, - LISTENER_ATTRIBUTE, LISTENER_ON_OFF, LISTENER_COLOR, LISTENER_FAN, - LISTENER_LEVEL, LISTENER_ZONE, LISTENER_ACTIVE_POWER, LISTENER_BATTERY, - LISTENER_EVENT_RELAY -) - -LISTENER_REGISTRY = {} - -_LOGGER = logging.getLogger(__name__) - - -def populate_listener_registry(): - """Populate the listener registry.""" - from zigpy import zcl - LISTENER_REGISTRY.update({ - zcl.clusters.general.Alarms.cluster_id: ClusterListener, - zcl.clusters.general.Commissioning.cluster_id: ClusterListener, - zcl.clusters.general.Identify.cluster_id: ClusterListener, - zcl.clusters.general.Groups.cluster_id: ClusterListener, - zcl.clusters.general.Scenes.cluster_id: ClusterListener, - zcl.clusters.general.Partition.cluster_id: ClusterListener, - zcl.clusters.general.Ota.cluster_id: ClusterListener, - zcl.clusters.general.PowerProfile.cluster_id: ClusterListener, - zcl.clusters.general.ApplianceControl.cluster_id: ClusterListener, - zcl.clusters.general.PollControl.cluster_id: ClusterListener, - zcl.clusters.general.GreenPowerProxy.cluster_id: ClusterListener, - zcl.clusters.general.OnOffConfiguration.cluster_id: ClusterListener, - zcl.clusters.general.OnOff.cluster_id: OnOffListener, - zcl.clusters.general.LevelControl.cluster_id: LevelListener, - zcl.clusters.lighting.Color.cluster_id: ColorListener, - zcl.clusters.homeautomation.ElectricalMeasurement.cluster_id: - ActivePowerListener, - zcl.clusters.general.PowerConfiguration.cluster_id: BatteryListener, - zcl.clusters.general.Basic.cluster_id: BasicListener, - zcl.clusters.security.IasZone.cluster_id: IASZoneListener, - zcl.clusters.hvac.Fan.cluster_id: FanListener, - }) - - -def parse_and_log_command(unique_id, cluster, tsn, command_id, args): - """Parse and log a zigbee cluster command.""" - cmd = cluster.server_commands.get(command_id, [command_id])[0] - _LOGGER.debug( - "%s: received '%s' command with %s args on cluster_id '%s' tsn '%s'", - unique_id, - cmd, - args, - cluster.cluster_id, - tsn - ) - return cmd - - -def decorate_command(listener, command): - """Wrap a cluster command to make it safe.""" - @wraps(command) - async def wrapper(*args, **kwds): - from zigpy.zcl.foundation import Status - from zigpy.exceptions import DeliveryError - try: - result = await command(*args, **kwds) - _LOGGER.debug("%s: executed command: %s %s %s %s", - listener.unique_id, - command.__name__, - "{}: {}".format("with args", args), - "{}: {}".format("with kwargs", kwds), - "{}: {}".format("and result", result)) - if isinstance(result, bool): - return result - return result[1] is Status.SUCCESS - except DeliveryError: - _LOGGER.debug("%s: command failed: %s", listener.unique_id, - command.__name__) - return False - return wrapper - - -class ListenerStatus(Enum): - """Status of a listener.""" - - CREATED = 1 - CONFIGURED = 2 - INITIALIZED = 3 - - -class ClusterListener: - """Listener for a Zigbee cluster.""" - - def __init__(self, cluster, device): - """Initialize ClusterListener.""" - self.name = 'cluster_{}'.format(cluster.cluster_id) - self._cluster = cluster - self._zha_device = device - self._unique_id = construct_unique_id(cluster) - self._report_config = CLUSTER_REPORT_CONFIGS.get( - self._cluster.cluster_id, - [{'attr': 0, 'config': REPORT_CONFIG_DEFAULT}] - ) - self._status = ListenerStatus.CREATED - self._cluster.add_listener(self) - - @property - def unique_id(self): - """Return the unique id for this listener.""" - return self._unique_id - - @property - def cluster(self): - """Return the zigpy cluster for this listener.""" - return self._cluster - - @property - def device(self): - """Return the device this listener is linked to.""" - return self._zha_device - - @property - def status(self): - """Return the status of the listener.""" - return self._status - - def set_report_config(self, report_config): - """Set the reporting configuration.""" - self._report_config = report_config - - async def async_configure(self): - """Set cluster binding and attribute reporting.""" - manufacturer = None - manufacturer_code = self._zha_device.manufacturer_code - if self.cluster.cluster_id >= 0xfc00 and manufacturer_code: - manufacturer = manufacturer_code - - skip_bind = False # bind cluster only for the 1st configured attr - for report_config in self._report_config: - attr = report_config.get('attr') - min_report_interval, max_report_interval, change = \ - report_config.get('config') - await bind_configure_reporting( - self._unique_id, self.cluster, attr, - min_report=min_report_interval, - max_report=max_report_interval, - reportable_change=change, - skip_bind=skip_bind, - manufacturer=manufacturer - ) - skip_bind = True - await asyncio.sleep(uniform(0.1, 0.5)) - _LOGGER.debug( - "%s: finished listener configuration", - self._unique_id - ) - self._status = ListenerStatus.CONFIGURED - - async def async_initialize(self, from_cache): - """Initialize listener.""" - self._status = ListenerStatus.INITIALIZED - - @callback - def cluster_command(self, tsn, command_id, args): - """Handle commands received to this cluster.""" - pass - - @callback - def attribute_updated(self, attrid, value): - """Handle attribute updates on this cluster.""" - pass - - @callback - def zdo_command(self, *args, **kwargs): - """Handle ZDO commands on this cluster.""" - pass - - @callback - def zha_send_event(self, cluster, command, args): - """Relay events to hass.""" - self._zha_device.hass.bus.async_fire( - 'zha_event', - { - 'unique_id': self._unique_id, - 'device_ieee': str(self._zha_device.ieee), - 'command': command, - 'args': args - } - ) - - async def async_update(self): - """Retrieve latest state from cluster.""" - pass - - async def get_attribute_value(self, attribute, from_cache=True): - """Get the value for an attribute.""" - result = await safe_read( - self._cluster, - [attribute], - allow_cache=from_cache, - only_cache=from_cache - ) - return result.get(attribute) - - def __getattr__(self, name): - """Get attribute or a decorated cluster command.""" - if hasattr(self._cluster, name) and callable( - getattr(self._cluster, name)): - command = getattr(self._cluster, name) - command.__name__ = name - return decorate_command( - self, - command - ) - return self.__getattribute__(name) - - -class AttributeListener(ClusterListener): - """Listener for the attribute reports cluster.""" - - def __init__(self, cluster, device): - """Initialize AttributeListener.""" - super().__init__(cluster, device) - self.name = LISTENER_ATTRIBUTE - attr = self._report_config[0].get('attr') - if isinstance(attr, str): - self._value_attribute = get_attr_id_by_name(self.cluster, attr) - else: - self._value_attribute = attr - - @callback - def attribute_updated(self, attrid, value): - """Handle attribute updates on this cluster.""" - if attrid == self._value_attribute: - async_dispatcher_send( - self._zha_device.hass, - "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), - value - ) - - async def async_initialize(self, from_cache): - """Initialize listener.""" - await self.get_attribute_value( - self._report_config[0].get('attr'), from_cache=from_cache) - await super().async_initialize(from_cache) - - -class OnOffListener(ClusterListener): - """Listener for the OnOff Zigbee cluster.""" - - ON_OFF = 0 - - def __init__(self, cluster, device): - """Initialize OnOffListener.""" - super().__init__(cluster, device) - self.name = LISTENER_ON_OFF - self._state = None - - @callback - def cluster_command(self, tsn, command_id, args): - """Handle commands received to this cluster.""" - cmd = parse_and_log_command( - self.unique_id, - self._cluster, - tsn, - command_id, - args - ) - - if cmd in ('off', 'off_with_effect'): - self.attribute_updated(self.ON_OFF, False) - elif cmd in ('on', 'on_with_recall_global_scene', 'on_with_timed_off'): - self.attribute_updated(self.ON_OFF, True) - elif cmd == 'toggle': - self.attribute_updated(self.ON_OFF, not bool(self._state)) - - @callback - def attribute_updated(self, attrid, value): - """Handle attribute updates on this cluster.""" - if attrid == self.ON_OFF: - async_dispatcher_send( - self._zha_device.hass, - "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), - value - ) - self._state = bool(value) - - async def async_initialize(self, from_cache): - """Initialize listener.""" - self._state = bool( - await self.get_attribute_value(self.ON_OFF, from_cache=from_cache)) - await super().async_initialize(from_cache) - - -class LevelListener(ClusterListener): - """Listener for the LevelControl Zigbee cluster.""" - - CURRENT_LEVEL = 0 - - def __init__(self, cluster, device): - """Initialize LevelListener.""" - super().__init__(cluster, device) - self.name = LISTENER_LEVEL - - @callback - def cluster_command(self, tsn, command_id, args): - """Handle commands received to this cluster.""" - cmd = parse_and_log_command( - self.unique_id, - self._cluster, - tsn, - command_id, - args - ) - - if cmd in ('move_to_level', 'move_to_level_with_on_off'): - self.dispatch_level_change(SIGNAL_SET_LEVEL, args[0]) - elif cmd in ('move', 'move_with_on_off'): - # We should dim slowly -- for now, just step once - rate = args[1] - if args[0] == 0xff: - rate = 10 # Should read default move rate - self.dispatch_level_change( - SIGNAL_MOVE_LEVEL, -rate if args[0] else rate) - elif cmd in ('step', 'step_with_on_off'): - # Step (technically may change on/off) - self.dispatch_level_change( - SIGNAL_MOVE_LEVEL, -args[1] if args[0] else args[1]) - - @callback - def attribute_updated(self, attrid, value): - """Handle attribute updates on this cluster.""" - _LOGGER.debug("%s: received attribute: %s update with value: %i", - self.unique_id, attrid, value) - if attrid == self.CURRENT_LEVEL: - self.dispatch_level_change(SIGNAL_SET_LEVEL, value) - - def dispatch_level_change(self, command, level): - """Dispatch level change.""" - async_dispatcher_send( - self._zha_device.hass, - "{}_{}".format(self.unique_id, command), - level - ) - - async def async_initialize(self, from_cache): - """Initialize listener.""" - await self.get_attribute_value( - self.CURRENT_LEVEL, from_cache=from_cache) - await super().async_initialize(from_cache) - - -class IASZoneListener(ClusterListener): - """Listener for the IASZone Zigbee cluster.""" - - def __init__(self, cluster, device): - """Initialize LevelListener.""" - super().__init__(cluster, device) - self.name = LISTENER_ZONE - - @callback - def cluster_command(self, tsn, command_id, args): - """Handle commands received to this cluster.""" - if command_id == 0: - state = args[0] & 3 - async_dispatcher_send( - self._zha_device.hass, - "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), - state - ) - _LOGGER.debug("Updated alarm state: %s", state) - elif command_id == 1: - _LOGGER.debug("Enroll requested") - res = self._cluster.enroll_response(0, 0) - self._zha_device.hass.async_create_task(res) - - async def async_configure(self): - """Configure IAS device.""" - from zigpy.exceptions import DeliveryError - _LOGGER.debug("%s: started IASZoneListener configuration", - self._unique_id) - - await bind_cluster(self.unique_id, self._cluster) - ieee = self._cluster.endpoint.device.application.ieee - - try: - res = await self._cluster.write_attributes({'cie_addr': ieee}) - _LOGGER.debug( - "%s: wrote cie_addr: %s to '%s' cluster: %s", - self.unique_id, str(ieee), self._cluster.ep_attribute, - res[0] - ) - except DeliveryError as ex: - _LOGGER.debug( - "%s: Failed to write cie_addr: %s to '%s' cluster: %s", - self.unique_id, str(ieee), self._cluster.ep_attribute, str(ex) - ) - _LOGGER.debug("%s: finished IASZoneListener configuration", - self._unique_id) - - await self.get_attribute_value('zone_type', from_cache=False) - - @callback - def attribute_updated(self, attrid, value): - """Handle attribute updates on this cluster.""" - if attrid == 2: - value = value & 3 - async_dispatcher_send( - self._zha_device.hass, - "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), - value - ) - - async def async_initialize(self, from_cache): - """Initialize listener.""" - await self.get_attribute_value('zone_status', from_cache=from_cache) - await self.get_attribute_value('zone_state', from_cache=from_cache) - await super().async_initialize(from_cache) - - -class ActivePowerListener(AttributeListener): - """Listener that polls active power level.""" - - def __init__(self, cluster, device): - """Initialize ActivePowerListener.""" - super().__init__(cluster, device) - self.name = LISTENER_ACTIVE_POWER - - async def async_update(self): - """Retrieve latest state.""" - _LOGGER.debug("%s async_update", self.unique_id) - - # This is a polling listener. Don't allow cache. - result = await self.get_attribute_value( - LISTENER_ACTIVE_POWER, from_cache=False) - async_dispatcher_send( - self._zha_device.hass, - "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), - result - ) - - async def async_initialize(self, from_cache): - """Initialize listener.""" - await self.get_attribute_value( - LISTENER_ACTIVE_POWER, from_cache=from_cache) - await super().async_initialize(from_cache) - - -class BasicListener(ClusterListener): - """Listener to interact with the basic cluster.""" - - BATTERY = 3 - POWER_SOURCES = { - 0: 'Unknown', - 1: 'Mains (single phase)', - 2: 'Mains (3 phase)', - BATTERY: 'Battery', - 4: 'DC source', - 5: 'Emergency mains constantly powered', - 6: 'Emergency mains and transfer switch' - } - - def __init__(self, cluster, device): - """Initialize BasicListener.""" - super().__init__(cluster, device) - self.name = LISTENER_BASIC - self._power_source = None - - async def async_configure(self): - """Configure this listener.""" - await super().async_configure() - await self.async_initialize(False) - - async def async_initialize(self, from_cache): - """Initialize listener.""" - self._power_source = await self.get_attribute_value( - 'power_source', from_cache=from_cache) - await super().async_initialize(from_cache) - - def get_power_source(self): - """Get the power source.""" - return self._power_source - - -class BatteryListener(ClusterListener): - """Listener that polls active power level.""" - - def __init__(self, cluster, device): - """Initialize BatteryListener.""" - super().__init__(cluster, device) - self.name = LISTENER_BATTERY - - @callback - def attribute_updated(self, attrid, value): - """Handle attribute updates on this cluster.""" - attr = self._report_config[1].get('attr') - if isinstance(attr, str): - attr_id = get_attr_id_by_name(self.cluster, attr) - else: - attr_id = attr - if attrid == attr_id: - async_dispatcher_send( - self._zha_device.hass, - "{}_{}".format(self.unique_id, SIGNAL_STATE_ATTR), - 'battery_level', - value - ) - - async def async_initialize(self, from_cache): - """Initialize listener.""" - await self.async_read_state(from_cache) - await super().async_initialize(from_cache) - - async def async_update(self): - """Retrieve latest state.""" - await self.async_read_state(True) - - async def async_read_state(self, from_cache): - """Read data from the cluster.""" - await self.get_attribute_value( - 'battery_size', from_cache=from_cache) - await self.get_attribute_value( - 'battery_percentage_remaining', from_cache=from_cache) - await self.get_attribute_value( - 'active_power', from_cache=from_cache) - - -class EventRelayListener(ClusterListener): - """Event relay that can be attached to zigbee clusters.""" - - def __init__(self, cluster, device): - """Initialize EventRelayListener.""" - super().__init__(cluster, device) - self.name = LISTENER_EVENT_RELAY - - @callback - def attribute_updated(self, attrid, value): - """Handle an attribute updated on this cluster.""" - self.zha_send_event( - self._cluster, - SIGNAL_ATTR_UPDATED, - { - 'attribute_id': attrid, - 'attribute_name': self._cluster.attributes.get( - attrid, - ['Unknown'])[0], - 'value': value - } - ) - - @callback - def cluster_command(self, tsn, command_id, args): - """Handle a cluster command received on this cluster.""" - if self._cluster.server_commands is not None and \ - self._cluster.server_commands.get(command_id) is not None: - self.zha_send_event( - self._cluster, - self._cluster.server_commands.get(command_id)[0], - args - ) - - -class ColorListener(ClusterListener): - """Color listener.""" - - CAPABILITIES_COLOR_XY = 0x08 - CAPABILITIES_COLOR_TEMP = 0x10 - UNSUPPORTED_ATTRIBUTE = 0x86 - - def __init__(self, cluster, device): - """Initialize ColorListener.""" - super().__init__(cluster, device) - self.name = LISTENER_COLOR - self._color_capabilities = None - - def get_color_capabilities(self): - """Return the color capabilities.""" - return self._color_capabilities - - async def async_initialize(self, from_cache): - """Initialize listener.""" - capabilities = await self.get_attribute_value( - 'color_capabilities', from_cache=from_cache) - - if capabilities is None: - # ZCL Version 4 devices don't support the color_capabilities - # attribute. In this version XY support is mandatory, but we - # need to probe to determine if the device supports color - # temperature. - capabilities = self.CAPABILITIES_COLOR_XY - result = await self.get_attribute_value( - 'color_temperature', from_cache=from_cache) - - if result is not self.UNSUPPORTED_ATTRIBUTE: - capabilities |= self.CAPABILITIES_COLOR_TEMP - self._color_capabilities = capabilities - await super().async_initialize(from_cache) - - -class FanListener(ClusterListener): - """Fan listener.""" - - _value_attribute = 0 - - def __init__(self, cluster, device): - """Initialize FanListener.""" - super().__init__(cluster, device) - self.name = LISTENER_FAN - - async def async_set_speed(self, value) -> None: - """Set the speed of the fan.""" - from zigpy.exceptions import DeliveryError - try: - await self.cluster.write_attributes({'fan_mode': value}) - except DeliveryError as ex: - _LOGGER.error("%s: Could not set speed: %s", self.unique_id, ex) - return - - async def async_update(self): - """Retrieve latest state.""" - result = await self.get_attribute_value('fan_mode', from_cache=True) - - async_dispatcher_send( - self._zha_device.hass, - "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), - result - ) - - def attribute_updated(self, attrid, value): - """Handle attribute update from fan cluster.""" - attr_name = self.cluster.attributes.get(attrid, [attrid])[0] - _LOGGER.debug("%s: Attribute report '%s'[%s] = %s", - self.unique_id, self.cluster.name, attr_name, value) - if attrid == self._value_attribute: - async_dispatcher_send( - self._zha_device.hass, - "{}_{}".format(self.unique_id, SIGNAL_ATTR_UPDATED), - value - ) - - async def async_initialize(self, from_cache): - """Initialize listener.""" - await self.get_attribute_value( - self._value_attribute, from_cache=from_cache) - await super().async_initialize(from_cache) - - -class ZDOListener: - """Listener for ZDO events.""" - - def __init__(self, cluster, device): - """Initialize ZDOListener.""" - self.name = 'zdo' - self._cluster = cluster - self._zha_device = device - self._status = ListenerStatus.CREATED - self._unique_id = "{}_ZDO".format(device.name) - self._cluster.add_listener(self) - - @property - def unique_id(self): - """Return the unique id for this listener.""" - return self._unique_id - - @property - def cluster(self): - """Return the aigpy cluster for this listener.""" - return self._cluster - - @property - def status(self): - """Return the status of the listener.""" - return self._status - - @callback - def device_announce(self, zigpy_device): - """Device announce handler.""" - pass - - @callback - def permit_duration(self, duration): - """Permit handler.""" - pass - - async def async_initialize(self, from_cache): - """Initialize listener.""" - self._status = ListenerStatus.INITIALIZED - - async def async_configure(self): - """Configure listener.""" - self._status = ListenerStatus.CONFIGURED diff --git a/homeassistant/components/zha/device_entity.py b/homeassistant/components/zha/device_entity.py index e8b765a07a6..5632c849d59 100644 --- a/homeassistant/components/zha/device_entity.py +++ b/homeassistant/components/zha/device_entity.py @@ -11,7 +11,7 @@ import time from homeassistant.core import callback from homeassistant.util import slugify from .entity import ZhaEntity -from .const import LISTENER_BATTERY, SIGNAL_STATE_ATTR +from .const import POWER_CONFIGURATION_CHANNEL, SIGNAL_STATE_ATTR _LOGGER = logging.getLogger(__name__) @@ -38,7 +38,7 @@ STATE_OFFLINE = 'offline' class ZhaDeviceEntity(ZhaEntity): """A base class for ZHA devices.""" - def __init__(self, zha_device, listeners, keepalive_interval=7200, + def __init__(self, zha_device, channels, keepalive_interval=7200, **kwargs): """Init ZHA endpoint entity.""" ieee = zha_device.ieee @@ -55,7 +55,7 @@ class ZhaDeviceEntity(ZhaEntity): unique_id = str(ieeetail) kwargs['component'] = 'zha' - super().__init__(unique_id, zha_device, listeners, skip_entity_id=True, + super().__init__(unique_id, zha_device, channels, skip_entity_id=True, **kwargs) self._keepalive_interval = keepalive_interval @@ -66,7 +66,8 @@ class ZhaDeviceEntity(ZhaEntity): 'rssi': zha_device.rssi, }) self._should_poll = True - self._battery_listener = self.cluster_listeners.get(LISTENER_BATTERY) + self._battery_channel = self.cluster_channels.get( + POWER_CONFIGURATION_CHANNEL) @property def state(self) -> str: @@ -97,9 +98,9 @@ class ZhaDeviceEntity(ZhaEntity): async def async_added_to_hass(self): """Run when about to be added to hass.""" await super().async_added_to_hass() - if self._battery_listener: + if self._battery_channel: await self.async_accept_signal( - self._battery_listener, SIGNAL_STATE_ATTR, + self._battery_channel, SIGNAL_STATE_ATTR, self.async_update_state_attribute) # only do this on add to HA because it is static await self._async_init_battery_values() @@ -114,7 +115,7 @@ class ZhaDeviceEntity(ZhaEntity): self._zha_device.update_available(False) else: self._zha_device.update_available(True) - if self._battery_listener: + if self._battery_channel: await self.async_get_latest_battery_reading() @callback @@ -127,14 +128,14 @@ class ZhaDeviceEntity(ZhaEntity): super().async_set_available(available) async def _async_init_battery_values(self): - """Get initial battery level and battery info from listener cache.""" - battery_size = await self._battery_listener.get_attribute_value( + """Get initial battery level and battery info from channel cache.""" + battery_size = await self._battery_channel.get_attribute_value( 'battery_size') if battery_size is not None: self._device_state_attributes['battery_size'] = BATTERY_SIZES.get( battery_size, 'Unknown') - battery_quantity = await self._battery_listener.get_attribute_value( + battery_quantity = await self._battery_channel.get_attribute_value( 'battery_quantity') if battery_quantity is not None: self._device_state_attributes['battery_quantity'] = \ @@ -142,8 +143,8 @@ class ZhaDeviceEntity(ZhaEntity): await self.async_get_latest_battery_reading() async def async_get_latest_battery_reading(self): - """Get the latest battery reading from listeners cache.""" - battery = await self._battery_listener.get_attribute_value( + """Get the latest battery reading from channels cache.""" + battery = await self._battery_channel.get_attribute_value( 'battery_percentage_remaining') if battery is not None: self._device_state_attributes['battery_level'] = battery diff --git a/homeassistant/components/zha/entity.py b/homeassistant/components/zha/entity.py index d914a76c4ce..2f5aed4ca29 100644 --- a/homeassistant/components/zha/entity.py +++ b/homeassistant/components/zha/entity.py @@ -27,7 +27,7 @@ class ZhaEntity(entity.Entity): _domain = None # Must be overridden by subclasses - def __init__(self, unique_id, zha_device, listeners, + def __init__(self, unique_id, zha_device, channels, skip_entity_id=False, **kwargs): """Init ZHA entity.""" self._force_update = False @@ -48,25 +48,25 @@ class ZhaEntity(entity.Entity): slugify(zha_device.manufacturer), slugify(zha_device.model), ieeetail, - listeners[0].cluster.endpoint.endpoint_id, + channels[0].cluster.endpoint.endpoint_id, kwargs.get(ENTITY_SUFFIX, ''), ) else: self.entity_id = "{}.zha_{}_{}{}".format( self._domain, ieeetail, - listeners[0].cluster.endpoint.endpoint_id, + channels[0].cluster.endpoint.endpoint_id, kwargs.get(ENTITY_SUFFIX, ''), ) self._state = None self._device_state_attributes = {} self._zha_device = zha_device - self.cluster_listeners = {} + self.cluster_channels = {} self._available = False self._component = kwargs['component'] self._unsubs = [] - for listener in listeners: - self.cluster_listeners[listener.name] = listener + for channel in channels: + self.cluster_channels[channel.name] = channel @property def name(self): @@ -147,7 +147,7 @@ class ZhaEntity(entity.Entity): ) self._zha_device.gateway.register_entity_reference( self._zha_device.ieee, self.entity_id, self._zha_device, - self.cluster_listeners, self.device_info) + self.cluster_channels, self.device_info) async def async_will_remove_from_hass(self) -> None: """Disconnect entity object when removed.""" @@ -156,13 +156,13 @@ class ZhaEntity(entity.Entity): async def async_update(self): """Retrieve latest state.""" - for listener in self.cluster_listeners: - if hasattr(listener, 'async_update'): - await listener.async_update() + for channel in self.cluster_channels: + if hasattr(channel, 'async_update'): + await channel.async_update() - async def async_accept_signal(self, listener, signal, func, + async def async_accept_signal(self, channel, signal, func, signal_override=False): - """Accept a signal from a listener.""" + """Accept a signal from a channel.""" unsub = None if signal_override: unsub = async_dispatcher_connect( @@ -173,7 +173,7 @@ class ZhaEntity(entity.Entity): else: unsub = async_dispatcher_connect( self.hass, - "{}_{}".format(listener.unique_id, signal), + "{}_{}".format(channel.unique_id, signal), func ) self._unsubs.append(unsub) diff --git a/homeassistant/components/zha/fan.py b/homeassistant/components/zha/fan.py index dfe3c8cdd23..761dfaede1e 100644 --- a/homeassistant/components/zha/fan.py +++ b/homeassistant/components/zha/fan.py @@ -11,7 +11,7 @@ from homeassistant.components.fan import ( FanEntity) from homeassistant.helpers.dispatcher import async_dispatcher_connect from .core.const import ( - DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, LISTENER_FAN, + DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, FAN_CHANNEL, SIGNAL_ATTR_UPDATED ) from .entity import ZhaEntity @@ -81,16 +81,16 @@ class ZhaFan(ZhaEntity, FanEntity): _domain = DOMAIN - def __init__(self, unique_id, zha_device, listeners, **kwargs): + def __init__(self, unique_id, zha_device, channels, **kwargs): """Init this sensor.""" - super().__init__(unique_id, zha_device, listeners, **kwargs) - self._fan_listener = self.cluster_listeners.get(LISTENER_FAN) + super().__init__(unique_id, zha_device, channels, **kwargs) + self._fan_channel = self.cluster_channels.get(FAN_CHANNEL) async def async_added_to_hass(self): """Run when about to be added to hass.""" await super().async_added_to_hass() await self.async_accept_signal( - self._fan_listener, SIGNAL_ATTR_UPDATED, self.async_set_state) + self._fan_channel, SIGNAL_ATTR_UPDATED, self.async_set_state) @property def supported_features(self) -> int: @@ -120,7 +120,7 @@ class ZhaFan(ZhaEntity, FanEntity): return self.state_attributes def async_set_state(self, state): - """Handle state update from listener.""" + """Handle state update from channel.""" self._state = VALUE_TO_SPEED.get(state, self._state) self.async_schedule_update_ha_state() @@ -137,5 +137,5 @@ class ZhaFan(ZhaEntity, FanEntity): async def async_set_speed(self, speed: str) -> None: """Set the speed of the fan.""" - await self._fan_listener.async_set_speed(SPEED_TO_VALUE[speed]) + await self._fan_channel.async_set_speed(SPEED_TO_VALUE[speed]) self.async_set_state(speed) diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 09f1812cd76..efa6f679ae8 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -10,8 +10,8 @@ from homeassistant.components import light from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.util.color as color_util from .const import ( - DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, LISTENER_COLOR, - LISTENER_ON_OFF, LISTENER_LEVEL, SIGNAL_ATTR_UPDATED, SIGNAL_SET_LEVEL + DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, COLOR_CHANNEL, + ON_OFF_CHANNEL, LEVEL_CHANNEL, SIGNAL_ATTR_UPDATED, SIGNAL_SET_LEVEL ) from .entity import ZhaEntity @@ -67,24 +67,24 @@ class Light(ZhaEntity, light.Light): _domain = light.DOMAIN - def __init__(self, unique_id, zha_device, listeners, **kwargs): + def __init__(self, unique_id, zha_device, channels, **kwargs): """Initialize the ZHA light.""" - super().__init__(unique_id, zha_device, listeners, **kwargs) + super().__init__(unique_id, zha_device, channels, **kwargs) self._supported_features = 0 self._color_temp = None self._hs_color = None self._brightness = None - self._on_off_listener = self.cluster_listeners.get(LISTENER_ON_OFF) - self._level_listener = self.cluster_listeners.get(LISTENER_LEVEL) - self._color_listener = self.cluster_listeners.get(LISTENER_COLOR) + self._on_off_channel = self.cluster_channels.get(ON_OFF_CHANNEL) + self._level_channel = self.cluster_channels.get(LEVEL_CHANNEL) + self._color_channel = self.cluster_channels.get(COLOR_CHANNEL) - if self._level_listener: + if self._level_channel: self._supported_features |= light.SUPPORT_BRIGHTNESS self._supported_features |= light.SUPPORT_TRANSITION self._brightness = 0 - if self._color_listener: - color_capabilities = self._color_listener.get_color_capabilities() + if self._color_channel: + color_capabilities = self._color_channel.get_color_capabilities() if color_capabilities & CAPABILITIES_COLOR_TEMP: self._supported_features |= light.SUPPORT_COLOR_TEMP @@ -139,10 +139,10 @@ class Light(ZhaEntity, light.Light): """Run when about to be added to hass.""" await super().async_added_to_hass() await self.async_accept_signal( - self._on_off_listener, SIGNAL_ATTR_UPDATED, self.async_set_state) - if self._level_listener: + self._on_off_channel, SIGNAL_ATTR_UPDATED, self.async_set_state) + if self._level_channel: await self.async_accept_signal( - self._level_listener, SIGNAL_SET_LEVEL, self.set_level) + self._level_channel, SIGNAL_SET_LEVEL, self.set_level) async def async_turn_on(self, **kwargs): """Turn the entity on.""" @@ -152,7 +152,7 @@ class Light(ZhaEntity, light.Light): if light.ATTR_COLOR_TEMP in kwargs and \ self.supported_features & light.SUPPORT_COLOR_TEMP: temperature = kwargs[light.ATTR_COLOR_TEMP] - success = await self._color_listener.move_to_color_temp( + success = await self._color_channel.move_to_color_temp( temperature, duration) if not success: return @@ -162,7 +162,7 @@ class Light(ZhaEntity, light.Light): self.supported_features & light.SUPPORT_COLOR: hs_color = kwargs[light.ATTR_HS_COLOR] xy_color = color_util.color_hs_to_xy(*hs_color) - success = await self._color_listener.move_to_color( + success = await self._color_channel.move_to_color( int(xy_color[0] * 65535), int(xy_color[1] * 65535), duration, @@ -174,7 +174,7 @@ class Light(ZhaEntity, light.Light): if self._brightness is not None: brightness = kwargs.get( light.ATTR_BRIGHTNESS, self._brightness or 255) - success = await self._level_listener.move_to_level_with_on_off( + success = await self._level_channel.move_to_level_with_on_off( brightness, duration ) @@ -185,7 +185,7 @@ class Light(ZhaEntity, light.Light): self.async_schedule_update_ha_state() return - success = await self._on_off_listener.on() + success = await self._on_off_channel.on() if not success: return @@ -198,12 +198,12 @@ class Light(ZhaEntity, light.Light): supports_level = self.supported_features & light.SUPPORT_BRIGHTNESS success = None if duration and supports_level: - success = await self._level_listener.move_to_level_with_on_off( + success = await self._level_channel.move_to_level_with_on_off( 0, duration*10 ) else: - success = await self._on_off_listener.off() + success = await self._on_off_channel.off() _LOGGER.debug("%s was turned off: %s", self.entity_id, success) if not success: return diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 9c00d8124bb..6dcdbb845dc 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -12,7 +12,7 @@ from homeassistant.helpers.dispatcher import async_dispatcher_connect from .core.const import ( DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, HUMIDITY, TEMPERATURE, ILLUMINANCE, PRESSURE, METERING, ELECTRICAL_MEASUREMENT, - GENERIC, SENSOR_TYPE, LISTENER_ATTRIBUTE, LISTENER_ACTIVE_POWER, + GENERIC, SENSOR_TYPE, ATTRIBUTE_CHANNEL, ELECTRICAL_MEASUREMENT_CHANNEL, SIGNAL_ATTR_UPDATED, SIGNAL_STATE_ATTR) from .entity import ZhaEntity @@ -74,8 +74,8 @@ UNIT_REGISTRY = { GENERIC: None } -LISTENER_REGISTRY = { - ELECTRICAL_MEASUREMENT: LISTENER_ACTIVE_POWER, +CHANNEL_REGISTRY = { + ELECTRICAL_MEASUREMENT: ELECTRICAL_MEASUREMENT_CHANNEL, } POLLING_REGISTRY = { @@ -130,9 +130,9 @@ class Sensor(ZhaEntity): _domain = DOMAIN - def __init__(self, unique_id, zha_device, listeners, **kwargs): + def __init__(self, unique_id, zha_device, channels, **kwargs): """Init this sensor.""" - super().__init__(unique_id, zha_device, listeners, **kwargs) + super().__init__(unique_id, zha_device, channels, **kwargs) sensor_type = kwargs.get(SENSOR_TYPE, GENERIC) self._unit = UNIT_REGISTRY.get(sensor_type) self._formatter_function = FORMATTER_FUNC_REGISTRY.get( @@ -147,17 +147,17 @@ class Sensor(ZhaEntity): sensor_type, False ) - self._listener = self.cluster_listeners.get( - LISTENER_REGISTRY.get(sensor_type, LISTENER_ATTRIBUTE) + self._channel = self.cluster_channels.get( + CHANNEL_REGISTRY.get(sensor_type, ATTRIBUTE_CHANNEL) ) async def async_added_to_hass(self): """Run when about to be added to hass.""" await super().async_added_to_hass() await self.async_accept_signal( - self._listener, SIGNAL_ATTR_UPDATED, self.async_set_state) + self._channel, SIGNAL_ATTR_UPDATED, self.async_set_state) await self.async_accept_signal( - self._listener, SIGNAL_STATE_ATTR, + self._channel, SIGNAL_STATE_ATTR, self.async_update_state_attribute) @property @@ -175,6 +175,6 @@ class Sensor(ZhaEntity): return self._state def async_set_state(self, state): - """Handle state update from listener.""" + """Handle state update from channel.""" self._state = self._formatter_function(state) self.async_schedule_update_ha_state() diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 4eee3d5da35..bdbdd7a6a76 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -9,7 +9,7 @@ import logging from homeassistant.components.switch import DOMAIN, SwitchDevice from homeassistant.helpers.dispatcher import async_dispatcher_connect from .core.const import ( - DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, LISTENER_ON_OFF, + DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW, ON_OFF_CHANNEL, SIGNAL_ATTR_UPDATED ) from .entity import ZhaEntity @@ -60,7 +60,7 @@ class Switch(ZhaEntity, SwitchDevice): def __init__(self, **kwargs): """Initialize the ZHA switch.""" super().__init__(**kwargs) - self._on_off_listener = self.cluster_listeners.get(LISTENER_ON_OFF) + self._on_off_channel = self.cluster_channels.get(ON_OFF_CHANNEL) @property def is_on(self) -> bool: @@ -71,14 +71,14 @@ class Switch(ZhaEntity, SwitchDevice): async def async_turn_on(self, **kwargs): """Turn the entity on.""" - await self._on_off_listener.on() + await self._on_off_channel.on() async def async_turn_off(self, **kwargs): """Turn the entity off.""" - await self._on_off_listener.off() + await self._on_off_channel.off() def async_set_state(self, state): - """Handle state update from listener.""" + """Handle state update from channel.""" self._state = bool(state) self.async_schedule_update_ha_state() @@ -91,4 +91,4 @@ class Switch(ZhaEntity, SwitchDevice): """Run when about to be added to hass.""" await super().async_added_to_hass() await self.async_accept_signal( - self._on_off_listener, SIGNAL_ATTR_UPDATED, self.async_set_state) + self._on_off_channel, SIGNAL_ATTR_UPDATED, self.async_set_state) diff --git a/tests/components/zha/conftest.py b/tests/components/zha/conftest.py index c806b1a2217..bd594941da1 100644 --- a/tests/components/zha/conftest.py +++ b/tests/components/zha/conftest.py @@ -7,8 +7,8 @@ from homeassistant.components.zha.core.const import ( ) from homeassistant.components.zha.core.gateway import ZHAGateway from homeassistant.components.zha.core.gateway import establish_device_mappings -from homeassistant.components.zha.core.listeners \ - import populate_listener_registry +from homeassistant.components.zha.core.channels.registry \ + import populate_channel_registry from .common import async_setup_entry @@ -28,7 +28,7 @@ def zha_gateway_fixture(hass): Create a ZHAGateway object that can be used to interact with as if we had a real zigbee network running. """ - populate_listener_registry() + populate_channel_registry() establish_device_mappings() for component in COMPONENTS: hass.data[DATA_ZHA][component] = ( From be26fc896d4bac88561ea2e26d5b8e68eef762ce Mon Sep 17 00:00:00 2001 From: Aaron Bach Date: Wed, 20 Feb 2019 04:10:42 -0700 Subject: [PATCH 41/45] Fix an Ambient PWS exception when location info is missing (#21220) --- homeassistant/components/ambient_station/__init__.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py index 4a7864d3f7f..4464992e5fa 100644 --- a/homeassistant/components/ambient_station/__init__.py +++ b/homeassistant/components/ambient_station/__init__.py @@ -339,8 +339,10 @@ class AmbientStation: self.stations[station['macAddress']] = { ATTR_LAST_DATA: station['lastData'], - ATTR_LOCATION: station['info']['location'], - ATTR_NAME: station['info']['name'], + ATTR_LOCATION: station.get('info', {}).get('location'), + ATTR_NAME: + station.get('info', {}).get( + 'name', station['macAddress']), } for component in ('binary_sensor', 'sensor'): From fd3bea177bf34c6b5489bc0139c407c1a7cbdb98 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Tue, 19 Feb 2019 23:02:56 -0800 Subject: [PATCH 42/45] Prevent invalid context from crashing (#21231) * Prevent invalid context from crashing * Lint --- homeassistant/core.py | 5 +- tests/test_core.py | 105 ++++++++++++++++++++++++------------------ 2 files changed, 64 insertions(+), 46 deletions(-) diff --git a/homeassistant/core.py b/homeassistant/core.py index 5cd23e9f9a2..e7f654f5184 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -744,7 +744,10 @@ class State: context = json_dict.get('context') if context: - context = Context(**context) + context = Context( + id=context.get('id'), + user_id=context.get('user_id'), + ) return cls(json_dict['entity_id'], json_dict['state'], json_dict.get('attributes'), last_changed, last_updated, diff --git a/tests/test_core.py b/tests/test_core.py index 3cb5b87b4bb..4acb1de6677 100644 --- a/tests/test_core.py +++ b/tests/test_core.py @@ -460,61 +460,76 @@ class TestEventBus(unittest.TestCase): assert len(coroutine_calls) == 1 -class TestState(unittest.TestCase): - """Test State methods.""" +def test_state_init(): + """Test state.init.""" + with pytest.raises(InvalidEntityFormatError): + ha.State('invalid_entity_format', 'test_state') - def test_init(self): - """Test state.init.""" - with pytest.raises(InvalidEntityFormatError): - ha.State('invalid_entity_format', 'test_state') + with pytest.raises(InvalidStateError): + ha.State('domain.long_state', 't' * 256) - with pytest.raises(InvalidStateError): - ha.State('domain.long_state', 't' * 256) - def test_domain(self): - """Test domain.""" - state = ha.State('some_domain.hello', 'world') - assert 'some_domain' == state.domain +def test_state_domain(): + """Test domain.""" + state = ha.State('some_domain.hello', 'world') + assert 'some_domain' == state.domain - def test_object_id(self): - """Test object ID.""" - state = ha.State('domain.hello', 'world') - assert 'hello' == state.object_id - def test_name_if_no_friendly_name_attr(self): - """Test if there is no friendly name.""" - state = ha.State('domain.hello_world', 'world') - assert 'hello world' == state.name +def test_state_object_id(): + """Test object ID.""" + state = ha.State('domain.hello', 'world') + assert 'hello' == state.object_id - def test_name_if_friendly_name_attr(self): - """Test if there is a friendly name.""" - name = 'Some Unique Name' - state = ha.State('domain.hello_world', 'world', - {ATTR_FRIENDLY_NAME: name}) - assert name == state.name - def test_dict_conversion(self): - """Test conversion of dict.""" - state = ha.State('domain.hello', 'world', {'some': 'attr'}) - assert state == ha.State.from_dict(state.as_dict()) +def test_state_name_if_no_friendly_name_attr(): + """Test if there is no friendly name.""" + state = ha.State('domain.hello_world', 'world') + assert 'hello world' == state.name - def test_dict_conversion_with_wrong_data(self): - """Test conversion with wrong data.""" - assert ha.State.from_dict(None) is None - assert ha.State.from_dict({'state': 'yes'}) is None - assert ha.State.from_dict({'entity_id': 'yes'}) is None - def test_repr(self): - """Test state.repr.""" - assert "" == \ - str(ha.State( - "happy.happy", "on", - last_changed=datetime(1984, 12, 8, 12, 0, 0))) +def test_state_name_if_friendly_name_attr(): + """Test if there is a friendly name.""" + name = 'Some Unique Name' + state = ha.State('domain.hello_world', 'world', + {ATTR_FRIENDLY_NAME: name}) + assert name == state.name - assert "" == \ - str(ha.State("happy.happy", "on", {"brightness": 144}, - datetime(1984, 12, 8, 12, 0, 0))) + +def test_state_dict_conversion(): + """Test conversion of dict.""" + state = ha.State('domain.hello', 'world', {'some': 'attr'}) + assert state == ha.State.from_dict(state.as_dict()) + + +def test_state_dict_conversion_with_wrong_data(): + """Test conversion with wrong data.""" + assert ha.State.from_dict(None) is None + assert ha.State.from_dict({'state': 'yes'}) is None + assert ha.State.from_dict({'entity_id': 'yes'}) is None + # Make sure invalid context data doesn't crash + wrong_context = ha.State.from_dict({ + 'entity_id': 'light.kitchen', + 'state': 'on', + 'context': { + 'id': '123', + 'non-existing': 'crash' + } + }) + assert wrong_context is not None + assert wrong_context.context.id == '123' + + +def test_state_repr(): + """Test state.repr.""" + assert "" == \ + str(ha.State( + "happy.happy", "on", + last_changed=datetime(1984, 12, 8, 12, 0, 0))) + + assert "" == \ + str(ha.State("happy.happy", "on", {"brightness": 144}, + datetime(1984, 12, 8, 12, 0, 0))) class TestStateMachine(unittest.TestCase): From 7dd3fc7ca7b497259d8748901f26db2f6026ed14 Mon Sep 17 00:00:00 2001 From: Paulus Schoutsen Date: Wed, 20 Feb 2019 08:58:37 -0800 Subject: [PATCH 43/45] Bumped version to 0.88.0 --- homeassistant/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/const.py b/homeassistant/const.py index 1924d145529..9dbb06e8adf 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -2,7 +2,7 @@ """Constants used by Home Assistant components.""" MAJOR_VERSION = 0 MINOR_VERSION = 88 -PATCH_VERSION = '0b4' +PATCH_VERSION = '0' __short_version__ = '{}.{}'.format(MAJOR_VERSION, MINOR_VERSION) __version__ = '{}.{}'.format(__short_version__, PATCH_VERSION) REQUIRED_PYTHON_VER = (3, 5, 3) From 9f99e173def241fbc179393feff050ba21265fbc Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Wed, 20 Feb 2019 10:27:03 -0500 Subject: [PATCH 44/45] Don't dispatch to components when there are no channels for ZHA sensors (#21223) * don't dispatch when channels don't exist * review comment --- homeassistant/components/zha/core/gateway.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index 4fbf96a22b6..cd549afc819 100644 --- a/homeassistant/components/zha/core/gateway.py +++ b/homeassistant/components/zha/core/gateway.py @@ -253,6 +253,10 @@ async def _create_cluster_channel(cluster, zha_device, is_new_join, async def _dispatch_discovery_info(hass, is_new_join, discovery_info): """Dispatch or store discovery information.""" + if not discovery_info['channels']: + _LOGGER.warning( + "there are no channels in the discovery info: %s", discovery_info) + return component = discovery_info['component'] if is_new_join: async_dispatcher_send( From 67008e094788ed6f6248753230d6451f9b2f40dc Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Wed, 20 Feb 2019 10:33:29 -0500 Subject: [PATCH 45/45] Fix bug in ZHA and tweak non sensor channel logic (#21234) * fix race condition and prevent profiles from stealing channels * fix battery voltage --- .../components/zha/core/channels/general.py | 2 +- homeassistant/components/zha/core/device.py | 10 +++++++++ homeassistant/components/zha/core/gateway.py | 21 +++++++++++-------- 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index bc015ae47f0..a29b23d340b 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -199,4 +199,4 @@ class PowerConfigurationChannel(ZigbeeChannel): await self.get_attribute_value( 'battery_percentage_remaining', from_cache=from_cache) await self.get_attribute_value( - 'active_power', from_cache=from_cache) + 'battery_voltage', from_cache=from_cache) diff --git a/homeassistant/components/zha/core/device.py b/homeassistant/components/zha/core/device.py index 3a012ed7895..12bb397fbc3 100644 --- a/homeassistant/components/zha/core/device.py +++ b/homeassistant/components/zha/core/device.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 +from enum import Enum import logging from homeassistant.helpers.dispatcher import ( @@ -23,6 +24,13 @@ from .channels.general import BasicChannel _LOGGER = logging.getLogger(__name__) +class DeviceStatus(Enum): + """Status of a device.""" + + CREATED = 1 + INITIALIZED = 2 + + class ZHADevice: """ZHA Zigbee device object.""" @@ -61,6 +69,7 @@ class ZHADevice: self._zigpy_device.__class__.__name__ ) self.power_source = None + self.status = DeviceStatus.CREATED @property def name(self): @@ -186,6 +195,7 @@ class ZHADevice: self.name, BasicChannel.POWER_SOURCES.get(self.power_source) ) + self.status = DeviceStatus.INITIALIZED _LOGGER.debug('%s: completed initialization', self.name) async def _execute_channel_tasks(self, task_name, *args): diff --git a/homeassistant/components/zha/core/gateway.py b/homeassistant/components/zha/core/gateway.py index cd549afc819..a50bfeae1be 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) -from .device import ZHADevice +from .device import ZHADevice, DeviceStatus from ..device_entity import ZhaDeviceEntity from .channels import ( AttributeListeningChannel, EventRelayChannel, ZDOChannel @@ -139,7 +139,9 @@ class ZHAGateway: """Update device that has just become available.""" if sender.ieee in self.devices: device = self.devices[sender.ieee] - device.update_available(True) + # avoid a race condition during new joins + if device.status is DeviceStatus.INITIALIZED: + device.update_available(True) async def async_device_initialized(self, device, is_new_join): """Handle device joined and basic information discovered (async).""" @@ -323,6 +325,14 @@ async def _handle_single_cluster_matches(hass, endpoint, zha_device, cluster_match_tasks = [] event_channel_tasks = [] for cluster in endpoint.in_clusters.values(): + # don't let profiles prevent these channels from being created + if cluster.cluster_id in NO_SENSOR_CLUSTERS: + cluster_match_tasks.append(_handle_channel_only_cluster_match( + zha_device, + cluster, + is_new_join, + )) + if cluster.cluster_id not in profile_clusters[0]: cluster_match_tasks.append(_handle_single_cluster_match( hass, @@ -333,13 +343,6 @@ async def _handle_single_cluster_matches(hass, endpoint, zha_device, is_new_join, )) - if cluster.cluster_id in NO_SENSOR_CLUSTERS: - cluster_match_tasks.append(_handle_channel_only_cluster_match( - zha_device, - cluster, - is_new_join, - )) - for cluster in endpoint.out_clusters.values(): if cluster.cluster_id not in profile_clusters[1]: cluster_match_tasks.append(_handle_single_cluster_match(