From 4692605974cbc1d4189fba80c28cadc0cc594539 Mon Sep 17 00:00:00 2001 From: Alexei Chetroi Date: Wed, 19 Dec 2018 08:52:20 -0500 Subject: [PATCH] ZHA entity ZCL reporting configuration (#19177) * Implement async_configure() method for ZHA entities. Allow attribute reporting configuration to be stored as dict of zha entity. * Update ZHA platform to use new attribute reporting configuration. * Use const declaration instead of magic numbers. * Add support for manufacturer_id in ZCL attribute reporting configuration. * Refactor async_configure() method. Rename attribute reporting dict to zcl_reporting_config. --- homeassistant/components/binary_sensor/zha.py | 31 ++++--- homeassistant/components/fan/zha.py | 31 ++++++- homeassistant/components/light/zha.py | 21 ++++- homeassistant/components/sensor/zha.py | 36 ++++++--- homeassistant/components/switch/zha.py | 22 +++-- homeassistant/components/zha/const.py | 21 +++++ .../components/zha/entities/entity.py | 81 +++++++++++++++++++ homeassistant/components/zha/helpers.py | 13 ++- 8 files changed, 214 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/binary_sensor/zha.py b/homeassistant/components/binary_sensor/zha.py index aa1f4eb2f86..065f29b0b3f 100644 --- a/homeassistant/components/binary_sensor/zha.py +++ b/homeassistant/components/binary_sensor/zha.py @@ -9,7 +9,7 @@ import logging from homeassistant.components.binary_sensor import DOMAIN, BinarySensorDevice from homeassistant.components.zha import helpers from homeassistant.components.zha.const import ( - DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW) + DATA_ZHA, DATA_ZHA_DISPATCHERS, REPORT_CONFIG_IMMEDIATE, ZHA_DISCOVERY_NEW) from homeassistant.components.zha.entities import ZhaEntity from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -89,21 +89,7 @@ async def _async_setup_remote(discovery_info): remote = Remote(**discovery_info) if discovery_info['new_join']: - from zigpy.zcl.clusters.general import OnOff, LevelControl - out_clusters = discovery_info['out_clusters'] - if OnOff.cluster_id in out_clusters: - cluster = out_clusters[OnOff.cluster_id] - await helpers.configure_reporting( - remote.entity_id, cluster, 0, min_report=0, max_report=600, - reportable_change=1 - ) - if LevelControl.cluster_id in out_clusters: - cluster = out_clusters[LevelControl.cluster_id] - await helpers.configure_reporting( - remote.entity_id, cluster, 0, min_report=1, max_report=600, - reportable_change=1 - ) - + await remote.async_configure() return remote @@ -238,6 +224,14 @@ class Remote(ZhaEntity, BinarySensorDevice): general.OnOff.cluster_id: self.OnOffListener(self), general.LevelControl.cluster_id: self.LevelListener(self), } + out_clusters = kwargs.get('out_clusters') + self._zcl_reporting = {} + for cluster_id in [general.OnOff.cluster_id, + general.LevelControl.cluster_id]: + if cluster_id not in out_clusters: + continue + cluster = out_clusters[cluster_id] + self._zcl_reporting[cluster] = {0: REPORT_CONFIG_IMMEDIATE} @property def should_poll(self) -> bool: @@ -257,6 +251,11 @@ class Remote(ZhaEntity, BinarySensorDevice): }) return self._device_state_attributes + @property + def zcl_reporting_config(self): + """Return ZCL attribute reporting configuration.""" + return self._zcl_reporting + def move_level(self, change): """Increment the level, setting state if appropriate.""" if not self._state and change > 0: diff --git a/homeassistant/components/fan/zha.py b/homeassistant/components/fan/zha.py index d1731e89894..4e0c303b767 100644 --- a/homeassistant/components/fan/zha.py +++ b/homeassistant/components/fan/zha.py @@ -11,7 +11,7 @@ from homeassistant.components.fan import ( FanEntity) from homeassistant.components.zha import helpers from homeassistant.components.zha.const import ( - DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW) + DATA_ZHA, DATA_ZHA_DISPATCHERS, REPORT_CONFIG_OP, ZHA_DISCOVERY_NEW) from homeassistant.components.zha.entities import ZhaEntity from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -70,7 +70,10 @@ async def _async_setup_entities(hass, config_entry, async_add_entities, """Set up the ZHA fans.""" entities = [] for discovery_info in discovery_infos: - entities.append(ZhaFan(**discovery_info)) + fan = ZhaFan(**discovery_info) + if discovery_info['new_join']: + await fan.async_configure() + entities.append(fan) async_add_entities(entities, update_before_add=True) @@ -79,6 +82,19 @@ class ZhaFan(ZhaEntity, FanEntity): """Representation of a ZHA fan.""" _domain = DOMAIN + value_attribute = 0 # fan_mode + + @property + def zcl_reporting_config(self) -> dict: + """Return a dict of attribute reporting configuration.""" + return { + self.cluster: {self.value_attribute: REPORT_CONFIG_OP} + } + + @property + def cluster(self): + """Fan ZCL Cluster.""" + return self._endpoint.fan @property def supported_features(self) -> int: @@ -129,7 +145,7 @@ class ZhaFan(ZhaEntity, FanEntity): async def async_update(self): """Retrieve latest state.""" - result = await helpers.safe_read(self._endpoint.fan, ['fan_mode'], + result = await helpers.safe_read(self.cluster, ['fan_mode'], allow_cache=False, only_cache=(not self._initialized)) new_value = result.get('fan_mode', None) @@ -142,3 +158,12 @@ class ZhaFan(ZhaEntity, FanEntity): False if entity pushes its state to HA. """ return False + + def attribute_updated(self, attribute, value): + """Handle attribute update from device.""" + attr_name = self.cluster.attributes.get(attribute, [attribute])[0] + _LOGGER.debug("%s: Attribute report '%s'[%s] = %s", + self.entity_id, self.cluster.name, attr_name, value) + if attribute == self.value_attribute: + self._state = VALUE_TO_SPEED.get(value, self._state) + self.async_schedule_update_ha_state() diff --git a/homeassistant/components/light/zha.py b/homeassistant/components/light/zha.py index 83448b39d9e..29cd4498949 100644 --- a/homeassistant/components/light/zha.py +++ b/homeassistant/components/light/zha.py @@ -9,7 +9,8 @@ import logging from homeassistant.components import light from homeassistant.components.zha import helpers from homeassistant.components.zha.const import ( - DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW) + DATA_ZHA, DATA_ZHA_DISPATCHERS, REPORT_CONFIG_ASAP, REPORT_CONFIG_DEFAULT, + REPORT_CONFIG_IMMEDIATE, ZHA_DISCOVERY_NEW) from homeassistant.components.zha.entities import ZhaEntity from homeassistant.helpers.dispatcher import async_dispatcher_connect import homeassistant.util.color as color_util @@ -73,7 +74,10 @@ async def _async_setup_entities(hass, config_entry, async_add_entities, UNSUPPORTED_ATTRIBUTE): discovery_info['color_capabilities'] |= \ CAPABILITIES_COLOR_TEMP - entities.append(Light(**discovery_info)) + zha_light = Light(**discovery_info) + if discovery_info['new_join']: + await zha_light.async_configure() + entities.append(zha_light) async_add_entities(entities, update_before_add=True) @@ -105,6 +109,19 @@ class Light(ZhaEntity, light.Light): self._supported_features |= light.SUPPORT_COLOR self._hs_color = (0, 0) + @property + def zcl_reporting_config(self) -> dict: + """Return attribute reporting configuration.""" + return { + 'on_off': {'on_off': REPORT_CONFIG_IMMEDIATE}, + 'level': {'current_level': REPORT_CONFIG_ASAP}, + 'light_color': { + 'current_x': REPORT_CONFIG_DEFAULT, + 'current_y': REPORT_CONFIG_DEFAULT, + 'color_temperature': REPORT_CONFIG_DEFAULT, + } + } + @property def is_on(self) -> bool: """Return true if entity is on.""" diff --git a/homeassistant/components/sensor/zha.py b/homeassistant/components/sensor/zha.py index 80aad9ac937..f01aa3e9ddf 100644 --- a/homeassistant/components/sensor/zha.py +++ b/homeassistant/components/sensor/zha.py @@ -9,7 +9,8 @@ import logging from homeassistant.components.sensor import DOMAIN from homeassistant.components.zha import helpers from homeassistant.components.zha.const import ( - DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW) + DATA_ZHA, DATA_ZHA_DISPATCHERS, REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_MIN_INT, REPORT_CONFIG_RPT_CHANGE, ZHA_DISCOVERY_NEW) from homeassistant.components.zha.entities import ZhaEntity from homeassistant.const import TEMP_CELSIUS from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -81,11 +82,7 @@ async def make_sensor(discovery_info): sensor = Sensor(**discovery_info) if discovery_info['new_join']: - cluster = list(in_clusters.values())[0] - await helpers.configure_reporting( - sensor.entity_id, cluster, sensor.value_attribute, - reportable_change=sensor.min_reportable_change - ) + await sensor.async_configure() return sensor @@ -95,7 +92,28 @@ class Sensor(ZhaEntity): _domain = DOMAIN value_attribute = 0 - min_reportable_change = 1 + min_report_interval = REPORT_CONFIG_MIN_INT + max_report_interval = REPORT_CONFIG_MAX_INT + min_reportable_change = REPORT_CONFIG_RPT_CHANGE + report_config = (min_report_interval, max_report_interval, + min_reportable_change) + + def __init__(self, **kwargs): + """Init ZHA Sensor instance.""" + super().__init__(**kwargs) + self._cluster = list(kwargs['in_clusters'].values())[0] + + @property + def zcl_reporting_config(self) -> dict: + """Return a dict of attribute reporting configuration.""" + return { + self.cluster: {self.value_attribute: self.report_config} + } + + @property + def cluster(self): + """Return Sensor's cluster.""" + return self._cluster @property def should_poll(self) -> bool: @@ -119,7 +137,7 @@ class Sensor(ZhaEntity): async def async_update(self): """Retrieve latest state.""" result = await helpers.safe_read( - list(self._in_clusters.values())[0], + self.cluster, [self.value_attribute], allow_cache=False, only_cache=(not self._initialized) @@ -251,6 +269,6 @@ class ElectricalMeasurementSensor(Sensor): _LOGGER.debug("%s async_update", self.entity_id) result = await helpers.safe_read( - self._endpoint.electrical_measurement, ['active_power'], + self.cluster, ['active_power'], allow_cache=False, only_cache=(not self._initialized)) self._state = result.get('active_power', self._state) diff --git a/homeassistant/components/switch/zha.py b/homeassistant/components/switch/zha.py index 4dac3bfbb22..70e3e055ace 100644 --- a/homeassistant/components/switch/zha.py +++ b/homeassistant/components/switch/zha.py @@ -9,7 +9,7 @@ import logging from homeassistant.components.switch import DOMAIN, SwitchDevice from homeassistant.components.zha import helpers from homeassistant.components.zha.const import ( - DATA_ZHA, DATA_ZHA_DISPATCHERS, ZHA_DISCOVERY_NEW) + DATA_ZHA, DATA_ZHA_DISPATCHERS, REPORT_CONFIG_IMMEDIATE, ZHA_DISCOVERY_NEW) from homeassistant.components.zha.entities import ZhaEntity from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -44,17 +44,11 @@ async def async_setup_entry(hass, config_entry, async_add_entities): async def _async_setup_entities(hass, config_entry, async_add_entities, discovery_infos): """Set up the ZHA switches.""" - from zigpy.zcl.clusters.general import OnOff entities = [] for discovery_info in discovery_infos: switch = Switch(**discovery_info) if discovery_info['new_join']: - in_clusters = discovery_info['in_clusters'] - cluster = in_clusters[OnOff.cluster_id] - await helpers.configure_reporting( - switch.entity_id, cluster, switch.value_attribute, - min_report=0, max_report=600, reportable_change=1 - ) + await switch.async_configure() entities.append(switch) async_add_entities(entities, update_before_add=True) @@ -76,6 +70,18 @@ class Switch(ZhaEntity, SwitchDevice): self._state = value self.async_schedule_update_ha_state() + @property + def zcl_reporting_config(self) -> dict: + """Retrun a dict of attribute reporting configuration.""" + return { + self.cluster: {'on_off': REPORT_CONFIG_IMMEDIATE} + } + + @property + def cluster(self): + """Entity's cluster.""" + return self._endpoint.on_off + @property def should_poll(self) -> bool: """Let zha handle polling.""" diff --git a/homeassistant/components/zha/const.py b/homeassistant/components/zha/const.py index a148eccf53f..0ee20be3901 100644 --- a/homeassistant/components/zha/const.py +++ b/homeassistant/components/zha/const.py @@ -57,6 +57,27 @@ CUSTOM_CLUSTER_MAPPINGS = {} COMPONENT_CLUSTERS = {} EVENTABLE_CLUSTERS = [] +REPORT_CONFIG_MAX_INT = 900 +REPORT_CONFIG_MAX_INT_BATTERY_SAVE = 10800 +REPORT_CONFIG_MIN_INT = 30 +REPORT_CONFIG_MIN_INT_ASAP = 1 +REPORT_CONFIG_MIN_INT_IMMEDIATE = 0 +REPORT_CONFIG_MIN_INT_OP = 5 +REPORT_CONFIG_MIN_INT_BATTERY_SAVE = 3600 +REPORT_CONFIG_RPT_CHANGE = 1 +REPORT_CONFIG_DEFAULT = (REPORT_CONFIG_MIN_INT, REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_RPT_CHANGE) +REPORT_CONFIG_ASAP = (REPORT_CONFIG_MIN_INT_ASAP, REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_RPT_CHANGE) +REPORT_CONFIG_BATTERY_SAVE = (REPORT_CONFIG_MIN_INT_BATTERY_SAVE, + REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_RPT_CHANGE) +REPORT_CONFIG_IMMEDIATE = (REPORT_CONFIG_MIN_INT_IMMEDIATE, + REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_RPT_CHANGE) +REPORT_CONFIG_OP = (REPORT_CONFIG_MIN_INT_OP, REPORT_CONFIG_MAX_INT, + REPORT_CONFIG_RPT_CHANGE) + def populate_data(): """Populate data using constants from bellows. diff --git a/homeassistant/components/zha/entities/entity.py b/homeassistant/components/zha/entities/entity.py index 0d99324f78b..563433c5ff2 100644 --- a/homeassistant/components/zha/entities/entity.py +++ b/homeassistant/components/zha/entities/entity.py @@ -4,13 +4,20 @@ Entity for Zigbee Home Automation. For more details about this component, please refer to the documentation at https://home-assistant.io/components/zha/ """ +from asyncio import sleep +import logging +from random import uniform + from homeassistant.components.zha.const import ( DATA_ZHA, DATA_ZHA_BRIDGE_ID, DOMAIN) +from homeassistant.components.zha.helpers import configure_reporting from homeassistant.core import callback from homeassistant.helpers import entity from homeassistant.helpers.device_registry import CONNECTION_ZIGBEE from homeassistant.util import slugify +_LOGGER = logging.getLogger(__name__) + class ZhaEntity(entity.Entity): """A base class for ZHA entities.""" @@ -57,6 +64,7 @@ class ZhaEntity(entity.Entity): self._out_listeners = {} self._initialized = False + self.manufacturer_code = None application_listener.register_entity(ieee, self) async def async_added_to_hass(self): @@ -71,6 +79,79 @@ class ZhaEntity(entity.Entity): self._initialized = True + async def async_configure(self): + """Set cluster binding and attribute reporting.""" + for cluster_key, attrs in self.zcl_reporting_config.items(): + cluster = self._get_cluster_from_report_config(cluster_key) + if cluster is None: + continue + + manufacturer = None + if cluster.cluster_id >= 0xfc00 and self.manufacturer_code: + manufacturer = self.manufacturer_code + + skip_bind = False # bind cluster only for the 1st configured attr + for attr, details in attrs.items(): + min_report_interval, max_report_interval, change = details + await configure_reporting( + self.entity_id, 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 sleep(uniform(0.1, 0.5)) + _LOGGER.debug("%s: finished configuration", self.entity_id) + + def _get_cluster_from_report_config(self, cluster_key): + """Parse an entry from zcl_reporting_config dict.""" + from zigpy.zcl import Cluster as Zcl_Cluster + + cluster = None + if isinstance(cluster_key, Zcl_Cluster): + cluster = cluster_key + elif isinstance(cluster_key, str): + cluster = getattr(self._endpoint, cluster_key, None) + elif isinstance(cluster_key, int): + if cluster_key in self._in_clusters: + cluster = self._in_clusters[cluster_key] + elif cluster_key in self._out_clusters: + cluster = self._out_clusters[cluster_key] + elif issubclass(cluster_key, Zcl_Cluster): + cluster_id = cluster_key.cluster_id + if cluster_id in self._in_clusters: + cluster = self._in_clusters[cluster_id] + elif cluster_id in self._out_clusters: + cluster = self._out_clusters[cluster_id] + return cluster + + @property + def zcl_reporting_config(self): + """Return a dict of ZCL attribute reporting configuration. + + { + Cluster_Class: { + attr_id: (min_report_interval, max_report_interval, change), + attr_name: (min_rep_interval, max_rep_interval, change) + } + Cluster_Instance: { + attr_id: (min_report_interval, max_report_interval, change), + attr_name: (min_rep_interval, max_rep_interval, change) + } + cluster_id: { + attr_id: (min_report_interval, max_report_interval, change), + attr_name: (min_rep_interval, max_rep_interval, change) + } + 'cluster_name': { + attr_id: (min_report_interval, max_report_interval, change), + attr_name: (min_rep_interval, max_rep_interval, change) + } + } + """ + return dict() + @property def unique_id(self) -> str: """Return a unique ID.""" diff --git a/homeassistant/components/zha/helpers.py b/homeassistant/components/zha/helpers.py index 7ae6fbf2d22..7333cb92254 100644 --- a/homeassistant/components/zha/helpers.py +++ b/homeassistant/components/zha/helpers.py @@ -7,7 +7,9 @@ https://home-assistant.io/components/zha/ import asyncio import logging -from .const import DEFAULT_BAUDRATE, RadioType +from .const import ( + DEFAULT_BAUDRATE, REPORT_CONFIG_MAX_INT, REPORT_CONFIG_MIN_INT, + REPORT_CONFIG_RPT_CHANGE, RadioType) _LOGGER = logging.getLogger(__name__) @@ -31,8 +33,10 @@ async def safe_read(cluster, attributes, allow_cache=True, only_cache=False): async def configure_reporting(entity_id, cluster, attr, skip_bind=False, - min_report=300, max_report=900, - reportable_change=1): + min_report=REPORT_CONFIG_MIN_INT, + max_report=REPORT_CONFIG_MAX_INT, + reportable_change=REPORT_CONFIG_RPT_CHANGE, + manufacturer=None): """Configure attribute reporting for a cluster. while swallowing the DeliverError exceptions in case of unreachable @@ -56,7 +60,8 @@ async def configure_reporting(entity_id, cluster, attr, skip_bind=False, try: res = await cluster.configure_reporting(attr, min_report, - max_report, reportable_change) + max_report, reportable_change, + manufacturer=manufacturer) _LOGGER.debug( "%s: reporting '%s' attr on '%s' cluster: %d/%d/%d: Result: '%s'", entity_id, attr_name, cluster_name, min_report, max_report,