From 4962ff5696eb41cca32a7711c0ece9dcee6d74b7 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Fri, 26 Jul 2024 16:55:29 +0200 Subject: [PATCH 1/9] Update unsupported attribute on configure This allow us to only display available attributes. --- zha/zigbee/cluster_handlers/__init__.py | 47 +++++++++++++++++++++++++ 1 file changed, 47 insertions(+) diff --git a/zha/zigbee/cluster_handlers/__init__.py b/zha/zigbee/cluster_handlers/__init__.py index 3ff76ee1a..a6d41fb4e 100644 --- a/zha/zigbee/cluster_handlers/__init__.py +++ b/zha/zigbee/cluster_handlers/__init__.py @@ -11,11 +11,14 @@ from typing import TYPE_CHECKING, Any, Final, ParamSpec, TypedDict import zigpy.exceptions +import zigpy.types import zigpy.util import zigpy.zcl from zigpy.zcl.foundation import ( CommandSchema, ConfigureReportingResponseRecord, + DiscoverAttributesResponseRecord, + GeneralCommand, Status, ZCLAttributeDef, ) @@ -441,6 +444,10 @@ async def async_configure(self) -> None: if ch_specific_cfg: self.debug("Performing cluster handler specific configuration") await ch_specific_cfg() + + self.debug("Discovering available attributes") + await self.discover_unsupported_attributes() + self.debug("finished cluster handler configuration") else: self.debug("skipping cluster handler configuration") @@ -624,6 +631,46 @@ async def write_attributes_safe( f"Failed to write attribute {name}={value}: {record.status}", ) + async def _discover_attributes_all( + self, + ) -> list[DiscoverAttributesResponseRecord] | None: + discovery_complete = zigpy.types.Bool.false + start_attribute_id = 0 + attribute_info = [] + cluster = self.cluster + while discovery_complete != zigpy.types.Bool.true: + rsp = await cluster.discover_attributes( + start_attribute_id=start_attribute_id, max_attribute_ids=0xFF + ) + assert rsp, "Must have a response to discover request" + + if rsp.command.id == GeneralCommand.Default_Response: + self.debug( + "Ignoring attribute discovery due to unexpected default response" + ) + return None + + attribute_info.extend(rsp.attribute_info) + discovery_complete = rsp.discovery_complete + start_attribute_id = ( + max((info.attrid for info in rsp.attribute_info), default=0) + 1 + ) + return attribute_info + + async def discover_unsupported_attributes(self): + """Discover the list of unsupported attributes from the device.""" + attribute_info = await self._discover_attributes_all() + if attribute_info is None: + return + attr_ids = {info.attrid for info in attribute_info} + + cluster = self.cluster + for attr_id in cluster.attributes: + if attr_id in attr_ids: + cluster.remove_unsupported_attribute(attr_id) + else: + cluster.add_unsupported_attribute(attr_id) + def log(self, level, msg, *args, **kwargs) -> None: """Log a message.""" msg = f"[%s:%s]: {msg}" From 48c5111c3343e7b032889a521656b3cc6334637f Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 30 Jul 2024 00:30:55 +0200 Subject: [PATCH 2/9] Rename to unsupported in log --- zha/zigbee/cluster_handlers/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zha/zigbee/cluster_handlers/__init__.py b/zha/zigbee/cluster_handlers/__init__.py index a6d41fb4e..f13de363f 100644 --- a/zha/zigbee/cluster_handlers/__init__.py +++ b/zha/zigbee/cluster_handlers/__init__.py @@ -445,7 +445,7 @@ async def async_configure(self) -> None: self.debug("Performing cluster handler specific configuration") await ch_specific_cfg() - self.debug("Discovering available attributes") + self.debug("Discovering unsupported attributes") await self.discover_unsupported_attributes() self.debug("finished cluster handler configuration") From 1110a5acea5ac9147926ac10a7fcc0f830fcb92d Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 30 Jul 2024 01:43:39 +0200 Subject: [PATCH 3/9] Mock discover attributes to list all --- tests/common.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/common.py b/tests/common.py index 11a944af9..c0ac85ad2 100644 --- a/tests/common.py +++ b/tests/common.py @@ -44,6 +44,18 @@ async def _read_attribute_raw(attributes: Any, *args: Any, **kwargs: Any) -> Any result.append(zcl_f.ReadAttributeRecord(attr_id, zcl_f.Status.FAILURE)) return (result,) + async def _discover_attributes(*args: Any, **kwargs: Any) -> Any: + schema = zcl_f.GENERAL_COMMANDS[ + zcl_f.GeneralCommand.Discover_Attributes_rsp + ].schema + records = [ + zcl_f.DiscoverAttributesResponseRecord.from_dict( + {"attrid": attr.id, "datatype": 0} + ) + for attr in cluster.attributes.values() + ] + return schema(discovery_complete=t.Bool.true, attribute_info=records) + cluster.bind = AsyncMock(return_value=[0]) cluster.configure_reporting = AsyncMock( return_value=[ @@ -61,6 +73,7 @@ async def _read_attribute_raw(attributes: Any, *args: Any, **kwargs: Any) -> Any cluster._write_attributes = AsyncMock( return_value=[zcl_f.WriteAttributesResponse.deserialize(b"\x00")[0]] ) + cluster.discover_attributes = AsyncMock(side_effect=_discover_attributes) if cluster.cluster_id == 4: cluster.add = AsyncMock(return_value=[0]) if cluster.cluster_id == 0x1000: From 744a031a47694c426b78bd467b63b373b3066a0b Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 30 Jul 2024 05:23:40 +0200 Subject: [PATCH 4/9] Attempt to fix tests --- tests/test_discover.py | 18 ++++++++++++++---- zha/zigbee/cluster_handlers/__init__.py | 10 ++++++---- 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/tests/test_discover.py b/tests/test_discover.py index 7e4a87fb5..a00074f54 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -147,18 +147,28 @@ async def test_devices( if cluster_identify and not zha_dev.skip_configuration: assert cluster_identify.request.mock_calls == [ + mock.call( + True, + zcl_f.GeneralCommand.Discover_Attributes, + zcl_f.GENERAL_COMMANDS[zcl_f.GeneralCommand.Discover_Attributes].schema, + manufacturer=None, + expect_reply=True, + tsn=None, + start_attribute_id=0, + max_attribute_ids=255, + ), mock.call( False, cluster_identify.commands_by_name["trigger_effect"].id, cluster_identify.commands_by_name["trigger_effect"].schema, + manufacturer=None, + expect_reply=True, + tsn=None, effect_id=zigpy.zcl.clusters.general.Identify.EffectIdentifier.Okay, effect_variant=( zigpy.zcl.clusters.general.Identify.EffectVariant.Default ), - expect_reply=True, - manufacturer=None, - tsn=None, - ) + ), ] event_cluster_handlers = { diff --git a/zha/zigbee/cluster_handlers/__init__.py b/zha/zigbee/cluster_handlers/__init__.py index f13de363f..199988444 100644 --- a/zha/zigbee/cluster_handlers/__init__.py +++ b/zha/zigbee/cluster_handlers/__init__.py @@ -15,6 +15,7 @@ import zigpy.util import zigpy.zcl from zigpy.zcl.foundation import ( + GENERAL_COMMANDS, CommandSchema, ConfigureReportingResponseRecord, DiscoverAttributesResponseRecord, @@ -642,11 +643,12 @@ async def _discover_attributes_all( rsp = await cluster.discover_attributes( start_attribute_id=start_attribute_id, max_attribute_ids=0xFF ) - assert rsp, "Must have a response to discover request" - - if rsp.command.id == GeneralCommand.Default_Response: + if not isinstance( + rsp, GENERAL_COMMANDS[GeneralCommand.Discover_Attributes_rsp].schema + ): self.debug( - "Ignoring attribute discovery due to unexpected default response" + "Ignoring attribute discovery due to unexpected default response: %r", + rsp, ) return None From b1aba9ab817f8492a9eedd7ae3702366bec32f00 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 30 Jul 2024 21:30:05 +0200 Subject: [PATCH 5/9] Patch each instance of cluster --- tests/test_discover.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/tests/test_discover.py b/tests/test_discover.py index a00074f54..835e69a9c 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -116,10 +116,6 @@ async def _mock( return _mock -@patch( - "zigpy.zcl.clusters.general.Identify.request", - new=AsyncMock(return_value=[mock.sentinel.data, zcl_f.Status.SUCCESS]), -) @pytest.mark.parametrize("device", DEVICES) async def test_devices( device, @@ -140,7 +136,9 @@ async def test_devices( cluster_identify = _get_identify_cluster(zigpy_device) if cluster_identify: - cluster_identify.request.reset_mock() + cluster_identify.request = AsyncMock( + return_value=[mock.sentinel.data, zcl_f.Status.SUCCESS] + ) zha_dev: Device = await device_joined(zigpy_device) await zha_gateway.async_block_till_done() From 474925caf60135e85b5076c2f3000f1099416ad9 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 30 Jul 2024 21:44:00 +0200 Subject: [PATCH 6/9] Only discover attributes on initialize --- tests/test_discover.py | 20 ++++++++++---------- zha/zigbee/cluster_handlers/__init__.py | 6 +++--- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/tests/test_discover.py b/tests/test_discover.py index 835e69a9c..6349b9508 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -145,16 +145,6 @@ async def test_devices( if cluster_identify and not zha_dev.skip_configuration: assert cluster_identify.request.mock_calls == [ - mock.call( - True, - zcl_f.GeneralCommand.Discover_Attributes, - zcl_f.GENERAL_COMMANDS[zcl_f.GeneralCommand.Discover_Attributes].schema, - manufacturer=None, - expect_reply=True, - tsn=None, - start_attribute_id=0, - max_attribute_ids=255, - ), mock.call( False, cluster_identify.commands_by_name["trigger_effect"].id, @@ -167,6 +157,16 @@ async def test_devices( zigpy.zcl.clusters.general.Identify.EffectVariant.Default ), ), + mock.call( + True, + zcl_f.GeneralCommand.Discover_Attributes, + zcl_f.GENERAL_COMMANDS[zcl_f.GeneralCommand.Discover_Attributes].schema, + manufacturer=None, + expect_reply=True, + tsn=None, + start_attribute_id=0, + max_attribute_ids=255, + ), ] event_cluster_handlers = { diff --git a/zha/zigbee/cluster_handlers/__init__.py b/zha/zigbee/cluster_handlers/__init__.py index 199988444..e8efaa3e7 100644 --- a/zha/zigbee/cluster_handlers/__init__.py +++ b/zha/zigbee/cluster_handlers/__init__.py @@ -446,9 +446,6 @@ async def async_configure(self) -> None: self.debug("Performing cluster handler specific configuration") await ch_specific_cfg() - self.debug("Discovering unsupported attributes") - await self.discover_unsupported_attributes() - self.debug("finished cluster handler configuration") else: self.debug("skipping cluster handler configuration") @@ -466,6 +463,9 @@ async def async_initialize(self, from_cache: bool) -> None: uncached = [a for a, cached in self.ZCL_INIT_ATTRS.items() if not cached] uncached.extend([cfg["attr"] for cfg in self.REPORT_CONFIG]) + self.debug("discovering unsupported attributes") + await self.discover_unsupported_attributes() + if cached: self.debug("initializing cached cluster handler attributes: %s", cached) await self._get_attributes( From 0fbbad41f2837d05cb4fdc6020ca4d411bfb2756 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Tue, 30 Jul 2024 21:48:26 +0200 Subject: [PATCH 7/9] Drop uneeded patch import --- tests/test_discover.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/test_discover.py b/tests/test_discover.py index 6349b9508..16e5d4a82 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -6,7 +6,7 @@ import re from typing import Any, Final from unittest import mock -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock import pytest from zhaquirks.ikea import PowerConfig1CRCluster, ScenesCluster From f97d8b3db371dc986039825c0fbe4c85ed7c761b Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Wed, 31 Jul 2024 15:48:23 +0200 Subject: [PATCH 8/9] Support marking attributes as unsupported in tests --- tests/common.py | 8 +++++++- tests/conftest.py | 7 ++++++- tests/test_sensor.py | 6 ++---- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/tests/common.py b/tests/common.py index c0ac85ad2..b8d48c36b 100644 --- a/tests/common.py +++ b/tests/common.py @@ -19,10 +19,15 @@ _LOGGER = logging.getLogger(__name__) -def patch_cluster(cluster: zigpy.zcl.Cluster) -> None: +def patch_cluster( + cluster: zigpy.zcl.Cluster, unsupported_attr: set[str] | None = None +) -> None: """Patch a cluster for testing.""" cluster.PLUGGED_ATTR_READS = {} + if unsupported_attr is None: + unsupported_attr = set() + async def _read_attribute_raw(attributes: Any, *args: Any, **kwargs: Any) -> Any: result = [] for attr_id in attributes: @@ -53,6 +58,7 @@ async def _discover_attributes(*args: Any, **kwargs: Any) -> Any: {"attrid": attr.id, "datatype": 0} ) for attr in cluster.attributes.values() + if attr.name not in unsupported_attr ] return schema(discovery_complete=t.Bool.true, attribute_info=records) diff --git a/tests/conftest.py b/tests/conftest.py index e96b66cf0..598381c3f 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -429,6 +429,7 @@ def _mock_dev( patch_cluster: bool = True, quirk: Optional[Callable] = None, attributes: dict[int, dict[str, dict[str, Any]]] = None, + unsupported_attr: dict[int, set[str]] | None = None, ) -> zigpy.device.Device: """Make a fake device using the specified cluster classes.""" device = zigpy.device.Device( @@ -457,12 +458,16 @@ def _mock_dev( device = get_device(device) if patch_cluster: + if unsupported_attr is None: + unsupported_attr = {} for endpoint in (ep for epid, ep in device.endpoints.items() if epid): endpoint.request = AsyncMock(return_value=[0]) for cluster in itertools.chain( endpoint.in_clusters.values(), endpoint.out_clusters.values() ): - common.patch_cluster(cluster) + common.patch_cluster( + cluster, unsupported_attr.get(endpoint.endpoint_id, set()) + ) if attributes is not None: for ep_id, clusters in attributes.items(): diff --git a/tests/test_sensor.py b/tests/test_sensor.py index 0b42a5a55..e0a10d227 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -798,14 +798,12 @@ async def test_unsupported_attributes_sensor( SIG_EP_TYPE: zigpy.profiles.zha.DeviceType.ON_OFF_SWITCH, SIG_EP_PROFILE: zigpy.profiles.zha.PROFILE_ID, } - } + }, + unsupported_attr={1: unsupported_attributes}, ) - cluster = zigpy_device.endpoints[1].in_clusters[cluster_id] if cluster_id == smartenergy.Metering.cluster_id: # this one is mains powered zigpy_device.node_desc.mac_capability_flags |= 0b_0000_0100 - for attr in unsupported_attributes: - cluster.add_unsupported_attribute(attr) zha_device = await device_joined(zigpy_device) From c16d356b683706a022b5d9e49430c73288643815 Mon Sep 17 00:00:00 2001 From: Joakim Plate Date: Wed, 31 Jul 2024 16:06:38 +0200 Subject: [PATCH 9/9] Only grab unsupported when we are not using cache --- zha/zigbee/cluster_handlers/__init__.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/zha/zigbee/cluster_handlers/__init__.py b/zha/zigbee/cluster_handlers/__init__.py index e8efaa3e7..f7365a702 100644 --- a/zha/zigbee/cluster_handlers/__init__.py +++ b/zha/zigbee/cluster_handlers/__init__.py @@ -463,8 +463,9 @@ async def async_initialize(self, from_cache: bool) -> None: uncached = [a for a, cached in self.ZCL_INIT_ATTRS.items() if not cached] uncached.extend([cfg["attr"] for cfg in self.REPORT_CONFIG]) - self.debug("discovering unsupported attributes") - await self.discover_unsupported_attributes() + if not from_cache: + self.debug("discovering unsupported attributes") + await self.discover_unsupported_attributes() if cached: self.debug("initializing cached cluster handler attributes: %s", cached)