From 71d9acd4de4b8eab4732f8f942b96e04602d071a Mon Sep 17 00:00:00 2001 From: David Mulcahey Date: Tue, 14 Jul 2026 09:41:09 -0400 Subject: [PATCH] Fix discovery with removed ZCL attributes --- tests/test_switch.py | 75 ++++++++++++++++++++ zha/application/platforms/sensor/__init__.py | 13 ++-- zha/application/platforms/switch.py | 9 ++- zha/zigbee/cluster_config.py | 34 ++++++--- 4 files changed, 114 insertions(+), 17 deletions(-) diff --git a/tests/test_switch.py b/tests/test_switch.py index a468c2015..8b579d95b 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -2,6 +2,7 @@ import asyncio import logging +from typing import Final from unittest.mock import call, patch import pytest @@ -34,6 +35,7 @@ get_group_entity, group_entity_availability_test, join_zigpy_device, + patch_cluster_for_testing, send_attributes_report, update_attribute_cache, zigpy_device_from_json, @@ -43,6 +45,11 @@ from zha.application.platforms import GroupEntity, PlatformEntity from zha.exceptions import ZHAException from zha.quirks import QUIRK_REGISTRY_ENTRY_ATTR, DeviceRegistry +from zha.zigbee.cluster_config import ( + AggregatedAttrConfig, + AggregatedClusterConfig, + initialize_cluster_configs, +) from zha.zigbee.device import Device from zha.zigbee.group import Group, GroupMemberReference @@ -904,3 +911,71 @@ async def test_binary_output_cluster(zha_gateway: Gateway) -> None: manufacturer=UNDEFINED, ) ] + + +class MissingOnOffAttributesCluster(CustomCluster, general.OnOff): + """OnOff cluster without the standard attribute definitions.""" + + class AttributeDefs(zcl_f.BaseAttributeDefs): + """Attribute definitions not inheriting from OnOff.AttributeDefs.""" + + window_detection_temperature: Final = zcl_f.ZCLAttributeDef( + id=0x6000, type=t.int16s, is_manufacturer_specific=True + ) + + +async def test_switch_missing_standard_attribute_definitions( + zha_gateway: Gateway, +) -> None: + """A quirk-replaced OnOff cluster does not abort device discovery.""" + registry = DeviceRegistry() + zigpy_device = create_mock_zigpy_device( + zha_gateway, + ZIGPY_DEVICE, + manufacturer="Test_Manufacturer", + model="Test_Model", + ) + + ( + QuirkBuilder(zigpy_device.manufacturer, zigpy_device.model) + .replaces(MissingOnOffAttributesCluster) + .add_to_registry(registry) + ) + + zigpy_device = registry.resolve(zigpy_device) + assert getattr(zigpy_device, QUIRK_REGISTRY_ENTRY_ATTR, None) is not None + assert isinstance(zigpy_device.endpoints[1].on_off, MissingOnOffAttributesCluster) + patch_cluster_for_testing(zigpy_device.endpoints[1].on_off) + + zha_device = await join_zigpy_device(zha_gateway, zigpy_device) + + with pytest.raises(KeyError): + get_entity(zha_device, platform=Platform.SWITCH) + + +async def test_missing_definition_does_not_suppress_valid_startup_read( + zha_gateway: Gateway, +) -> None: + """An undefined config attribute does not suppress a valid startup read.""" + zigpy_device = create_mock_zigpy_device( + zha_gateway, + ZIGPY_DEVICE, + ) + cluster = zigpy_device.endpoints[1].on_off + configs = { + (1, general.OnOff.cluster_id, True): AggregatedClusterConfig( + cluster=cluster, + attributes={ + general.OnOff.AttributeDefs.on_off.name: AggregatedAttrConfig( + read_on_startup=True + ), + "missing_attribute": AggregatedAttrConfig(read_on_startup=True), + }, + ) + } + + await initialize_cluster_configs(configs, from_cache=False) + assert cluster.read_attributes_raw.call_count == 1 + assert cluster.read_attributes_raw.call_args.args[0] == [ + general.OnOff.AttributeDefs.on_off.id + ] diff --git a/zha/application/platforms/sensor/__init__.py b/zha/application/platforms/sensor/__init__.py index 5ac490b1b..42e1da67b 100644 --- a/zha/application/platforms/sensor/__init__.py +++ b/zha/application/platforms/sensor/__init__.py @@ -919,8 +919,9 @@ async def async_update(self) -> None: continue if not entity.enabled: continue - if entity._attribute_name and self._cluster.is_attribute_unsupported( - entity._attribute_name + if entity._attribute_name and ( + entity._attribute_name not in self._cluster.attributes_by_name + or self._cluster.is_attribute_unsupported(entity._attribute_name) ): continue cfg = entity._server_cluster_config.get(self._cluster_id) @@ -929,10 +930,14 @@ async def async_update(self) -> None: for attr_def, attr_cfg in cfg.attributes.items(): if attr_cfg.reporting is None: continue - if self._cluster.is_attribute_unsupported(attr_def): + attr_name = attr_def if isinstance(attr_def, str) else attr_def.name + if ( + attr_name not in self._cluster.attributes_by_name + or self._cluster.is_attribute_unsupported(attr_name) + ): continue - attrs.add(attr_def if isinstance(attr_def, str) else attr_def.name) + attrs.add(attr_name) if not attrs: return diff --git a/zha/application/platforms/switch.py b/zha/application/platforms/switch.py index a6474a2ce..da22d0010 100644 --- a/zha/application/platforms/switch.py +++ b/zha/application/platforms/switch.py @@ -202,7 +202,10 @@ def on_add(self) -> None: ) def _is_supported(self) -> bool: - if self._cluster.is_attribute_unsupported(self._attribute_name): + if ( + self._attribute_name not in self._cluster.attributes_by_name + or self._cluster.is_attribute_unsupported(self._attribute_name) + ): _LOGGER.debug( "%s is not supported - skipping %s entity creation", self._attribute_name, @@ -1041,8 +1044,8 @@ def _is_supported(self) -> bool: # this entity needs a second attribute to function if ( - self._cluster.is_attribute_unsupported(window_covering_mode_attr) - or window_covering_mode_attr not in self._cluster.attributes_by_name + window_covering_mode_attr not in self._cluster.attributes_by_name + or self._cluster.is_attribute_unsupported(window_covering_mode_attr) or self._cluster.get(window_covering_mode_attr) is None ): _LOGGER.debug( diff --git a/zha/zigbee/cluster_config.py b/zha/zigbee/cluster_config.py index f0d487d01..9087ed4fa 100644 --- a/zha/zigbee/cluster_config.py +++ b/zha/zigbee/cluster_config.py @@ -178,6 +178,15 @@ async def configure_cluster_configs( for attr_name, attr_config in agg.attributes.items(): if attr_config.reporting is None: continue + if attr_name not in agg.cluster.attributes_by_name: + _LOGGER.debug( + "[%s] Attribute %s has no definition on cluster %s," + " skipping reporting configuration", + agg.cluster.endpoint.device.ieee, + attr_name, + agg.cluster.ep_attribute, + ) + continue attr_def = agg.cluster.find_attribute(attr_name) reporting_attrs[attr_def] = attr_config.reporting @@ -248,16 +257,21 @@ async def initialize_cluster_configs( ) -> None: """Read initial attribute values from aggregated configs.""" for agg in configs.values(): - cached_attrs = [ - attr_name - for attr_name, attr_config in agg.attributes.items() - if not attr_config.read_on_startup - ] - fresh_attrs = [ - attr_name - for attr_name, attr_config in agg.attributes.items() - if attr_config.read_on_startup - ] + cached_attrs: list[str] = [] + fresh_attrs: list[str] = [] + for attr_name, attr_config in agg.attributes.items(): + if attr_name not in agg.cluster.attributes_by_name: + _LOGGER.debug( + "[%s] Attribute %s has no definition on cluster %s," + " skipping initialization", + agg.cluster.endpoint.device.ieee, + attr_name, + agg.cluster.ep_attribute, + ) + continue + + attrs = fresh_attrs if attr_config.read_on_startup else cached_attrs + attrs.append(attr_name) if cached_attrs: try: