From e52542c4d7a33ad051130e5ebddb4178fd570e54 Mon Sep 17 00:00:00 2001 From: "David F. Mulcahey" Date: Sat, 7 Mar 2020 07:33:59 -0500 Subject: [PATCH] Allow multiple attribute reads in ZHA (#32498) * multi attribute reads for lights * catch specific exceptions * get attributes * fix mains powered update * add guards and use get_attributes * use debug for read failures * cleanup * update return value for read_attributes * fix on with timed off --- homeassistant/components/zha/binary_sensor.py | 4 +- .../components/zha/core/channels/__init__.py | 10 ++++ .../components/zha/core/channels/base.py | 33 ++++++++++-- .../components/zha/core/channels/closures.py | 21 ++++---- .../components/zha/core/channels/general.py | 37 +++++++------- .../zha/core/channels/homeautomation.py | 14 +++-- .../components/zha/core/channels/hvac.py | 7 +-- .../components/zha/core/channels/lighting.py | 9 ++-- .../components/zha/core/channels/security.py | 4 +- homeassistant/components/zha/core/helpers.py | 12 ----- homeassistant/components/zha/light.py | 51 ++++++++++++------- homeassistant/components/zha/sensor.py | 6 ++- homeassistant/components/zha/switch.py | 4 +- tests/components/zha/common.py | 2 +- 14 files changed, 136 insertions(+), 78 deletions(-) diff --git a/homeassistant/components/zha/binary_sensor.py b/homeassistant/components/zha/binary_sensor.py index def1588a127..5bcb0878a1a 100644 --- a/homeassistant/components/zha/binary_sensor.py +++ b/homeassistant/components/zha/binary_sensor.py @@ -112,7 +112,9 @@ class BinarySensor(ZhaEntity, BinarySensorDevice): """Attempt to retrieve on off state from the binary sensor.""" await super().async_update() attribute = getattr(self._channel, "value_attribute", "on_off") - self._state = await self._channel.get_attribute_value(attribute) + attr_value = await self._channel.get_attribute_value(attribute) + if attr_value is not None: + self._state = attr_value @STRICT_MATCH(channel_names=CHANNEL_ACCELEROMETER) diff --git a/homeassistant/components/zha/core/channels/__init__.py b/homeassistant/components/zha/core/channels/__init__.py index 715bc3e3e75..a4848fbaa63 100644 --- a/homeassistant/components/zha/core/channels/__init__.py +++ b/homeassistant/components/zha/core/channels/__init__.py @@ -191,6 +191,11 @@ class ChannelPool: """Device NWK for logging.""" return self._channels.zha_device.nwk + @property + def is_mains_powered(self) -> bool: + """Device is_mains_powered.""" + return self._channels.zha_device.is_mains_powered + @property def manufacturer(self) -> Optional[str]: """Return device manufacturer.""" @@ -201,6 +206,11 @@ class ChannelPool: """Return device manufacturer.""" return self._channels.zha_device.manufacturer_code + @property + def hass(self): + """Return hass.""" + return self._channels.zha_device.hass + @property def model(self) -> Optional[str]: """Return device model.""" diff --git a/homeassistant/components/zha/core/channels/base.py b/homeassistant/components/zha/core/channels/base.py index d94c01fe4cd..dca0bbe09f3 100644 --- a/homeassistant/components/zha/core/channels/base.py +++ b/homeassistant/components/zha/core/channels/base.py @@ -26,7 +26,7 @@ from ..const import ( REPORT_CONFIG_RPT_CHANGE, SIGNAL_ATTR_UPDATED, ) -from ..helpers import LogMixin, get_attr_id_by_name, safe_read +from ..helpers import LogMixin, safe_read _LOGGER = logging.getLogger(__name__) @@ -98,7 +98,7 @@ class ZigbeeChannel(LogMixin): if not hasattr(self, "_value_attribute") and len(self._report_config) > 0: attr = self._report_config[0].get("attr") if isinstance(attr, str): - self.value_attribute = get_attr_id_by_name(self.cluster, attr) + self.value_attribute = self.cluster.attridx.get(attr) else: self.value_attribute = attr self._status = ChannelStatus.CREATED @@ -212,8 +212,11 @@ class ZigbeeChannel(LogMixin): async def async_initialize(self, from_cache): """Initialize channel.""" self.debug("initializing channel: from_cache: %s", from_cache) + attributes = [] for report_config in self._report_config: - await self.get_attribute_value(report_config["attr"], from_cache=from_cache) + attributes.append(report_config["attr"]) + if len(attributes) > 0: + await self.get_attributes(attributes, from_cache=from_cache) self._status = ChannelStatus.INITIALIZED @callback @@ -267,6 +270,30 @@ class ZigbeeChannel(LogMixin): ) return result.get(attribute) + async def get_attributes(self, attributes, from_cache=True): + """Get the values for a list of attributes.""" + manufacturer = None + manufacturer_code = self._ch_pool.manufacturer_code + if self.cluster.cluster_id >= 0xFC00 and manufacturer_code: + manufacturer = manufacturer_code + try: + result, _ = await self.cluster.read_attributes( + attributes, + allow_cache=from_cache, + only_cache=from_cache, + manufacturer=manufacturer, + ) + results = {attribute: result.get(attribute) for attribute in attributes} + except (asyncio.TimeoutError, zigpy.exceptions.DeliveryError) as ex: + self.debug( + "failed to get attributes '%s' on '%s' cluster: %s", + attributes, + self.cluster.ep_attribute, + str(ex), + ) + results = {} + return results + def log(self, level, msg, *args): """Log a message.""" msg = f"[%s:%s]: {msg}" diff --git a/homeassistant/components/zha/core/channels/closures.py b/homeassistant/components/zha/core/channels/closures.py index af6306c45e3..2b6c06ba12a 100644 --- a/homeassistant/components/zha/core/channels/closures.py +++ b/homeassistant/components/zha/core/channels/closures.py @@ -22,10 +22,10 @@ class DoorLockChannel(ZigbeeChannel): async def async_update(self): """Retrieve latest state.""" result = await self.get_attribute_value("lock_state", from_cache=True) - - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", 0, "lock_state", result - ) + if result is not None: + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", 0, "lock_state", result + ) @callback def attribute_updated(self, attrid, value): @@ -67,12 +67,13 @@ class WindowCovering(ZigbeeChannel): "current_position_lift_percentage", from_cache=False ) self.debug("read current position: %s", result) - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", - 8, - "current_position_lift_percentage", - result, - ) + if result is not None: + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + 8, + "current_position_lift_percentage", + result, + ) @callback def attribute_updated(self, attrid, value): diff --git a/homeassistant/components/zha/core/channels/general.py b/homeassistant/components/zha/core/channels/general.py index 3af8192dee1..d51c03b33c9 100644 --- a/homeassistant/components/zha/core/channels/general.py +++ b/homeassistant/components/zha/core/channels/general.py @@ -17,7 +17,6 @@ from ..const import ( SIGNAL_SET_LEVEL, SIGNAL_STATE_ATTR, ) -from ..helpers import get_attr_id_by_name from .base import ZigbeeChannel, parse_and_log_command _LOGGER = logging.getLogger(__name__) @@ -90,9 +89,11 @@ class BasicChannel(ZigbeeChannel): async def async_initialize(self, from_cache): """Initialize channel.""" - self._power_source = await self.get_attribute_value( + power_source = await self.get_attribute_value( "power_source", from_cache=from_cache ) + if power_source is not None: + self._power_source = power_source await super().async_initialize(from_cache) def get_power_source(self): @@ -269,7 +270,7 @@ class OnOffChannel(ZigbeeChannel): self.attribute_updated(self.ON_OFF, True) if on_time > 0: self._off_listener = async_call_later( - self.device.hass, + self._ch_pool.hass, (on_time / 10), # value is in 10ths of a second self.set_to_off, ) @@ -293,19 +294,20 @@ class OnOffChannel(ZigbeeChannel): async def async_initialize(self, from_cache): """Initialize channel.""" - self._state = bool( - await self.get_attribute_value(self.ON_OFF, from_cache=from_cache) - ) + state = await self.get_attribute_value(self.ON_OFF, from_cache=from_cache) + if state is not None: + self._state = bool(state) await super().async_initialize(from_cache) async def async_update(self): """Initialize channel.""" if self.cluster.is_client: return - self.debug("attempting to update onoff state - from cache: False") - self._state = bool( - await self.get_attribute_value(self.ON_OFF, from_cache=False) - ) + from_cache = not self._ch_pool.is_mains_powered + self.debug("attempting to update onoff state - from cache: %s", from_cache) + state = await self.get_attribute_value(self.ON_OFF, from_cache=from_cache) + if state is not None: + self._state = bool(state) await super().async_update() @@ -352,7 +354,7 @@ class PowerConfigurationChannel(ZigbeeChannel): """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) + attr_id = self.cluster.attridx.get(attr) else: attr_id = attr if attrid == attr_id: @@ -379,12 +381,13 @@ class PowerConfigurationChannel(ZigbeeChannel): 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("battery_voltage", from_cache=from_cache) - await self.get_attribute_value("battery_quantity", from_cache=from_cache) + attributes = [ + "battery_size", + "battery_percentage_remaining", + "battery_voltage", + "battery_quantity", + ] + await self.get_attributes(attributes, from_cache=from_cache) @registries.ZIGBEE_CHANNEL_REGISTRY.register(general.PowerProfile.cluster_id) diff --git a/homeassistant/components/zha/core/channels/homeautomation.py b/homeassistant/components/zha/core/channels/homeautomation.py index d4c1a1b7422..1df7cf117e2 100644 --- a/homeassistant/components/zha/core/channels/homeautomation.py +++ b/homeassistant/components/zha/core/channels/homeautomation.py @@ -73,9 +73,13 @@ class ElectricalMeasurementChannel(ZigbeeChannel): # This is a polling channel. Don't allow cache. result = await self.get_attribute_value("active_power", from_cache=False) - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", 0x050B, "active_power", result - ) + if result is not None: + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", + 0x050B, + "active_power", + result, + ) async def async_initialize(self, from_cache): """Initialize channel.""" @@ -92,6 +96,8 @@ class ElectricalMeasurementChannel(ZigbeeChannel): divisor = await self.get_attribute_value( "power_divisor", from_cache=from_cache ) + if divisor is None: + divisor = 1 self._divisor = divisor mult = await self.get_attribute_value( @@ -101,6 +107,8 @@ class ElectricalMeasurementChannel(ZigbeeChannel): mult = await self.get_attribute_value( "power_multiplier", from_cache=from_cache ) + if mult is None: + mult = 1 self._multiplier = mult @property diff --git a/homeassistant/components/zha/core/channels/hvac.py b/homeassistant/components/zha/core/channels/hvac.py index 6d5ce4beb29..3c00e186ebb 100644 --- a/homeassistant/components/zha/core/channels/hvac.py +++ b/homeassistant/components/zha/core/channels/hvac.py @@ -40,9 +40,10 @@ class FanChannel(ZigbeeChannel): async def async_update(self): """Retrieve latest state.""" result = await self.get_attribute_value("fan_mode", from_cache=True) - self.async_send_signal( - f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", 0, "fan_mode", result - ) + if result is not None: + self.async_send_signal( + f"{self.unique_id}_{SIGNAL_ATTR_UPDATED}", 0, "fan_mode", result + ) @callback def attribute_updated(self, attrid, value): diff --git a/homeassistant/components/zha/core/channels/lighting.py b/homeassistant/components/zha/core/channels/lighting.py index c87235d9ec0..7dc98d04515 100644 --- a/homeassistant/components/zha/core/channels/lighting.py +++ b/homeassistant/components/zha/core/channels/lighting.py @@ -34,7 +34,7 @@ class ColorChannel(ZigbeeChannel): ) def __init__( - self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType, + self, cluster: zha_typing.ZigpyClusterType, ch_pool: zha_typing.ChannelPoolType ) -> None: """Initialize ColorChannel.""" super().__init__(cluster, ch_pool) @@ -52,9 +52,8 @@ class ColorChannel(ZigbeeChannel): async def async_initialize(self, from_cache): """Initialize channel.""" await self.fetch_color_capabilities(True) - await self.get_attribute_value("color_temperature", from_cache=from_cache) - await self.get_attribute_value("current_x", from_cache=from_cache) - await self.get_attribute_value("current_y", from_cache=from_cache) + attributes = ["color_temperature", "current_x", "current_y"] + await self.get_attributes(attributes, from_cache=from_cache) async def fetch_color_capabilities(self, from_cache): """Get the color configuration.""" @@ -72,7 +71,7 @@ class ColorChannel(ZigbeeChannel): "color_temperature", from_cache=from_cache ) - if result is not self.UNSUPPORTED_ATTRIBUTE: + if result is not None and 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/security.py b/homeassistant/components/zha/core/channels/security.py index cd826792790..2616161de03 100644 --- a/homeassistant/components/zha/core/channels/security.py +++ b/homeassistant/components/zha/core/channels/security.py @@ -176,6 +176,6 @@ class IASZoneChannel(ZigbeeChannel): 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) + attributes = ["zone_status", "zone_state"] + await self.get_attributes(attributes, from_cache=from_cache) await super().async_initialize(from_cache) diff --git a/homeassistant/components/zha/core/helpers.py b/homeassistant/components/zha/core/helpers.py index c0008b055db..ab4c7ae540c 100644 --- a/homeassistant/components/zha/core/helpers.py +++ b/homeassistant/components/zha/core/helpers.py @@ -35,18 +35,6 @@ async def safe_read( return {} -def get_attr_id_by_name(cluster, attr_name): - """Get the attribute id for a cluster attribute by its name.""" - return next( - ( - attrid - for attrid, (attrname, datatype) in cluster.attributes.items() - if attr_name == attrname - ), - None, - ) - - async def get_matched_clusters(source_zha_device, target_zha_device): """Get matched input/output cluster pairs for 2 devices.""" source_clusters = source_zha_device.async_get_std_clusters() diff --git a/homeassistant/components/zha/light.py b/homeassistant/components/zha/light.py index 634bf50001e..18df380780d 100644 --- a/homeassistant/components/zha/light.py +++ b/homeassistant/components/zha/light.py @@ -329,44 +329,59 @@ class Light(ZhaEntity, light.Light): """Attempt to retrieve on off state from the light.""" self.debug("polling current state") if self._on_off_channel: - self._state = await self._on_off_channel.get_attribute_value( + state = await self._on_off_channel.get_attribute_value( "on_off", from_cache=from_cache ) + if state is not None: + self._state = state if self._level_channel: - self._brightness = await self._level_channel.get_attribute_value( + level = await self._level_channel.get_attribute_value( "current_level", from_cache=from_cache ) + if level is not None: + self._brightness = level if self._color_channel: + attributes = [] color_capabilities = self._color_channel.get_color_capabilities() if ( color_capabilities is not None and color_capabilities & CAPABILITIES_COLOR_TEMP ): - self._color_temp = await self._color_channel.get_attribute_value( - "color_temperature", from_cache=from_cache - ) + attributes.append("color_temperature") if ( color_capabilities is not None and color_capabilities & CAPABILITIES_COLOR_XY ): - color_x = await self._color_channel.get_attribute_value( - "current_x", from_cache=from_cache - ) - color_y = await self._color_channel.get_attribute_value( - "current_y", from_cache=from_cache - ) - if color_x is not None and color_y is not None: - self._hs_color = color_util.color_xy_to_hs( - float(color_x / 65535), float(color_y / 65535) - ) + attributes.append("current_x") + attributes.append("current_y") if ( color_capabilities is not None and color_capabilities & CAPABILITIES_COLOR_LOOP ): - color_loop_active = await self._color_channel.get_attribute_value( - "color_loop_active", from_cache=from_cache + attributes.append("color_loop_active") + + results = await self._color_channel.get_attributes( + attributes, from_cache=from_cache + ) + + if ( + "color_temperature" in results + and results["color_temperature"] is not None + ): + self._color_temp = results["color_temperature"] + + color_x = results.get("color_x", None) + color_y = results.get("color_y", None) + if color_x is not None and color_y is not None: + self._hs_color = color_util.color_xy_to_hs( + float(color_x / 65535), float(color_y / 65535) ) - if color_loop_active is not None and color_loop_active == 1: + if ( + "color_loop_active" in results + and results["color_loop_active"] is not None + ): + color_loop_active = results["color_loop_active"] + if color_loop_active == 1: self._effect = light.EFFECT_COLORLOOP async def refresh(self, time): diff --git a/homeassistant/components/zha/sensor.py b/homeassistant/components/zha/sensor.py index 01298a40fca..9f18913c45a 100644 --- a/homeassistant/components/zha/sensor.py +++ b/homeassistant/components/zha/sensor.py @@ -176,10 +176,12 @@ class Battery(Sensor): async def async_state_attr_provider(self): """Return device state attrs for battery sensors.""" state_attrs = {} - battery_size = await self._channel.get_attribute_value("battery_size") + attributes = ["battery_size", "battery_quantity"] + results = await self._channel.get_attributes(attributes) + battery_size = results.get("battery_size", None) if battery_size is not None: state_attrs["battery_size"] = BATTERY_SIZES.get(battery_size, "Unknown") - battery_quantity = await self._channel.get_attribute_value("battery_quantity") + battery_quantity = results.get("battery_quantity", None) if battery_quantity is not None: state_attrs["battery_quantity"] = battery_quantity return state_attrs diff --git a/homeassistant/components/zha/switch.py b/homeassistant/components/zha/switch.py index 15e10b50393..298bcb9db77 100644 --- a/homeassistant/components/zha/switch.py +++ b/homeassistant/components/zha/switch.py @@ -97,4 +97,6 @@ class Switch(ZhaEntity, SwitchDevice): """Attempt to retrieve on off state from the switch.""" await super().async_update() if self._on_off_channel: - self._state = await self._on_off_channel.get_attribute_value("on_off") + state = await self._on_off_channel.get_attribute_value("on_off") + if state is not None: + self._state = state diff --git a/tests/components/zha/common.py b/tests/components/zha/common.py index 8e99a51f1f9..c21d05aa364 100644 --- a/tests/components/zha/common.py +++ b/tests/components/zha/common.py @@ -52,7 +52,7 @@ def patch_cluster(cluster): cluster.configure_reporting = CoroutineMock(return_value=[0]) cluster.deserialize = Mock() cluster.handle_cluster_request = Mock() - cluster.read_attributes = CoroutineMock() + cluster.read_attributes = CoroutineMock(return_value=[{}, {}]) cluster.read_attributes_raw = Mock() cluster.unbind = CoroutineMock(return_value=[0]) cluster.write_attributes = CoroutineMock(return_value=[0])