From df8828942e5f063c41ceb1bd7d9819201dabf48d 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/2] Migrate to new zigpy ZCL attribute system --- tests/common.py | 15 ++- tests/test_device.py | 15 ++- tests/test_discover.py | 6 +- zha/application/gateway.py | 4 +- zha/zigbee/cluster_handlers/__init__.py | 92 ++++++++++++------- zha/zigbee/cluster_handlers/general.py | 39 ++++++-- .../cluster_handlers/manufacturerspecific.py | 36 +++++--- 7 files changed, 135 insertions(+), 72 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/application/gateway.py b/zha/application/gateway.py index 79984b032..d6bff81a8 100644 --- a/zha/application/gateway.py +++ b/zha/application/gateway.py @@ -348,7 +348,9 @@ def load_groups(self) -> None: @property def radio_concurrency(self) -> int: """Maximum configured radio concurrency.""" - return self.application_controller._concurrent_requests_semaphore.max_value # pylint: disable=protected-access + return ( + self.application_controller._concurrent_requests_semaphore.max_concurrency + ) # pylint: disable=protected-access async def async_fetch_updated_state_mains(self) -> None: """Fetch updated state for mains powered devices.""" 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 dad8c6ff2dca36c6226419e8c022f514b56e464f 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/2] 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 ff2abb4cd..4959d8451 100644 --- a/tests/test_light.py +++ b/tests/test_light.py @@ -459,7 +459,6 @@ async def test_light( transition_time=100.0, expect_reply=True, manufacturer=None, - tsn=None, ) cluster_color.request.reset_mock() @@ -481,7 +480,6 @@ async def test_light( transition_time=0, expect_reply=True, manufacturer=None, - tsn=None, ) cluster_color.request.reset_mock() @@ -558,7 +556,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) @@ -584,7 +581,6 @@ async def async_test_off_from_client( cluster.commands_by_name["off"].schema, expect_reply=True, manufacturer=None, - tsn=None, ) @@ -628,7 +624,6 @@ async def _reset_light(): on_off_cluster.commands_by_name["on"].schema, expect_reply=True, manufacturer=None, - tsn=None, ) await _reset_light() @@ -649,7 +644,6 @@ async def _reset_light(): transition_time=100, expect_reply=True, manufacturer=None, - tsn=None, ) await _reset_light() @@ -670,7 +664,6 @@ async def _reset_light(): transition_time=int(expected_default_transition), expect_reply=True, manufacturer=None, - tsn=None, ) await _reset_light() @@ -725,7 +718,6 @@ async def async_test_flash_from_client( effect_variant=general.Identify.EffectVariant.Default, expect_reply=True, manufacturer=None, - tsn=None, ) @@ -1177,7 +1169,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 @@ -1197,7 +1188,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 @@ -1211,7 +1201,6 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tsn=None, ) assert bool(eWeLink_light_entity.state["on"]) is True @@ -1239,7 +1228,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 @@ -1266,7 +1254,6 @@ async def test_transitions( transition_time=35, expect_reply=True, manufacturer=None, - tsn=None, ) assert dev1_cluster_color.request.call_args == call( False, @@ -1276,7 +1263,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 @@ -1304,7 +1290,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 @@ -1332,7 +1317,6 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tsn=None, ) assert dev1_cluster_color.request.call_args == call( False, @@ -1342,7 +1326,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, @@ -1352,7 +1335,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 @@ -1398,7 +1380,6 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tsn=None, ) assert dev1_cluster_color.request.call_args == call( False, @@ -1408,7 +1389,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, @@ -1418,7 +1398,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 @@ -1460,7 +1439,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( @@ -1471,7 +1449,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 @@ -1518,7 +1495,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 @@ -1560,7 +1536,6 @@ async def test_transitions( transition_time=1, expect_reply=True, manufacturer=None, - tsn=None, ) assert dev2_cluster_color.request.call_args == call( False, @@ -1570,7 +1545,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, @@ -1580,7 +1554,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 @@ -1627,7 +1600,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, @@ -1637,7 +1609,6 @@ async def test_transitions( transition_time=10, expect_reply=True, manufacturer=None, - tsn=None, ) assert bool(entity.state["on"]) is True @@ -1679,7 +1650,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 @@ -1703,7 +1673,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 @@ -1730,7 +1699,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, @@ -1740,7 +1708,6 @@ async def test_transitions( transition_time=0, expect_reply=True, manufacturer=None, - tsn=None, ) assert bool(eWeLink_light_entity.state["on"]) is True @@ -1797,7 +1764,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, @@ -1807,7 +1773,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 @@ -1851,7 +1816,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, @@ -1861,7 +1825,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, @@ -1871,7 +1834,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