From 958ee6a4c659e69f20dc72ffd6aeb2b7a3241abc Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 23 Dec 2025 15:02:53 -0500 Subject: [PATCH 1/7] Migrate to new zigpy ZCL attribute system --- tests/common.py | 15 ++- tests/test_device.py | 15 ++- tests/test_discover.py | 6 +- zha/zigbee/cluster_handlers/__init__.py | 92 ++++++++++++------- zha/zigbee/cluster_handlers/general.py | 39 ++++++-- .../cluster_handlers/manufacturerspecific.py | 36 +++++--- 6 files changed, 132 insertions(+), 71 deletions(-) diff --git a/tests/common.py b/tests/common.py index dbe4ab0e1..73ff35087 100644 --- a/tests/common.py +++ b/tests/common.py @@ -415,16 +415,21 @@ def zigpy_device_from_device_data( for attr in cluster["attributes"]: attrid = int(attr["id"], 16) + attr_name = attr.get("name") + + # Look up by name to avoid ambiguity with manufacturer-specific attrs + if attr_name is not None: + attr_def = real_cluster.find_attribute(attr_name) + assert attr_def.id == attrid + else: + attr_def = real_cluster.find_attribute(attrid) if attr.get("value", None) is not None: - real_cluster._attr_cache[attrid] = attr["value"] + real_cluster._attr_cache.set_value(attr_def, attr["value"]) real_cluster.PLUGGED_ATTR_READS[attrid] = attr["value"] if attr.get("unsupported", False): - real_cluster.unsupported_attributes.add(attrid) - - if attr["name"] is not None: - real_cluster.unsupported_attributes.add(attr["name"]) + real_cluster.add_unsupported_attribute(attr_def) for obj in device_data["neighbors"]: app.topology.neighbors[device.ieee].append( diff --git a/tests/test_device.py b/tests/test_device.py index 050bce943..98ad5124f 100644 --- a/tests/test_device.py +++ b/tests/test_device.py @@ -1,7 +1,6 @@ """Test ZHA device switch.""" import asyncio -from datetime import UTC, datetime import logging import time from unittest import mock @@ -824,19 +823,17 @@ async def test_device_firmware_version_syncing(zha_gateway: Gateway) -> None: # If we update the entity, the device updates as well update_entity = get_entity(zha_device, platform=Platform.UPDATE) - update_entity._ota_cluster_handler.attribute_updated( - attrid=Ota.AttributeDefs.current_file_version.id, - value=zigpy.types.uint32_t(0xABCD1234), - timestamp=datetime.now(UTC), + update_entity._ota_cluster_handler.cluster.update_attribute( + Ota.AttributeDefs.current_file_version.id, + zigpy.types.uint32_t(0xABCD1234), ) assert zha_device.firmware_version == "0xabcd1234" # Duplicate updates are ignored - update_entity._ota_cluster_handler.attribute_updated( - attrid=Ota.AttributeDefs.current_file_version.id, - value=zigpy.types.uint32_t(0xABCD1234), - timestamp=datetime.now(UTC), + update_entity._ota_cluster_handler.cluster.update_attribute( + Ota.AttributeDefs.current_file_version.id, + zigpy.types.uint32_t(0xABCD1234), ) assert zha_device.firmware_version == "0xabcd1234" diff --git a/tests/test_discover.py b/tests/test_discover.py index ea8e9fd57..16caf9380 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -802,7 +802,10 @@ async def test_devices_from_files( # XXX: attribute updates during device initialization unfortunately triggers # logic within quirks to "fix" attributes. Since these attributes are *read out* # in this state, this will compound the "fix" repeatedly. - with mock.patch("zigpy.zcl.Cluster._update_attribute"): + with ( + mock.patch("zigpy.zcl.Cluster._update_attribute"), + mock.patch("zigpy.zcl.helpers.AttributeCache.set_value"), + ): zha_device = await join_zigpy_device(zha_gateway, zigpy_device) await zha_gateway.async_block_till_done(wait_background_tasks=True) assert zha_device is not None @@ -868,6 +871,5 @@ async def test_devices_from_files( not in ("HDC52EastwindFan", "HBUniversalCFRemote") ), manufacturer=None, - tsn=None, ) ] diff --git a/zha/zigbee/cluster_handlers/__init__.py b/zha/zigbee/cluster_handlers/__init__.py index 0f5c95b0d..bed021271 100644 --- a/zha/zigbee/cluster_handlers/__init__.py +++ b/zha/zigbee/cluster_handlers/__init__.py @@ -5,7 +5,6 @@ from collections.abc import Awaitable, Callable, Coroutine, Iterator import contextlib from dataclasses import dataclass -from datetime import datetime from enum import Enum import functools import logging @@ -14,6 +13,12 @@ import zigpy.exceptions import zigpy.util import zigpy.zcl +from zigpy.zcl import ( + AttributeReadEvent, + AttributeReportedEvent, + AttributeUpdatedEvent, + AttributeWrittenEvent, +) from zigpy.zcl.foundation import ( CommandSchema, ConfigureReportingResponseRecord, @@ -213,14 +218,55 @@ def __init__(self, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> None: self.value_attribute = attr_def.name self._status: ClusterHandlerStatus = ClusterHandlerStatus.CREATED self.data_cache: dict[str, Any] = {} + self._unsubs: list[Callable[[], None]] = [] def on_add(self) -> None: """Call when cluster handler is added.""" self._cluster.add_listener(self) + for event_type in ( + AttributeReadEvent, + AttributeReportedEvent, + AttributeUpdatedEvent, + AttributeWrittenEvent, + ): + self._unsubs.append( + self._cluster.on_event( + event_type.event_type, self._handle_attribute_updated_event + ) + ) def on_remove(self) -> None: """Call when cluster handler will be removed.""" self._cluster.remove_listener(self) + for unsub in self._unsubs: + unsub() + self._unsubs.clear() + + def _handle_attribute_updated_event( + self, + event: AttributeReadEvent + | AttributeReportedEvent + | AttributeUpdatedEvent + | AttributeWrittenEvent, + ) -> None: + """Handle attribute updated event from zigpy.""" + self.debug( + "cluster_handler[%s] attribute_updated - cluster[%s] attr[%s] value[%s]", + self.name, + self.cluster.name, + event.attribute_name, + event.value, + ) + self.emit( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + ClusterAttributeUpdatedEvent( + attribute_id=event.attribute_id, + attribute_name=event.attribute_name, + attribute_value=event.value, + cluster_handler_unique_id=self.unique_id, + cluster_id=self.cluster.cluster_id, + ), + ) @classmethod def matches(cls, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> bool: # pylint: disable=unused-argument @@ -499,27 +545,6 @@ async def async_initialize(self, from_cache: bool) -> None: def cluster_command(self, tsn, command_id, args) -> None: """Handle commands received to this cluster.""" - def attribute_updated(self, attrid: int, value: Any, timestamp: datetime) -> None: - """Handle attribute updates on this cluster.""" - attr_name = self._get_attribute_name(attrid) - self.debug( - "cluster_handler[%s] attribute_updated - cluster[%s] attr[%s] value[%s]", - self.name, - self.cluster.name, - attr_name, - value, - ) - self.emit( - CLUSTER_HANDLER_ATTRIBUTE_UPDATED, - ClusterAttributeUpdatedEvent( - attribute_id=attrid, - attribute_name=attr_name, - attribute_value=value, - cluster_handler_unique_id=self.unique_id, - cluster_id=self.cluster.cluster_id, - ), - ) - def zdo_command(self, *args, **kwargs) -> None: """Handle ZDO commands on this cluster.""" @@ -731,22 +756,23 @@ def __init__(self, *args, **kwargs) -> None: self._generic_id += "_client" self._id += "_client" - def attribute_updated(self, attrid: int, value: Any, timestamp: datetime) -> None: + def _handle_attribute_updated_event( + self, + event: AttributeReadEvent + | AttributeReportedEvent + | AttributeUpdatedEvent + | AttributeWrittenEvent, + ) -> None: """Handle an attribute updated on this cluster.""" - super().attribute_updated(attrid, value, timestamp) - - try: - attr_name = self._cluster.attributes[attrid].name - except KeyError: - attr_name = "Unknown" + super()._handle_attribute_updated_event(event) self.emit_zha_event( SIGNAL_ATTR_UPDATED, { - ATTRIBUTE_ID: attrid, - ATTRIBUTE_NAME: attr_name, - ATTRIBUTE_VALUE: value, - VALUE: value, + ATTRIBUTE_ID: event.attribute_id, + ATTRIBUTE_NAME: event.attribute_name or "Unknown", + ATTRIBUTE_VALUE: event.value, + VALUE: event.value, }, ) diff --git a/zha/zigbee/cluster_handlers/general.py b/zha/zigbee/cluster_handlers/general.py index e47517495..5f918e464 100644 --- a/zha/zigbee/cluster_handlers/general.py +++ b/zha/zigbee/cluster_handlers/general.py @@ -5,13 +5,18 @@ import asyncio from collections.abc import Coroutine from dataclasses import dataclass -from datetime import datetime from typing import TYPE_CHECKING, Any, Final from zhaquirks.quirk_ids import TUYA_PLUG_ONOFF import zigpy.exceptions import zigpy.types as t import zigpy.zcl +from zigpy.zcl import ( + AttributeReadEvent, + AttributeReportedEvent, + AttributeUpdatedEvent, + AttributeWrittenEvent, +) from zigpy.zcl.clusters.general import ( Alarms, AnalogInput, @@ -466,13 +471,22 @@ def cluster_command(self, tsn, command_id, args): SIGNAL_MOVE_LEVEL, -args[1] if args[0] else args[1] ) - def attribute_updated(self, attrid: int, value: Any, timestamp: datetime) -> None: + def _handle_attribute_updated_event( + self, + event: AttributeReadEvent + | AttributeReportedEvent + | AttributeUpdatedEvent + | AttributeWrittenEvent, + ) -> None: """Handle attribute updates on this cluster.""" - self.debug("received attribute: %s update with value: %s", attrid, value) - if attrid == self.CURRENT_LEVEL: - self.dispatch_level_change(SIGNAL_SET_LEVEL, value) - else: - super().attribute_updated(attrid, value, timestamp) + self.debug( + "received attribute: %s update with value: %s", + event.attribute_id, + event.value, + ) + if event.attribute_id == self.CURRENT_LEVEL: + self.dispatch_level_change(SIGNAL_SET_LEVEL, event.value) + super()._handle_attribute_updated_event(event) def dispatch_level_change(self, command, level): """Dispatch level change.""" @@ -667,12 +681,17 @@ def current_file_version(self) -> int | None: """Return cached value of current_file_version attribute.""" return self.cluster.get(Ota.AttributeDefs.current_file_version.name) - def attribute_updated(self, attrid: int, value: Any, timestamp: datetime) -> None: + def _handle_attribute_updated_event( + self, + event: AttributeReadEvent + | AttributeReportedEvent + | AttributeUpdatedEvent + | AttributeWrittenEvent, + ) -> None: """Handle an attribute updated on this cluster.""" - # We intentionally avoid the `ClientClusterHandler` attribute update handler: # it emits a logbook event on every update, which pollutes the logbook - ClusterHandler.attribute_updated(self, attrid, value, timestamp) + ClusterHandler._handle_attribute_updated_event(self, event) def cluster_command( self, tsn: int, command_id: int, args: list[Any] | None diff --git a/zha/zigbee/cluster_handlers/manufacturerspecific.py b/zha/zigbee/cluster_handlers/manufacturerspecific.py index 89ef94e8d..a2e9b2948 100644 --- a/zha/zigbee/cluster_handlers/manufacturerspecific.py +++ b/zha/zigbee/cluster_handlers/manufacturerspecific.py @@ -2,7 +2,6 @@ from __future__ import annotations -from datetime import datetime import logging from typing import TYPE_CHECKING, Any @@ -13,6 +12,12 @@ XIAOMI_AQARA_VIBRATION_AQ1, ) import zigpy.zcl +from zigpy.zcl import ( + AttributeReadEvent, + AttributeReportedEvent, + AttributeUpdatedEvent, + AttributeWrittenEvent, +) from zigpy.zcl.clusters.closures import DoorLock from zigpy.zcl.clusters.homeautomation import Diagnostic from zigpy.zcl.clusters.hvac import Thermostat, UserInterface @@ -234,20 +239,21 @@ def matches(cls, cluster: zigpy.zcl.Cluster, endpoint: Endpoint) -> bool: "SmartThings", ) - def attribute_updated(self, attrid: int, value: Any, timestamp: datetime) -> None: + def _handle_attribute_updated_event( + self, + event: AttributeReadEvent + | AttributeReportedEvent + | AttributeUpdatedEvent + | AttributeWrittenEvent, + ) -> None: """Handle attribute updates on this cluster.""" - super().attribute_updated(attrid, value, timestamp) - try: - attr_name = self._cluster.attributes[attrid].name - except KeyError: - attr_name = UNKNOWN - + super()._handle_attribute_updated_event(event) self.emit_zha_event( SIGNAL_ATTR_UPDATED, { - ATTRIBUTE_ID: attrid, - ATTRIBUTE_NAME: attr_name, - ATTRIBUTE_VALUE: value, + ATTRIBUTE_ID: event.attribute_id, + ATTRIBUTE_NAME: event.attribute_name or UNKNOWN, + ATTRIBUTE_VALUE: event.value, }, ) @@ -256,7 +262,13 @@ def attribute_updated(self, attrid: int, value: Any, timestamp: datetime) -> Non class InovelliNotificationClientClusterHandler(ClientClusterHandler): """Inovelli Notification cluster handler.""" - def attribute_updated(self, attrid: int, value: Any, _: Any) -> None: + def _handle_attribute_updated_event( + self, + event: AttributeReadEvent + | AttributeReportedEvent + | AttributeUpdatedEvent + | AttributeWrittenEvent, + ) -> None: """Handle an attribute updated on this cluster.""" def cluster_command(self, tsn, command_id, args): From ed8e0a4cd9d1729b9e123ba5aed9ec48a8b60c88 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 23 Dec 2025 17:55:48 -0500 Subject: [PATCH 2/7] Fix tests --- tests/data/devices/smartthings-tagv4.json | 4 +- .../data/devices/tze200-c88teujp-ts0601.json | 141 ++++++++++++++++++ .../data/devices/tze200-h4cgnbzg-ts0601.json | 141 ++++++++++++++++++ .../data/devices/tze200-yw7cahqs-ts0601.json | 141 ++++++++++++++++++ tests/test_light.py | 38 ----- tests/test_sensor.py | 13 +- tests/test_switch.py | 6 - 7 files changed, 432 insertions(+), 52 deletions(-) diff --git a/tests/data/devices/smartthings-tagv4.json b/tests/data/devices/smartthings-tagv4.json index eab31decc..e97db2e4d 100644 --- a/tests/data/devices/smartthings-tagv4.json +++ b/tests/data/devices/smartthings-tagv4.json @@ -293,8 +293,8 @@ "state": { "class_name": "DeviceScannerEntity", "available": true, - "connected": false, - "battery_level": null + "connected": true, + "battery_level": 69.0 } } ], diff --git a/tests/data/devices/tze200-c88teujp-ts0601.json b/tests/data/devices/tze200-c88teujp-ts0601.json index f784eb9b9..f36d352c8 100644 --- a/tests/data/devices/tze200-c88teujp-ts0601.json +++ b/tests/data/devices/tze200-c88teujp-ts0601.json @@ -350,6 +350,147 @@ } ], "number": [ + { + "info_object": { + "fallback_name": null, + "unique_id": "ab:cd:ef:12:f0:e2:89:82-1-513-local_temperature_calibration", + "migrate_unique_ids": [], + "platform": "number", + "class_name": "ThermostatLocalTempCalibration", + "translation_key": "local_temperature_calibration", + "translation_placeholders": null, + "device_class": null, + "state_class": null, + "entity_category": "config", + "entity_registry_enabled_default": true, + "enabled": true, + "primary": false, + "cluster_handlers": [ + { + "class_name": "ThermostatClusterHandler", + "generic_id": "cluster_handler_0x0201", + "endpoint_id": 1, + "cluster": { + "id": 513, + "name": "TuyaThermostatV2NoSchedule", + "type": "server" + }, + "id": "1:0x0201", + "unique_id": "ab:cd:ef:12:f0:e2:89:82:1:0x0201", + "status": "INITIALIZED", + "value_attribute": "local_temperature" + } + ], + "device_ieee": "ab:cd:ef:12:f0:e2:89:82", + "endpoint_id": 1, + "available": true, + "group_id": null, + "mode": "box", + "native_max_value": 2.5, + "native_min_value": -2.5, + "native_step": 0.1, + "native_unit_of_measurement": "\u00b0C" + }, + "state": { + "class_name": "ThermostatLocalTempCalibration", + "available": true, + "state": 2.0 + } + }, + { + "info_object": { + "fallback_name": null, + "unique_id": "ab:cd:ef:12:f0:e2:89:82-1-513-max_heat_setpoint_limit", + "migrate_unique_ids": [], + "platform": "number", + "class_name": "MaxHeatSetpointLimit", + "translation_key": "max_heat_setpoint_limit", + "translation_placeholders": null, + "device_class": null, + "state_class": null, + "entity_category": "config", + "entity_registry_enabled_default": true, + "enabled": true, + "primary": false, + "cluster_handlers": [ + { + "class_name": "ThermostatClusterHandler", + "generic_id": "cluster_handler_0x0201", + "endpoint_id": 1, + "cluster": { + "id": 513, + "name": "TuyaThermostatV2NoSchedule", + "type": "server" + }, + "id": "1:0x0201", + "unique_id": "ab:cd:ef:12:f0:e2:89:82:1:0x0201", + "status": "INITIALIZED", + "value_attribute": "local_temperature" + } + ], + "device_ieee": "ab:cd:ef:12:f0:e2:89:82", + "endpoint_id": 1, + "available": true, + "group_id": null, + "mode": "box", + "native_max_value": 30.0, + "native_min_value": 5.0, + "native_step": 0.5, + "native_unit_of_measurement": "\u00b0C" + }, + "state": { + "class_name": "MaxHeatSetpointLimit", + "available": true, + "state": 30.0 + } + }, + { + "info_object": { + "fallback_name": null, + "unique_id": "ab:cd:ef:12:f0:e2:89:82-1-513-min_heat_setpoint_limit", + "migrate_unique_ids": [], + "platform": "number", + "class_name": "MinHeatSetpointLimit", + "translation_key": "min_heat_setpoint_limit", + "translation_placeholders": null, + "device_class": null, + "state_class": null, + "entity_category": "config", + "entity_registry_enabled_default": true, + "enabled": true, + "primary": false, + "cluster_handlers": [ + { + "class_name": "ThermostatClusterHandler", + "generic_id": "cluster_handler_0x0201", + "endpoint_id": 1, + "cluster": { + "id": 513, + "name": "TuyaThermostatV2NoSchedule", + "type": "server" + }, + "id": "1:0x0201", + "unique_id": "ab:cd:ef:12:f0:e2:89:82:1:0x0201", + "status": "INITIALIZED", + "value_attribute": "local_temperature" + } + ], + "device_ieee": "ab:cd:ef:12:f0:e2:89:82", + "endpoint_id": 1, + "available": true, + "group_id": null, + "mode": "box", + "native_max_value": 30.0, + "native_min_value": 5.0, + "native_step": 0.5, + "native_unit_of_measurement": "\u00b0C" + }, + "state": { + "class_name": "MinHeatSetpointLimit", + "available": true, + "state": 5.0 + } + }, { "info_object": { "fallback_name": "Local temperature calibration", diff --git a/tests/data/devices/tze200-h4cgnbzg-ts0601.json b/tests/data/devices/tze200-h4cgnbzg-ts0601.json index 79f46391c..0e275125d 100644 --- a/tests/data/devices/tze200-h4cgnbzg-ts0601.json +++ b/tests/data/devices/tze200-h4cgnbzg-ts0601.json @@ -316,6 +316,147 @@ } ], "number": [ + { + "info_object": { + "fallback_name": null, + "unique_id": "ab:cd:ef:12:d1:91:b1:8f-1-513-local_temperature_calibration", + "migrate_unique_ids": [], + "platform": "number", + "class_name": "ThermostatLocalTempCalibration", + "translation_key": "local_temperature_calibration", + "translation_placeholders": null, + "device_class": null, + "state_class": null, + "entity_category": "config", + "entity_registry_enabled_default": true, + "enabled": true, + "primary": false, + "cluster_handlers": [ + { + "class_name": "ThermostatClusterHandler", + "generic_id": "cluster_handler_0x0201", + "endpoint_id": 1, + "cluster": { + "id": 513, + "name": "TuyaThermostatV2NoSchedule", + "type": "server" + }, + "id": "1:0x0201", + "unique_id": "ab:cd:ef:12:d1:91:b1:8f:1:0x0201", + "status": "INITIALIZED", + "value_attribute": "local_temperature" + } + ], + "device_ieee": "ab:cd:ef:12:d1:91:b1:8f", + "endpoint_id": 1, + "available": true, + "group_id": null, + "mode": "box", + "native_max_value": 2.5, + "native_min_value": -2.5, + "native_step": 0.1, + "native_unit_of_measurement": "\u00b0C" + }, + "state": { + "class_name": "ThermostatLocalTempCalibration", + "available": true, + "state": -0.4 + } + }, + { + "info_object": { + "fallback_name": null, + "unique_id": "ab:cd:ef:12:d1:91:b1:8f-1-513-max_heat_setpoint_limit", + "migrate_unique_ids": [], + "platform": "number", + "class_name": "MaxHeatSetpointLimit", + "translation_key": "max_heat_setpoint_limit", + "translation_placeholders": null, + "device_class": null, + "state_class": null, + "entity_category": "config", + "entity_registry_enabled_default": true, + "enabled": true, + "primary": false, + "cluster_handlers": [ + { + "class_name": "ThermostatClusterHandler", + "generic_id": "cluster_handler_0x0201", + "endpoint_id": 1, + "cluster": { + "id": 513, + "name": "TuyaThermostatV2NoSchedule", + "type": "server" + }, + "id": "1:0x0201", + "unique_id": "ab:cd:ef:12:d1:91:b1:8f:1:0x0201", + "status": "INITIALIZED", + "value_attribute": "local_temperature" + } + ], + "device_ieee": "ab:cd:ef:12:d1:91:b1:8f", + "endpoint_id": 1, + "available": true, + "group_id": null, + "mode": "box", + "native_max_value": 30.0, + "native_min_value": 5.0, + "native_step": 0.5, + "native_unit_of_measurement": "\u00b0C" + }, + "state": { + "class_name": "MaxHeatSetpointLimit", + "available": true, + "state": 30.0 + } + }, + { + "info_object": { + "fallback_name": null, + "unique_id": "ab:cd:ef:12:d1:91:b1:8f-1-513-min_heat_setpoint_limit", + "migrate_unique_ids": [], + "platform": "number", + "class_name": "MinHeatSetpointLimit", + "translation_key": "min_heat_setpoint_limit", + "translation_placeholders": null, + "device_class": null, + "state_class": null, + "entity_category": "config", + "entity_registry_enabled_default": true, + "enabled": true, + "primary": false, + "cluster_handlers": [ + { + "class_name": "ThermostatClusterHandler", + "generic_id": "cluster_handler_0x0201", + "endpoint_id": 1, + "cluster": { + "id": 513, + "name": "TuyaThermostatV2NoSchedule", + "type": "server" + }, + "id": "1:0x0201", + "unique_id": "ab:cd:ef:12:d1:91:b1:8f:1:0x0201", + "status": "INITIALIZED", + "value_attribute": "local_temperature" + } + ], + "device_ieee": "ab:cd:ef:12:d1:91:b1:8f", + "endpoint_id": 1, + "available": true, + "group_id": null, + "mode": "box", + "native_max_value": 30.0, + "native_min_value": 5.0, + "native_step": 0.5, + "native_unit_of_measurement": "\u00b0C" + }, + "state": { + "class_name": "MinHeatSetpointLimit", + "available": true, + "state": 5.0 + } + }, { "info_object": { "fallback_name": "Local temperature calibration", diff --git a/tests/data/devices/tze200-yw7cahqs-ts0601.json b/tests/data/devices/tze200-yw7cahqs-ts0601.json index c4df8be60..05ea2f85b 100644 --- a/tests/data/devices/tze200-yw7cahqs-ts0601.json +++ b/tests/data/devices/tze200-yw7cahqs-ts0601.json @@ -350,6 +350,147 @@ } ], "number": [ + { + "info_object": { + "fallback_name": null, + "unique_id": "ab:cd:ef:12:39:54:17:e4-1-513-local_temperature_calibration", + "migrate_unique_ids": [], + "platform": "number", + "class_name": "ThermostatLocalTempCalibration", + "translation_key": "local_temperature_calibration", + "translation_placeholders": null, + "device_class": null, + "state_class": null, + "entity_category": "config", + "entity_registry_enabled_default": true, + "enabled": true, + "primary": false, + "cluster_handlers": [ + { + "class_name": "ThermostatClusterHandler", + "generic_id": "cluster_handler_0x0201", + "endpoint_id": 1, + "cluster": { + "id": 513, + "name": "TuyaThermostatV2NoSchedule", + "type": "server" + }, + "id": "1:0x0201", + "unique_id": "ab:cd:ef:12:39:54:17:e4:1:0x0201", + "status": "INITIALIZED", + "value_attribute": "local_temperature" + } + ], + "device_ieee": "ab:cd:ef:12:39:54:17:e4", + "endpoint_id": 1, + "available": true, + "group_id": null, + "mode": "box", + "native_max_value": 2.5, + "native_min_value": -2.5, + "native_step": 0.1, + "native_unit_of_measurement": "\u00b0C" + }, + "state": { + "class_name": "ThermostatLocalTempCalibration", + "available": true, + "state": 0.0 + } + }, + { + "info_object": { + "fallback_name": null, + "unique_id": "ab:cd:ef:12:39:54:17:e4-1-513-max_heat_setpoint_limit", + "migrate_unique_ids": [], + "platform": "number", + "class_name": "MaxHeatSetpointLimit", + "translation_key": "max_heat_setpoint_limit", + "translation_placeholders": null, + "device_class": null, + "state_class": null, + "entity_category": "config", + "entity_registry_enabled_default": true, + "enabled": true, + "primary": false, + "cluster_handlers": [ + { + "class_name": "ThermostatClusterHandler", + "generic_id": "cluster_handler_0x0201", + "endpoint_id": 1, + "cluster": { + "id": 513, + "name": "TuyaThermostatV2NoSchedule", + "type": "server" + }, + "id": "1:0x0201", + "unique_id": "ab:cd:ef:12:39:54:17:e4:1:0x0201", + "status": "INITIALIZED", + "value_attribute": "local_temperature" + } + ], + "device_ieee": "ab:cd:ef:12:39:54:17:e4", + "endpoint_id": 1, + "available": true, + "group_id": null, + "mode": "box", + "native_max_value": 30.0, + "native_min_value": 5.0, + "native_step": 0.5, + "native_unit_of_measurement": "\u00b0C" + }, + "state": { + "class_name": "MaxHeatSetpointLimit", + "available": true, + "state": 30.0 + } + }, + { + "info_object": { + "fallback_name": null, + "unique_id": "ab:cd:ef:12:39:54:17:e4-1-513-min_heat_setpoint_limit", + "migrate_unique_ids": [], + "platform": "number", + "class_name": "MinHeatSetpointLimit", + "translation_key": "min_heat_setpoint_limit", + "translation_placeholders": null, + "device_class": null, + "state_class": null, + "entity_category": "config", + "entity_registry_enabled_default": true, + "enabled": true, + "primary": false, + "cluster_handlers": [ + { + "class_name": "ThermostatClusterHandler", + "generic_id": "cluster_handler_0x0201", + "endpoint_id": 1, + "cluster": { + "id": 513, + "name": "TuyaThermostatV2NoSchedule", + "type": "server" + }, + "id": "1:0x0201", + "unique_id": "ab:cd:ef:12:39:54:17:e4:1:0x0201", + "status": "INITIALIZED", + "value_attribute": "local_temperature" + } + ], + "device_ieee": "ab:cd:ef:12:39:54:17:e4", + "endpoint_id": 1, + "available": true, + "group_id": null, + "mode": "box", + "native_max_value": 30.0, + "native_min_value": 5.0, + "native_step": 0.5, + "native_unit_of_measurement": "\u00b0C" + }, + "state": { + "class_name": "MinHeatSetpointLimit", + "available": true, + "state": 5.0 + } + }, { "info_object": { "fallback_name": "Local temperature calibration", diff --git a/tests/test_light.py b/tests/test_light.py index 01875a8bf..449471b61 100644 --- a/tests/test_light.py +++ b/tests/test_light.py @@ -454,7 +454,6 @@ async def test_light( transition_time=100.0, expect_reply=True, manufacturer=None, - tsn=None, ) cluster_color.request.reset_mock() @@ -476,7 +475,6 @@ async def test_light( transition_time=0, expect_reply=True, manufacturer=None, - tsn=None, ) cluster_color.request.reset_mock() @@ -553,7 +551,6 @@ async def async_test_on_off_from_client( cluster.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tsn=None, ) await async_test_off_from_client(zha_gateway, cluster, entity) @@ -579,7 +576,6 @@ async def async_test_off_from_client( cluster.commands_by_name["off"].schema, expect_reply=True, manufacturer=None, - tsn=None, ) @@ -623,7 +619,6 @@ async def _reset_light(): on_off_cluster.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tsn=None, ) await _reset_light() @@ -644,7 +639,6 @@ async def _reset_light(): transition_time=100, expect_reply=True, manufacturer=None, - tsn=None, ) await _reset_light() @@ -665,7 +659,6 @@ async def _reset_light(): transition_time=int(expected_default_transition), expect_reply=True, manufacturer=None, - tsn=None, ) await _reset_light() @@ -720,7 +713,6 @@ async def async_test_flash_from_client( effect_variant=general.Identify.EffectVariant.Default, expect_reply=True, manufacturer=None, - tsn=None, ) @@ -1172,7 +1164,6 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tsn=None, ) assert bool(device_1_light_entity.state["on"]) is True @@ -1192,7 +1183,6 @@ async def test_transitions( eWeLink_cluster_on_off.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tsn=None, ) assert eWeLink_cluster_color.request.call_count == 0 assert eWeLink_cluster_color.request.await_count == 0 @@ -1206,7 +1196,6 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tsn=None, ) assert bool(eWeLink_light_entity.state["on"]) is True @@ -1234,7 +1223,6 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tsn=None, ) assert bool(device_1_light_entity.state["on"]) is True @@ -1261,7 +1249,6 @@ async def test_transitions( transition_time=35, expect_reply=True, manufacturer=None, - tsn=None, ) assert dev1_cluster_color.request.call_args == call( False, @@ -1271,7 +1258,6 @@ async def test_transitions( transition_time=35, expect_reply=True, manufacturer=None, - tsn=None, ) assert bool(device_1_light_entity.state["on"]) is True @@ -1299,7 +1285,6 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tsn=None, ) assert bool(device_1_light_entity.state["on"]) is False @@ -1327,7 +1312,6 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tsn=None, ) assert dev1_cluster_color.request.call_args == call( False, @@ -1337,7 +1321,6 @@ async def test_transitions( transition_time=0, # no transition when new_color_provided_while_off expect_reply=True, manufacturer=None, - tsn=None, ) assert dev1_cluster_level.request.call_args_list[1] == call( False, @@ -1347,7 +1330,6 @@ async def test_transitions( transition_time=10, expect_reply=True, manufacturer=None, - tsn=None, ) assert bool(device_1_light_entity.state["on"]) is True @@ -1393,7 +1375,6 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tsn=None, ) assert dev1_cluster_color.request.call_args == call( False, @@ -1403,7 +1384,6 @@ async def test_transitions( transition_time=0, # no transition when new_color_provided_while_off expect_reply=True, manufacturer=None, - tsn=None, ) assert dev1_cluster_level.request.call_args_list[1] == call( False, @@ -1413,7 +1393,6 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tsn=None, ) assert bool(device_1_light_entity.state["on"]) is True @@ -1455,7 +1434,6 @@ async def test_transitions( dev1_cluster_on_off.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tsn=None, ) assert dev1_cluster_color.request.call_args == call( @@ -1466,7 +1444,6 @@ async def test_transitions( transition_time=0, # no transition when new_color_provided_while_off expect_reply=True, manufacturer=None, - tsn=None, ) assert bool(device_1_light_entity.state["on"]) is True @@ -1513,7 +1490,6 @@ async def test_transitions( transition_time=1, # transition time - sengled light uses default minimum expect_reply=True, manufacturer=None, - tsn=None, ) assert bool(device_2_light_entity.state["on"]) is True @@ -1555,7 +1531,6 @@ async def test_transitions( transition_time=1, expect_reply=True, manufacturer=None, - tsn=None, ) assert dev2_cluster_color.request.call_args == call( False, @@ -1565,7 +1540,6 @@ async def test_transitions( transition_time=1, # sengled transition == 1 when new_color_provided_while_off expect_reply=True, manufacturer=None, - tsn=None, ) assert dev2_cluster_level.request.call_args_list[1] == call( False, @@ -1575,7 +1549,6 @@ async def test_transitions( transition_time=10, expect_reply=True, manufacturer=None, - tsn=None, ) assert bool(device_2_light_entity.state["on"]) is True @@ -1622,7 +1595,6 @@ async def test_transitions( transition_time=10, # sengled transition == 1 when new_color_provided_while_off expect_reply=True, manufacturer=None, - tsn=None, ) assert group_level_cluster_handler.request.call_args == call( False, @@ -1632,7 +1604,6 @@ async def test_transitions( transition_time=10, expect_reply=True, manufacturer=None, - tsn=None, ) assert bool(entity.state["on"]) is True @@ -1674,7 +1645,6 @@ async def test_transitions( transition_time=20, # transition time expect_reply=True, manufacturer=None, - tsn=None, ) assert bool(device_2_light_entity.state["on"]) is False @@ -1698,7 +1668,6 @@ async def test_transitions( transition_time=1, # transition time - sengled light uses default minimum expect_reply=True, manufacturer=None, - tsn=None, ) assert bool(device_2_light_entity.state["on"]) is True @@ -1725,7 +1694,6 @@ async def test_transitions( eWeLink_cluster_on_off.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tsn=None, ) assert eWeLink_cluster_color.request.call_args == call( False, @@ -1735,7 +1703,6 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tsn=None, ) assert bool(eWeLink_light_entity.state["on"]) is True @@ -1792,7 +1759,6 @@ async def test_on_with_off_color(zha_gateway: Gateway) -> None: dev1_cluster_on_off.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tsn=None, ) assert dev1_cluster_color.request.call_args == call( False, @@ -1802,7 +1768,6 @@ async def test_on_with_off_color(zha_gateway: Gateway) -> None: transition_time=0, expect_reply=True, manufacturer=None, - tsn=None, ) assert bool(entity.state["on"]) is True @@ -1846,7 +1811,6 @@ async def test_on_with_off_color(zha_gateway: Gateway) -> None: transition_time=0, expect_reply=True, manufacturer=None, - tsn=None, ) assert dev1_cluster_color.request.call_args == call( False, @@ -1856,7 +1820,6 @@ async def test_on_with_off_color(zha_gateway: Gateway) -> None: transition_time=0, expect_reply=True, manufacturer=None, - tsn=None, ) assert dev1_cluster_level.request.call_args_list[1] == call( False, @@ -1866,7 +1829,6 @@ async def test_on_with_off_color(zha_gateway: Gateway) -> None: transition_time=0, expect_reply=True, manufacturer=None, - tsn=None, ) assert bool(entity.state["on"]) is True diff --git a/tests/test_sensor.py b/tests/test_sensor.py index b59a78307..3d35c80e5 100644 --- a/tests/test_sensor.py +++ b/tests/test_sensor.py @@ -415,9 +415,6 @@ async def async_test_powerconfiguration2( zha_gateway: Gateway, cluster: Cluster, entity: PlatformEntity ): """Test powerconfiguration/battery sensor.""" - await send_attributes_report(zha_gateway, cluster, {33: -1}) - assert_state(entity, None, "%") - await send_attributes_report(zha_gateway, cluster, {33: 255}) assert_state(entity, None, "%") @@ -531,7 +528,7 @@ async def async_test_change_source_timestamp( "summation_formatting": 0b1_0111_010, "unit_of_measure": 0x01, }, - {"instaneneous_demand"}, + {"instantaneous_demand"}, ), ( smartenergy.Metering.cluster_id, @@ -547,7 +544,7 @@ async def async_test_change_source_timestamp( "unit_of_measure": 0x00, "current_summ_received": 0, }, - {"instaneneous_demand", "current_summ_delivered"}, + {"instantaneous_demand", "current_summ_delivered"}, ), ( homeautomation.ElectricalMeasurement.cluster_id, @@ -753,6 +750,10 @@ async def test_analog_input_ignored(zha_gateway: Gateway) -> None: zigpy_dev.endpoints[2].analog_input.add_unsupported_attribute( AnalogInput.AttributeDefs.engineering_units.id ) + # Also remove from PLUGGED_ATTR_READS so read_attributes doesn't restore the value + zigpy_dev.endpoints[2].analog_input.PLUGGED_ATTR_READS.pop( + AnalogInput.AttributeDefs.engineering_units.id, None + ) zha_dev = await join_zigpy_device(zha_gateway, zigpy_dev) @@ -1172,7 +1173,7 @@ async def test_se_summation_uom( zigpy_device.node_desc.mac_capability_flags |= 0b_0000_0100 cluster = zigpy_device.endpoints[1].in_clusters[smartenergy.Metering.cluster_id] - for attr in ("instanteneous_demand",): + for attr in ("instantaneous_demand",): cluster.add_unsupported_attribute(attr) cluster.PLUGGED_ATTR_READS = { "current_summ_delivered": raw_value, diff --git a/tests/test_switch.py b/tests/test_switch.py index 09493864f..b2b47582d 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -146,7 +146,6 @@ async def test_switch(zha_gateway: Gateway) -> None: cluster.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tsn=None, ) # Fail turn off from client @@ -167,7 +166,6 @@ async def test_switch(zha_gateway: Gateway) -> None: cluster.commands_by_name["off"].schema, expect_reply=True, manufacturer=None, - tsn=None, ) # turn off from client @@ -185,7 +183,6 @@ async def test_switch(zha_gateway: Gateway) -> None: cluster.commands_by_name["off"].schema, expect_reply=True, manufacturer=None, - tsn=None, ) # Fail turn on from client @@ -206,7 +203,6 @@ async def test_switch(zha_gateway: Gateway) -> None: cluster.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tsn=None, ) # test updating entity state from client @@ -269,7 +265,6 @@ async def test_zha_group_switch_entity(zha_gateway: Gateway) -> None: group_cluster_on_off.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tsn=None, ) assert bool(entity.state["state"]) is True @@ -288,7 +283,6 @@ async def test_zha_group_switch_entity(zha_gateway: Gateway) -> None: group_cluster_on_off.commands_by_name["off"].schema, expect_reply=True, manufacturer=None, - tsn=None, ) assert bool(entity.state["state"]) is False From 61aaf608d2ecaab077f810a83e5fd162956c3df5 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Thu, 15 Jan 2026 11:02:07 -0500 Subject: [PATCH 3/7] Fix private imports --- zha/zigbee/device.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/zha/zigbee/device.py b/zha/zigbee/device.py index f33fb1b1f..13154502b 100644 --- a/zha/zigbee/device.py +++ b/zha/zigbee/device.py @@ -31,7 +31,13 @@ ZCLCommandDef, ) import zigpy.zdo.types as zdo_types -from zigpy.zdo.types import RouteStatus, _NeighborEnums +from zigpy.zdo.types import ( + DeviceType, + PermitJoins, + Relationship, + RouteStatus, + RxOnWhenIdle, +) from zha.application import Platform, discovery from zha.application.const import ( @@ -202,13 +208,13 @@ class DeviceInfo: class NeighborInfo: """Describes a neighbor.""" - device_type: _NeighborEnums.DeviceType - rx_on_when_idle: _NeighborEnums.RxOnWhenIdle - relationship: _NeighborEnums.Relationship + device_type: DeviceType + rx_on_when_idle: RxOnWhenIdle + relationship: Relationship extended_pan_id: ExtendedPanId ieee: EUI64 nwk: NWK - permit_joining: _NeighborEnums.PermitJoins + permit_joining: PermitJoins depth: uint8_t lqi: uint8_t From 87964f048fe7443b16d480e84df09d4a53f20c14 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 19 Jan 2026 20:16:23 -0500 Subject: [PATCH 4/7] Replace `unsupported_attributes` with `is_attribute_unsupported` --- tools/import_diagnostics.py | 12 ++++++------ tools/migrate_diagnostics.py | 12 ++++++------ zha/application/platforms/number/__init__.py | 2 +- zha/application/platforms/select.py | 2 +- zha/application/platforms/sensor/__init__.py | 2 +- zha/application/platforms/switch.py | 10 +++++----- zha/zigbee/cluster_handlers/homeautomation.py | 2 +- zha/zigbee/cluster_handlers/smartenergy.py | 2 +- zha/zigbee/device.py | 2 +- 9 files changed, 23 insertions(+), 23 deletions(-) diff --git a/tools/import_diagnostics.py b/tools/import_diagnostics.py index dbbe7b692..28ef4b9ac 100644 --- a/tools/import_diagnostics.py +++ b/tools/import_diagnostics.py @@ -230,13 +230,13 @@ def zigpy_device_from_legacy_diagnostics( # noqa: C901 "0x" ): attrid = int(unsupported_attr, 16) - real_cluster.unsupported_attributes.add(attrid) + real_cluster.add_unsupported_attribute(attrid) if attrid in real_cluster.attributes: - real_cluster.unsupported_attributes.add( + real_cluster.add_unsupported_attribute( real_cluster.attributes[attrid].name ) else: - real_cluster.unsupported_attributes.add(unsupported_attr) + real_cluster.add_unsupported_attribute(unsupported_attr) for cluster_id, cluster in ep["out_clusters"].items(): try: @@ -271,13 +271,13 @@ def zigpy_device_from_legacy_diagnostics( # noqa: C901 "0x" ): attrid = int(unsupported_attr, 16) - real_cluster.unsupported_attributes.add(attrid) + real_cluster.add_unsupported_attribute(attrid) if attrid in real_cluster.attributes: - real_cluster.unsupported_attributes.add( + real_cluster.add_unsupported_attribute( real_cluster.attributes[attrid].name ) else: - real_cluster.unsupported_attributes.add(unsupported_attr) + real_cluster.add_unsupported_attribute(unsupported_attr) if device.model is None and device.manufacturer is None: return None diff --git a/tools/migrate_diagnostics.py b/tools/migrate_diagnostics.py index fe49c45f8..54ac10bd9 100644 --- a/tools/migrate_diagnostics.py +++ b/tools/migrate_diagnostics.py @@ -116,13 +116,13 @@ def zigpy_device_from_legacy_device_data( "0x" ): attrid = int(unsupported_attr, 16) - real_cluster.unsupported_attributes.add(attrid) + real_cluster.add_unsupported_attribute(attrid) if attrid in real_cluster.attributes: - real_cluster.unsupported_attributes.add( + real_cluster.add_unsupported_attribute( real_cluster.attributes[attrid].name ) else: - real_cluster.unsupported_attributes.add(unsupported_attr) + real_cluster.add_unsupported_attribute(unsupported_attr) for cluster_id, cluster in ep["out_clusters"].items(): real_cluster = device.endpoints[int(epid)].out_clusters[int(cluster_id, 16)] @@ -141,13 +141,13 @@ def zigpy_device_from_legacy_device_data( "0x" ): attrid = int(unsupported_attr, 16) - real_cluster.unsupported_attributes.add(attrid) + real_cluster.add_unsupported_attribute(attrid) if attrid in real_cluster.attributes: - real_cluster.unsupported_attributes.add( + real_cluster.add_unsupported_attribute( real_cluster.attributes[attrid].name ) else: - real_cluster.unsupported_attributes.add(unsupported_attr) + real_cluster.add_unsupported_attribute(unsupported_attr) return device diff --git a/zha/application/platforms/number/__init__.py b/zha/application/platforms/number/__init__.py index fed5c8d95..3f0e12e45 100644 --- a/zha/application/platforms/number/__init__.py +++ b/zha/application/platforms/number/__init__.py @@ -210,7 +210,7 @@ def __init__( def _is_supported(self) -> bool: """Return if the entity is supported for the device, internal.""" if ( - self._attribute_name in self._cluster_handler.cluster.unsupported_attributes + self._cluster_handler.cluster.is_attribute_unsupported(self._attribute_name) or ( self._attribute_name not in self._cluster_handler.cluster.attributes_by_name diff --git a/zha/application/platforms/select.py b/zha/application/platforms/select.py index a943d5f82..9353d8853 100644 --- a/zha/application/platforms/select.py +++ b/zha/application/platforms/select.py @@ -195,7 +195,7 @@ def on_add(self) -> None: def _is_supported(self) -> bool: if ( - self._attribute_name in self._cluster_handler.cluster.unsupported_attributes + self._cluster_handler.cluster.is_attribute_unsupported(self._attribute_name) or self._attribute_name not in self._cluster_handler.cluster.attributes_by_name or self._cluster_handler.cluster.get(self._attribute_name) is None diff --git a/zha/application/platforms/sensor/__init__.py b/zha/application/platforms/sensor/__init__.py index fd20e3699..b4b21d58b 100644 --- a/zha/application/platforms/sensor/__init__.py +++ b/zha/application/platforms/sensor/__init__.py @@ -215,7 +215,7 @@ def on_add(self) -> None: def _is_supported(self) -> bool: if ( - self._attribute_name in self._cluster_handler.cluster.unsupported_attributes + self._cluster_handler.cluster.is_attribute_unsupported(self._attribute_name) or self._attribute_name not in self._cluster_handler.cluster.attributes_by_name ): diff --git a/zha/application/platforms/switch.py b/zha/application/platforms/switch.py index 9fab50d83..12d983d2e 100644 --- a/zha/application/platforms/switch.py +++ b/zha/application/platforms/switch.py @@ -137,9 +137,8 @@ def on_add(self) -> None: ) def _is_supported(self) -> bool: - if ( + if self._on_off_cluster_handler.cluster.is_attribute_unsupported( self._attribute_name - in self._on_off_cluster_handler.cluster.unsupported_attributes ): _LOGGER.debug( "%s is not supported - skipping %s entity creation", @@ -313,7 +312,7 @@ def _init_from_quirks_metadata(self, entity_metadata: SwitchMetadata) -> None: def _is_supported(self) -> bool: if ( - self._attribute_name in self._cluster_handler.cluster.unsupported_attributes + self._cluster_handler.cluster.is_attribute_unsupported(self._attribute_name) or self._attribute_name not in self._cluster_handler.cluster.attributes_by_name or self._cluster_handler.cluster.get(self._attribute_name) is None @@ -749,8 +748,9 @@ def _is_supported(self) -> bool: # this entity needs a second attribute to function if ( ( - window_covering_mode_attr - in self._cluster_handler.cluster.unsupported_attributes + self._cluster_handler.cluster.is_attribute_unsupported( + window_covering_mode_attr + ) ) or ( window_covering_mode_attr diff --git a/zha/zigbee/cluster_handlers/homeautomation.py b/zha/zigbee/cluster_handlers/homeautomation.py index 5dad74f53..58cd14006 100644 --- a/zha/zigbee/cluster_handlers/homeautomation.py +++ b/zha/zigbee/cluster_handlers/homeautomation.py @@ -196,7 +196,7 @@ async def async_update(self): attrs = [ attr for attr in self.ZCL_POLLING_ATTRS - if attr not in self.cluster.unsupported_attributes + if not self.cluster.is_attribute_unsupported(attr) ] await self.get_attributes(attrs, from_cache=False, only_cache=False) diff --git a/zha/zigbee/cluster_handlers/smartenergy.py b/zha/zigbee/cluster_handlers/smartenergy.py index 723e4baaf..b091da428 100644 --- a/zha/zigbee/cluster_handlers/smartenergy.py +++ b/zha/zigbee/cluster_handlers/smartenergy.py @@ -314,7 +314,7 @@ async def async_update(self) -> None: attrs = [ a["attr"] for a in self.REPORT_CONFIG - if a["attr"] not in self.cluster.unsupported_attributes + if not self.cluster.is_attribute_unsupported(a["attr"]) ] await self.get_attributes(attrs, from_cache=False, only_cache=False) diff --git a/zha/zigbee/device.py b/zha/zigbee/device.py index 13154502b..ede995730 100644 --- a/zha/zigbee/device.py +++ b/zha/zigbee/device.py @@ -105,7 +105,7 @@ def get_cluster_attr_data(cluster: Cluster) -> list[dict]: attr_def.zcl_type.name if attr_def.zcl_type.name != "bool_" else "bool" ), "value": cluster.get(attr_def.name), - "unsupported": (attr_def.id in cluster.unsupported_attributes), + "unsupported": cluster.is_attribute_unsupported(attr_def.id), } # Don't unnecessarily list out attributes that are just unread From f0e6c2e90178a5410cc62886d1720d5e19955ecb Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 19 Jan 2026 22:17:21 -0500 Subject: [PATCH 5/7] Do not access private `_DEVICE_REGISTRY` from zigpy --- tests/test_climate.py | 4 ++-- tests/test_cluster_handlers.py | 6 +++--- tests/test_discover.py | 12 ++++++------ tests/test_registries.py | 2 +- tests/test_switch.py | 8 ++++---- 5 files changed, 16 insertions(+), 16 deletions(-) diff --git a/tests/test_climate.py b/tests/test_climate.py index b3629be2e..e23dd0420 100644 --- a/tests/test_climate.py +++ b/tests/test_climate.py @@ -1641,7 +1641,7 @@ async def test_thermostat_quirkv2_local_temperature_calibration_config_overwrite zigpy_device.endpoints[1].thermostat.PLUGGED_ATTR_READS = ZCL_ATTR_PLUG ( - QuirkBuilder("unk_manufacturer", "FakeModel", zigpy.quirks._DEVICE_REGISTRY) + QuirkBuilder("unk_manufacturer", "FakeModel", zigpy.quirks.DEVICE_REGISTRY) # Local temperature calibration. .number( Thermostat.AttributeDefs.local_temperature_calibration.name, @@ -1656,7 +1656,7 @@ async def test_thermostat_quirkv2_local_temperature_calibration_config_overwrite .add_to_registry() ) - zigpy_device = zigpy.quirks._DEVICE_REGISTRY.get_device(zigpy_device) + zigpy_device = zigpy.quirks.DEVICE_REGISTRY.get_device(zigpy_device) zha_device = await join_zigpy_device(zha_gateway, zigpy_device) assert zha_device.model == "FakeModel" diff --git a/tests/test_cluster_handlers.py b/tests/test_cluster_handlers.py index cfc400160..35b65ed82 100644 --- a/tests/test_cluster_handlers.py +++ b/tests/test_cluster_handlers.py @@ -16,7 +16,7 @@ from zigpy.device import Device as ZigpyDevice from zigpy.endpoint import Endpoint as ZigpyEndpoint import zigpy.profiles.zha -from zigpy.quirks import _DEVICE_REGISTRY +from zigpy.quirks import DEVICE_REGISTRY import zigpy.types as t from zigpy.zcl import foundation import zigpy.zcl.clusters @@ -531,7 +531,7 @@ def test_cluster_handler_registry() -> None: cluster_exposed_feature_map[cluster_id] = {None} # loop over custom clusters in v2 quirks registry - for quirks in _DEVICE_REGISTRY.registry_v2.values(): + for quirks in DEVICE_REGISTRY.registry_v2.values(): for quirk_reg_entry in quirks: # get standalone adds_metadata and adds_metadata from replaces_metadata all_metadata = set(quirk_reg_entry.adds_metadata) | { @@ -541,7 +541,7 @@ def test_cluster_handler_registry() -> None: cluster_exposed_feature_map[metadata.cluster.cluster_id] = {None} # loop over custom clusters in v1 quirks registry - for manufacturer in _DEVICE_REGISTRY.registry_v1.values(): + for manufacturer in DEVICE_REGISTRY.registry_v1.values(): for model_quirk_list in manufacturer.values(): for quirk in model_quirk_list: qid: set[str] | str = getattr(quirk, ATTR_QUIRK_ID, set()) diff --git a/tests/test_discover.py b/tests/test_discover.py index 16caf9380..414d7352c 100644 --- a/tests/test_discover.py +++ b/tests/test_discover.py @@ -333,7 +333,7 @@ async def test_quirks_v2_entity_discovery( ( QuirkBuilder( - "Ikea of Sweden", "TRADFRI remote control", zigpy.quirks._DEVICE_REGISTRY + "Ikea of Sweden", "TRADFRI remote control", zigpy.quirks.DEVICE_REGISTRY ) .replaces(PowerConfig1CRCluster) .replaces(ScenesCluster, cluster_type=ClusterType.Client) @@ -352,7 +352,7 @@ async def test_quirks_v2_entity_discovery( .add_to_registry() ) - zigpy_device = zigpy.quirks._DEVICE_REGISTRY.get_device(zigpy_device) + zigpy_device = zigpy.quirks.DEVICE_REGISTRY.get_device(zigpy_device) zigpy_device.endpoints[1].power.PLUGGED_ATTR_READS = { "battery_voltage": 3, "battery_percentage_remaining": 100, @@ -454,7 +454,7 @@ class FakeXiaomiAqaraDriverE1(XiaomiAqaraDriverE1): manufacturer="LUMI", model="lumi.curtain.agl006", ) - aqara_E1_device = zigpy.quirks._DEVICE_REGISTRY.get_device(aqara_E1_device) + aqara_E1_device = zigpy.quirks.DEVICE_REGISTRY.get_device(aqara_E1_device) aqara_E1_device.endpoints[1].opple_cluster.PLUGGED_ATTR_READS = { "hand_open": 0, @@ -539,7 +539,7 @@ def _get_test_device( ) quirk_builder = ( - QuirkBuilder(manufacturer, model, zigpy.quirks._DEVICE_REGISTRY) + QuirkBuilder(manufacturer, model, zigpy.quirks.DEVICE_REGISTRY) .replaces(PowerConfig1CRCluster) .replaces(ScenesCluster, cluster_type=ClusterType.Client) .number( @@ -579,7 +579,7 @@ def _get_test_device( quirk_builder.add_to_registry() - zigpy_device = zigpy.quirks._DEVICE_REGISTRY.get_device(zigpy_device) + zigpy_device = zigpy.quirks.DEVICE_REGISTRY.get_device(zigpy_device) zigpy_device.endpoints[1].power.PLUGGED_ATTR_READS = { "battery_voltage": 3, "battery_percentage_remaining": 100, @@ -742,7 +742,7 @@ async def test_quirks_v2_metadata_bad_device_classes( assert expected_exception_string in caplog.text # remove the device so we don't pollute the rest of the tests - zigpy.quirks._DEVICE_REGISTRY.remove(zigpy_device) + zigpy.quirks.DEVICE_REGISTRY.remove(zigpy_device) async def test_quirks_v2_fallback_name(zha_gateway: Gateway) -> None: diff --git a/tests/test_registries.py b/tests/test_registries.py index bd2c507c7..3e4d68271 100644 --- a/tests/test_registries.py +++ b/tests/test_registries.py @@ -555,7 +555,7 @@ def quirk_class_validator(value): # get all quirk ID from zigpy quirks registry all_exposed_features: set[str] = set() - for manufacturer in zigpy_quirks._DEVICE_REGISTRY.registry_v1.values(): + for manufacturer in zigpy_quirks.DEVICE_REGISTRY.registry_v1.values(): for model_quirk_list in manufacturer.values(): for quirk in model_quirk_list: qid: set[str] | str = getattr(quirk, ATTR_QUIRK_ID, set()) diff --git a/tests/test_switch.py b/tests/test_switch.py index b2b47582d..7f1b8a7e1 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -14,7 +14,7 @@ ) from zigpy.exceptions import ZigbeeException from zigpy.profiles import zha -from zigpy.quirks import _DEVICE_REGISTRY, CustomCluster, CustomDevice +from zigpy.quirks import DEVICE_REGISTRY, CustomCluster, CustomDevice from zigpy.quirks.v2 import CustomDeviceV2, QuirkBuilder import zigpy.types as t from zigpy.zcl.clusters import closures, general @@ -510,7 +510,7 @@ async def test_switch_configurable_custom_on_off_values(zha_gateway: Gateway) -> .add_to_registry() ) - zigpy_device_ = _DEVICE_REGISTRY.get_device(zigpy_dev) + zigpy_device_ = DEVICE_REGISTRY.get_device(zigpy_dev) assert isinstance(zigpy_device_, CustomDeviceV2) cluster = zigpy_device_.endpoints[1].tuya_manufacturer @@ -591,7 +591,7 @@ async def test_switch_configurable_custom_on_off_values_force_inverted( .add_to_registry() ) - zigpy_device_ = _DEVICE_REGISTRY.get_device(zigpy_dev) + zigpy_device_ = DEVICE_REGISTRY.get_device(zigpy_dev) assert isinstance(zigpy_device_, CustomDeviceV2) cluster = zigpy_device_.endpoints[1].tuya_manufacturer @@ -672,7 +672,7 @@ async def test_switch_configurable_custom_on_off_values_inverter_attribute( .add_to_registry() ) - zigpy_device_ = _DEVICE_REGISTRY.get_device(zigpy_dev) + zigpy_device_ = DEVICE_REGISTRY.get_device(zigpy_dev) assert isinstance(zigpy_device_, CustomDeviceV2) cluster = zigpy_device_.endpoints[1].tuya_manufacturer From 61e98a56af431243db45ec5cb0c4818cc42f5b0b Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 19 Jan 2026 22:18:17 -0500 Subject: [PATCH 6/7] Use `attr_def` when possible --- zha/zigbee/device.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zha/zigbee/device.py b/zha/zigbee/device.py index ede995730..3c7c9c777 100644 --- a/zha/zigbee/device.py +++ b/zha/zigbee/device.py @@ -105,7 +105,7 @@ def get_cluster_attr_data(cluster: Cluster) -> list[dict]: attr_def.zcl_type.name if attr_def.zcl_type.name != "bool_" else "bool" ), "value": cluster.get(attr_def.name), - "unsupported": cluster.is_attribute_unsupported(attr_def.id), + "unsupported": cluster.is_attribute_unsupported(attr_def), } # Don't unnecessarily list out attributes that are just unread From 4ade3224dc8ffa46b6c0626cbe7d88dd6257b90a Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Mon, 19 Jan 2026 22:24:56 -0500 Subject: [PATCH 7/7] Check for attribute existence before checking if attribute is supported --- zha/application/platforms/number/__init__.py | 6 ++++-- zha/application/platforms/select.py | 7 ++++--- zha/application/platforms/sensor/__init__.py | 6 +++--- zha/application/platforms/switch.py | 7 ++++--- 4 files changed, 15 insertions(+), 11 deletions(-) diff --git a/zha/application/platforms/number/__init__.py b/zha/application/platforms/number/__init__.py index 3f0e12e45..2711918ac 100644 --- a/zha/application/platforms/number/__init__.py +++ b/zha/application/platforms/number/__init__.py @@ -210,11 +210,13 @@ def __init__( def _is_supported(self) -> bool: """Return if the entity is supported for the device, internal.""" if ( - self._cluster_handler.cluster.is_attribute_unsupported(self._attribute_name) - or ( + ( self._attribute_name not in self._cluster_handler.cluster.attributes_by_name ) + or self._cluster_handler.cluster.is_attribute_unsupported( + self._attribute_name + ) or self._cluster_handler.cluster.get(self._attribute_name) is None ): _LOGGER.debug( diff --git a/zha/application/platforms/select.py b/zha/application/platforms/select.py index 9353d8853..de23f3ac7 100644 --- a/zha/application/platforms/select.py +++ b/zha/application/platforms/select.py @@ -195,9 +195,10 @@ def on_add(self) -> None: def _is_supported(self) -> bool: if ( - self._cluster_handler.cluster.is_attribute_unsupported(self._attribute_name) - or self._attribute_name - not in self._cluster_handler.cluster.attributes_by_name + self._attribute_name not in self._cluster_handler.cluster.attributes_by_name + or self._cluster_handler.cluster.is_attribute_unsupported( + self._attribute_name + ) or self._cluster_handler.cluster.get(self._attribute_name) is None ): _LOGGER.debug( diff --git a/zha/application/platforms/sensor/__init__.py b/zha/application/platforms/sensor/__init__.py index b4b21d58b..86f8bb414 100644 --- a/zha/application/platforms/sensor/__init__.py +++ b/zha/application/platforms/sensor/__init__.py @@ -215,9 +215,9 @@ def on_add(self) -> None: def _is_supported(self) -> bool: if ( - self._cluster_handler.cluster.is_attribute_unsupported(self._attribute_name) - or self._attribute_name - not in self._cluster_handler.cluster.attributes_by_name + self._attribute_name not in self._cluster_handler.cluster.attributes_by_name + ) or self._cluster_handler.cluster.is_attribute_unsupported( + self._attribute_name ): _LOGGER.debug( "%s is not supported - skipping %s entity creation", diff --git a/zha/application/platforms/switch.py b/zha/application/platforms/switch.py index 12d983d2e..ca20c4079 100644 --- a/zha/application/platforms/switch.py +++ b/zha/application/platforms/switch.py @@ -312,9 +312,10 @@ def _init_from_quirks_metadata(self, entity_metadata: SwitchMetadata) -> None: def _is_supported(self) -> bool: if ( - self._cluster_handler.cluster.is_attribute_unsupported(self._attribute_name) - or self._attribute_name - not in self._cluster_handler.cluster.attributes_by_name + self._attribute_name not in self._cluster_handler.cluster.attributes_by_name + or self._cluster_handler.cluster.is_attribute_unsupported( + self._attribute_name + ) or self._cluster_handler.cluster.get(self._attribute_name) is None ): _LOGGER.debug(