From ed912945408bcb887604b890788ff2819781e805 Mon Sep 17 00:00:00 2001 From: oddlama Date: Tue, 17 Jun 2025 21:54:56 +0200 Subject: [PATCH 01/11] feat: add support for BinaryOutput cluster --- zha/application/platforms/switch.py | 78 +++++++++++++++++++++++++- zha/application/registries.py | 1 + zha/zigbee/cluster_handlers/const.py | 1 + zha/zigbee/cluster_handlers/general.py | 26 +++++++++ 4 files changed, 104 insertions(+), 2 deletions(-) diff --git a/zha/application/platforms/switch.py b/zha/application/platforms/switch.py index a3a3201a7..c5638598e 100644 --- a/zha/application/platforms/switch.py +++ b/zha/application/platforms/switch.py @@ -11,7 +11,7 @@ from zhaquirks.quirk_ids import DANFOSS_ALLY_THERMOSTAT, TUYA_PLUG_ONOFF from zigpy.quirks.v2 import SwitchMetadata from zigpy.zcl.clusters.closures import ConfigStatus, WindowCovering, WindowCoveringMode -from zigpy.zcl.clusters.general import OnOff +from zigpy.zcl.clusters.general import OnOff, BinaryOutput from zigpy.zcl.foundation import Status from zha.application import Platform @@ -27,6 +27,7 @@ from zha.zigbee.cluster_handlers.const import ( CLUSTER_HANDLER_ATTRIBUTE_UPDATED, CLUSTER_HANDLER_BASIC, + CLUSTER_HANDLER_BINARY_OUTPUT, CLUSTER_HANDLER_COVER, CLUSTER_HANDLER_INOVELLI, CLUSTER_HANDLER_ON_OFF, @@ -36,7 +37,7 @@ from zha.zigbee.group import Group if TYPE_CHECKING: - from zha.zigbee.cluster_handlers import ClusterHandler + from zha.zigbee.cluster_handlers import ClusterHandler, BinaryOutputClusterHandler from zha.zigbee.device import Device from zha.zigbee.endpoint import Endpoint @@ -140,6 +141,79 @@ def handle_cluster_handler_attribute_updated( self.maybe_emit_state_changed_event() +@STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_BINARY_OUTPUT) +class BinaryOutputSwitch(PlatformEntity): + """BinaryOutputCluster switch.""" + + PLATFORM = Platform.SWITCH + _attr_primary_weight = 10 + + def __init__( + self, + cluster_handlers: list[ClusterHandler], + endpoint: Endpoint, + device: Device, + **kwargs: Any, + ) -> None: + """Initialize the switch.""" + super().__init__(cluster_handlers, endpoint, device, **kwargs) + self._binary_output_cluster_handler: BinaryOutputClusterHandler = self.cluster_handlers[ + CLUSTER_HANDLER_BINARY_OUTPUT + ] + + def _is_supported(self) -> bool: + if self._cluster_handler.description is None: + return False + + return super()._is_supported() + + def recompute_capabilities(self) -> None: + """Recompute capabilities.""" + super().recompute_capabilities() + self._attr_fallback_name = self._binary_output_cluster_handler.description + + def on_add(self) -> None: + """Run when entity is added.""" + super().on_add() + self._on_remove_callbacks.append( + self._binary_output_cluster_handler.on_event( + CLUSTER_HANDLER_ATTRIBUTE_UPDATED, + self.handle_cluster_handler_attribute_updated, + ) + ) + + @property + def state(self) -> dict[str, Any]: + """Return the state of the switch.""" + response = super().state + response["state"] = self.is_on + return response + + @property + def is_on(self) -> bool: + """Return if the switch is on based on the statemachine.""" + if self._binary_output_cluster_handler.present_value is None: + return False + return self._binary_output_cluster_handler.present_value + + async def async_turn_on(self, **kwargs: Any) -> None: # pylint: disable=unused-argument + """Turn the entity on.""" + await self._binary_output_cluster_handler.async_set_present_value(True) + self.maybe_emit_state_changed_event() + + async def async_turn_off(self, **kwargs: Any) -> None: # pylint: disable=unused-argument + """Turn the entity off.""" + await self._binary_output_cluster_handler.async_set_present_value(False) + self.maybe_emit_state_changed_event() + + def handle_cluster_handler_attribute_updated( + self, + event: ClusterAttributeUpdatedEvent, # pylint: disable=unused-argument + ) -> None: + """Handle state update from cluster handler.""" + self.maybe_emit_state_changed_event() + + @GROUP_MATCH() class SwitchGroup(GroupEntity, BaseSwitch): """Representation of a switch group.""" diff --git a/zha/application/registries.py b/zha/application/registries.py index 9334866d7..cb874e6fe 100644 --- a/zha/application/registries.py +++ b/zha/application/registries.py @@ -52,6 +52,7 @@ # a different dict that is keyed by manufacturer zcl.clusters.general.AnalogOutput.cluster_id: Platform.NUMBER, zcl.clusters.general.AnalogInput.cluster_id: Platform.SENSOR, + zcl.clusters.general.BinaryOutput.cluster_id: Platform.SWITCH, zcl.clusters.general.MultistateInput.cluster_id: Platform.SENSOR, zcl.clusters.general.OnOff.cluster_id: Platform.SWITCH, zcl.clusters.hvac.Fan.cluster_id: Platform.FAN, diff --git a/zha/zigbee/cluster_handlers/const.py b/zha/zigbee/cluster_handlers/const.py index bd6dd79c0..3711359d9 100644 --- a/zha/zigbee/cluster_handlers/const.py +++ b/zha/zigbee/cluster_handlers/const.py @@ -40,6 +40,7 @@ CLUSTER_HANDLER_ACCELEROMETER: Final[str] = "accelerometer" CLUSTER_HANDLER_BINARY_INPUT: Final[str] = "binary_input" +CLUSTER_HANDLER_BINARY_OUTPUT: Final[str] = "binary_output" CLUSTER_HANDLER_ANALOG_INPUT: Final[str] = "analog_input" CLUSTER_HANDLER_ANALOG_OUTPUT: Final[str] = "analog_output" CLUSTER_HANDLER_ATTRIBUTE: Final[str] = "attribute" diff --git a/zha/zigbee/cluster_handlers/general.py b/zha/zigbee/cluster_handlers/general.py index 61d4b2c39..ac3eb80d2 100644 --- a/zha/zigbee/cluster_handlers/general.py +++ b/zha/zigbee/cluster_handlers/general.py @@ -325,6 +325,32 @@ class BinaryOutputClusterHandler(ClusterHandler): ), ) + ZCL_INIT_ATTRS = { + BinaryOutput.AttributeDefs.description.name: True, + } + + @property + def description(self) -> str | None: + """Return cached value of description.""" + return self.cluster.get(BinaryOutput.AttributeDefs.description.name) + + @property + def present_value(self) -> bool | None: + """Return cached value of present_value.""" + return self.cluster.get(BinaryOutput.AttributeDefs.present_value.name) + + async def async_set_present_value(self, value: bool) -> None: + """Update present_value.""" + await self.write_attributes_safe( + {BinaryOutput.AttributeDefs.present_value.name: value} + ) + + async def async_update(self): + """Update cluster value attribute.""" + await self.get_attribute_value( + BinaryOutput.AttributeDefs.present_value.name, from_cache=False + ) + @registries.CLUSTER_HANDLER_REGISTRY.register(BinaryValue.cluster_id) class BinaryValueClusterHandler(ClusterHandler): From be16d9000b9309819cb26f27c8718e6fdda2fc95 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 18 Jun 2025 18:50:14 +0000 Subject: [PATCH 02/11] Apply pre-commit auto fixes --- zha/application/platforms/switch.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/zha/application/platforms/switch.py b/zha/application/platforms/switch.py index c5638598e..e16bf0b74 100644 --- a/zha/application/platforms/switch.py +++ b/zha/application/platforms/switch.py @@ -11,7 +11,7 @@ from zhaquirks.quirk_ids import DANFOSS_ALLY_THERMOSTAT, TUYA_PLUG_ONOFF from zigpy.quirks.v2 import SwitchMetadata from zigpy.zcl.clusters.closures import ConfigStatus, WindowCovering, WindowCoveringMode -from zigpy.zcl.clusters.general import OnOff, BinaryOutput +from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.foundation import Status from zha.application import Platform @@ -37,7 +37,7 @@ from zha.zigbee.group import Group if TYPE_CHECKING: - from zha.zigbee.cluster_handlers import ClusterHandler, BinaryOutputClusterHandler + from zha.zigbee.cluster_handlers import BinaryOutputClusterHandler, ClusterHandler from zha.zigbee.device import Device from zha.zigbee.endpoint import Endpoint @@ -157,9 +157,9 @@ def __init__( ) -> None: """Initialize the switch.""" super().__init__(cluster_handlers, endpoint, device, **kwargs) - self._binary_output_cluster_handler: BinaryOutputClusterHandler = self.cluster_handlers[ - CLUSTER_HANDLER_BINARY_OUTPUT - ] + self._binary_output_cluster_handler: BinaryOutputClusterHandler = ( + self.cluster_handlers[CLUSTER_HANDLER_BINARY_OUTPUT] + ) def _is_supported(self) -> bool: if self._cluster_handler.description is None: From f2b6703c40e9d2dc7eb487aa9ddc88ece60bfea8 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 24 Jun 2025 21:23:00 -0400 Subject: [PATCH 03/11] Fix typo in `_is_supported` --- zha/application/platforms/switch.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/zha/application/platforms/switch.py b/zha/application/platforms/switch.py index e16bf0b74..f1139906f 100644 --- a/zha/application/platforms/switch.py +++ b/zha/application/platforms/switch.py @@ -162,7 +162,7 @@ def __init__( ) def _is_supported(self) -> bool: - if self._cluster_handler.description is None: + if self._binary_output_cluster_handler.description is None: return False return super()._is_supported() From 24ad5a4e80adcdabe605962b3ccb082b28eb7087 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 24 Jun 2025 21:23:29 -0400 Subject: [PATCH 04/11] Derive from `BaseSwitch` to reduce duplication --- zha/application/platforms/switch.py | 12 +----------- 1 file changed, 1 insertion(+), 11 deletions(-) diff --git a/zha/application/platforms/switch.py b/zha/application/platforms/switch.py index f1139906f..4631e6e43 100644 --- a/zha/application/platforms/switch.py +++ b/zha/application/platforms/switch.py @@ -142,12 +142,9 @@ def handle_cluster_handler_attribute_updated( @STRICT_MATCH(cluster_handler_names=CLUSTER_HANDLER_BINARY_OUTPUT) -class BinaryOutputSwitch(PlatformEntity): +class BinaryOutputSwitch(PlatformEntity, BaseSwitch): """BinaryOutputCluster switch.""" - PLATFORM = Platform.SWITCH - _attr_primary_weight = 10 - def __init__( self, cluster_handlers: list[ClusterHandler], @@ -182,13 +179,6 @@ def on_add(self) -> None: ) ) - @property - def state(self) -> dict[str, Any]: - """Return the state of the switch.""" - response = super().state - response["state"] = self.is_on - return response - @property def is_on(self) -> bool: """Return if the switch is on based on the statemachine.""" From ff2d5d390b7bc03895a8e933142407c300dd46cd Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 24 Jun 2025 21:23:35 -0400 Subject: [PATCH 05/11] Clean up --- zha/application/platforms/switch.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/zha/application/platforms/switch.py b/zha/application/platforms/switch.py index 4631e6e43..9a038c727 100644 --- a/zha/application/platforms/switch.py +++ b/zha/application/platforms/switch.py @@ -11,7 +11,7 @@ from zhaquirks.quirk_ids import DANFOSS_ALLY_THERMOSTAT, TUYA_PLUG_ONOFF from zigpy.quirks.v2 import SwitchMetadata from zigpy.zcl.clusters.closures import ConfigStatus, WindowCovering, WindowCoveringMode -from zigpy.zcl.clusters.general import OnOff +from zigpy.zcl.clusters.general import BinaryOutput, OnOff from zigpy.zcl.foundation import Status from zha.application import Platform @@ -181,7 +181,7 @@ def on_add(self) -> None: @property def is_on(self) -> bool: - """Return if the switch is on based on the statemachine.""" + """Return if the switch is on.""" if self._binary_output_cluster_handler.present_value is None: return False return self._binary_output_cluster_handler.present_value @@ -201,7 +201,8 @@ def handle_cluster_handler_attribute_updated( event: ClusterAttributeUpdatedEvent, # pylint: disable=unused-argument ) -> None: """Handle state update from cluster handler.""" - self.maybe_emit_state_changed_event() + if event.attribute_name == BinaryOutput.AttributeDefs.present_value.name: + self.maybe_emit_state_changed_event() @GROUP_MATCH() From aefc63c45de4787efc5913ca7066be4643c3382d Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 24 Jun 2025 21:32:01 -0400 Subject: [PATCH 06/11] Fix failing unit test --- zha/application/platforms/switch.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/zha/application/platforms/switch.py b/zha/application/platforms/switch.py index 9a038c727..93b92039a 100644 --- a/zha/application/platforms/switch.py +++ b/zha/application/platforms/switch.py @@ -145,6 +145,8 @@ def handle_cluster_handler_attribute_updated( class BinaryOutputSwitch(PlatformEntity, BaseSwitch): """BinaryOutputCluster switch.""" + _attr_translation_key = "switch" + def __init__( self, cluster_handlers: list[ClusterHandler], From f9ad49d7843b64fb5e4f9b15c6c323abe158bbc7 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 24 Jun 2025 21:46:49 -0400 Subject: [PATCH 07/11] Add a synthetic unit test device --- .../espressif-zigbeebinaryoutputdevice.json | 564 ++++++++++++++++++ 1 file changed, 564 insertions(+) create mode 100644 tests/data/devices/espressif-zigbeebinaryoutputdevice.json diff --git a/tests/data/devices/espressif-zigbeebinaryoutputdevice.json b/tests/data/devices/espressif-zigbeebinaryoutputdevice.json new file mode 100644 index 000000000..9f1983a94 --- /dev/null +++ b/tests/data/devices/espressif-zigbeebinaryoutputdevice.json @@ -0,0 +1,564 @@ +{ + "version": 1, + "ieee": "ab:cd:ef:12:39:a2:73:56", + "nwk": "0xA62B", + "manufacturer": "Espressif", + "model": "ZigbeeBinaryAnalogDevice", + "friendly_manufacturer": "Espressif", + "friendly_model": "ZigbeeBinaryAnalogDevice", + "name": "Espressif ZigbeeBinaryAnalogDevice", + "quirk_applied": false, + "quirk_class": "zigpy.device.Device", + "quirk_id": null, + "manufacturer_code": 4660, + "power_source": "Mains", + "lqi": null, + "rssi": null, + "last_seen": "2025-04-29T19:12:44.332742+00:00", + "available": true, + "device_type": "EndDevice", + "active_coordinator": false, + "node_descriptor": { + "logical_type": "EndDevice", + "complex_descriptor_available": false, + "user_descriptor_available": false, + "reserved": 0, + "aps_flags": 0, + "frequency_band": 8, + "mac_capability_flags": 140, + "manufacturer_code": 4660, + "maximum_buffer_size": 108, + "maximum_incoming_transfer_size": 1613, + "server_mask": 11264, + "maximum_outgoing_transfer_size": 1613, + "descriptor_capability_field": 0 + }, + "endpoints": { + "1": { + "profile_id": 260, + "device_type": { + "name": "SIMPLE_SENSOR", + "id": 12 + }, + "in_clusters": [ + { + "cluster_id": "0x0000", + "endpoint_attribute": "basic", + "attributes": [ + { + "id": "0x0013", + "name": "alarm_mask", + "zcl_type": "map8", + "value": null, + "unsupported": false + }, + { + "id": "0x0001", + "name": "app_version", + "zcl_type": "uint8", + "value": null, + "unsupported": false + }, + { + "id": "0xfffd", + "name": "cluster_revision", + "zcl_type": "uint16", + "value": null, + "unsupported": false + }, + { + "id": "0x0006", + "name": "date_code", + "zcl_type": "string", + "value": null, + "unsupported": false + }, + { + "id": "0x0012", + "name": "device_enabled", + "zcl_type": "bool_", + "value": null, + "unsupported": false + }, + { + "id": "0x0014", + "name": "disable_local_config", + "zcl_type": "map8", + "value": null, + "unsupported": false + }, + { + "id": "0x0008", + "name": "generic_device_class", + "zcl_type": "enum8", + "value": null, + "unsupported": false + }, + { + "id": "0x0009", + "name": "generic_device_type", + "zcl_type": "enum8", + "value": null, + "unsupported": false + }, + { + "id": "0x0003", + "name": "hw_version", + "zcl_type": "uint8", + "value": null, + "unsupported": false + }, + { + "id": "0x0010", + "name": "location_desc", + "zcl_type": "string", + "value": null, + "unsupported": false + }, + { + "id": "0x0004", + "name": "manufacturer", + "zcl_type": "string", + "value": "Espressif", + "unsupported": false + }, + { + "id": "0x000c", + "name": "manufacturer_version_details", + "zcl_type": "string", + "value": null, + "unsupported": false + }, + { + "id": "0x0005", + "name": "model", + "zcl_type": "string", + "value": "ZigbeeBinaryAnalogDevice", + "unsupported": false + }, + { + "id": "0x0011", + "name": "physical_env", + "zcl_type": "enum8", + "value": null, + "unsupported": false + }, + { + "id": "0x0007", + "name": "power_source", + "zcl_type": "enum8", + "value": null, + "unsupported": false + }, + { + "id": "0x000a", + "name": "product_code", + "zcl_type": "octstr", + "value": null, + "unsupported": false + }, + { + "id": "0x000e", + "name": "product_label", + "zcl_type": "string", + "value": null, + "unsupported": false + }, + { + "id": "0x000b", + "name": "product_url", + "zcl_type": "string", + "value": null, + "unsupported": false + }, + { + "id": "0xfffe", + "name": "reporting_status", + "zcl_type": "enum8", + "value": null, + "unsupported": false + }, + { + "id": "0x000d", + "name": "serial_number", + "zcl_type": "string", + "value": null, + "unsupported": false + }, + { + "id": "0x0002", + "name": "stack_version", + "zcl_type": "uint8", + "value": null, + "unsupported": false + }, + { + "id": "0x4000", + "name": "sw_build_id", + "zcl_type": "string", + "value": null, + "unsupported": false + }, + { + "id": "0x0000", + "name": "zcl_version", + "zcl_type": "uint8", + "value": null, + "unsupported": false + } + ] + }, + { + "cluster_id": "0x0003", + "endpoint_attribute": "identify", + "attributes": [ + { + "id": "0xfffd", + "name": "cluster_revision", + "zcl_type": "uint16", + "value": null, + "unsupported": false + }, + { + "id": "0x0000", + "name": "identify_time", + "zcl_type": "uint16", + "value": null, + "unsupported": false + }, + { + "id": "0xfffe", + "name": "reporting_status", + "zcl_type": "enum8", + "value": null, + "unsupported": false + } + ] + }, + { + "cluster_id": "0x0010", + "endpoint_attribute": "binary_output", + "attributes": [ + { + "id": "0x0004", + "name": "active_text", + "zcl_type": "string", + "value": null, + "unsupported": false + }, + { + "id": "0x0100", + "name": "application_type", + "zcl_type": "uint32", + "value": null, + "unsupported": false + }, + { + "id": "0xfffd", + "name": "cluster_revision", + "zcl_type": "uint16", + "value": null, + "unsupported": false + }, + { + "id": "0x001c", + "name": "description", + "zcl_type": "string", + "value": "Entity Description", + "unsupported": false + }, + { + "id": "0x002e", + "name": "inactive_text", + "zcl_type": "string", + "value": null, + "unsupported": false + }, + { + "id": "0x0042", + "name": "minimum_off_time", + "zcl_type": "uint32", + "value": null, + "unsupported": false + }, + { + "id": "0x0043", + "name": "minimum_on_time", + "zcl_type": "uint32", + "value": null, + "unsupported": false + }, + { + "id": "0x0051", + "name": "out_of_service", + "zcl_type": "bool_", + "value": null, + "unsupported": false + }, + { + "id": "0x0054", + "name": "polarity", + "zcl_type": "enum8", + "value": null, + "unsupported": false + }, + { + "id": "0x0055", + "name": "present_value", + "zcl_type": "bool_", + "value": true, + "unsupported": false + }, + { + "id": "0x0067", + "name": "reliability", + "zcl_type": "enum8", + "value": null, + "unsupported": false + }, + { + "id": "0x0068", + "name": "relinquish_default", + "zcl_type": "bool_", + "value": null, + "unsupported": false + }, + { + "id": "0xfffe", + "name": "reporting_status", + "zcl_type": "enum8", + "value": null, + "unsupported": false + }, + { + "id": "0x006f", + "name": "status_flags", + "zcl_type": "map8", + "value": null, + "unsupported": false + } + ] + } + ], + "out_clusters": [] + } + }, + "zha_lib_entities": { + "button": [ + { + "info_object": { + "fallback_name": null, + "unique_id": "ab:cd:ef:12:39:a2:73:56-1-3", + "migrate_unique_ids": [], + "platform": "button", + "class_name": "IdentifyButton", + "translation_key": null, + "device_class": "identify", + "state_class": null, + "entity_category": "diagnostic", + "entity_registry_enabled_default": true, + "enabled": true, + "primary": false, + "cluster_handlers": [ + { + "class_name": "IdentifyClusterHandler", + "generic_id": "cluster_handler_0x0003", + "endpoint_id": 1, + "cluster": { + "id": 3, + "name": "Identify", + "type": "server" + }, + "id": "1:0x0003", + "unique_id": "ab:cd:ef:12:39:a2:73:56:1:0x0003", + "status": "INITIALIZED", + "value_attribute": null + } + ], + "device_ieee": [ + 86, + 115, + 162, + 57, + 18, + 239, + 205, + 171 + ], + "endpoint_id": 1, + "available": true, + "group_id": null, + "command": "identify", + "args": [ + 5 + ], + "kwargs": {} + }, + "state": { + "class_name": "IdentifyButton", + "available": true + } + } + ], + "sensor": [ + { + "info_object": { + "fallback_name": null, + "unique_id": "ab:cd:ef:12:39:a2:73:56-1-0-lqi", + "migrate_unique_ids": [], + "platform": "sensor", + "class_name": "LQISensor", + "translation_key": "lqi", + "device_class": null, + "state_class": "measurement", + "entity_category": "diagnostic", + "entity_registry_enabled_default": false, + "enabled": true, + "primary": false, + "cluster_handlers": [ + { + "class_name": "BasicClusterHandler", + "generic_id": "cluster_handler_0x0000", + "endpoint_id": 1, + "cluster": { + "id": 0, + "name": "Basic", + "type": "server" + }, + "id": "1:0x0000", + "unique_id": "ab:cd:ef:12:39:a2:73:56:1:0x0000", + "status": "INITIALIZED", + "value_attribute": null + } + ], + "device_ieee": [ + 86, + 115, + 162, + 57, + 18, + 239, + 205, + 171 + ], + "endpoint_id": 1, + "available": true, + "group_id": null, + "suggested_display_precision": null, + "unit": null + }, + "state": { + "class_name": "LQISensor", + "available": true, + "state": null + } + }, + { + "info_object": { + "fallback_name": null, + "unique_id": "ab:cd:ef:12:39:a2:73:56-1-0-rssi", + "migrate_unique_ids": [], + "platform": "sensor", + "class_name": "RSSISensor", + "translation_key": "rssi", + "device_class": "signal_strength", + "state_class": "measurement", + "entity_category": "diagnostic", + "entity_registry_enabled_default": false, + "enabled": true, + "primary": false, + "cluster_handlers": [ + { + "class_name": "BasicClusterHandler", + "generic_id": "cluster_handler_0x0000", + "endpoint_id": 1, + "cluster": { + "id": 0, + "name": "Basic", + "type": "server" + }, + "id": "1:0x0000", + "unique_id": "ab:cd:ef:12:39:a2:73:56:1:0x0000", + "status": "INITIALIZED", + "value_attribute": null + } + ], + "device_ieee": [ + 86, + 115, + 162, + 57, + 18, + 239, + 205, + 171 + ], + "endpoint_id": 1, + "available": true, + "group_id": null, + "suggested_display_precision": null, + "unit": "dBm" + }, + "state": { + "class_name": "RSSISensor", + "available": true, + "state": null + } + } + ], + "switch": [ + { + "info_object": { + "fallback_name": "Entity Description", + "unique_id": "ab:cd:ef:12:39:a2:73:56-1-16", + "migrate_unique_ids": [], + "platform": "switch", + "class_name": "BinaryOutputSwitch", + "translation_key": null, + "device_class": null, + "state_class": null, + "entity_category": null, + "entity_registry_enabled_default": true, + "enabled": true, + "primary": true, + "cluster_handlers": [ + { + "class_name": "BinaryOutputClusterHandler", + "generic_id": "cluster_handler_0x0010", + "endpoint_id": 1, + "cluster": { + "id": 16, + "name": "BinaryOutput", + "type": "server" + }, + "id": "1:0x0010", + "unique_id": "ab:cd:ef:12:39:a2:73:56:1:0x0010", + "status": "INITIALIZED", + "value_attribute": "present_value" + } + ], + "device_ieee": [ + 86, + 115, + 162, + 57, + 18, + 239, + 205, + 171 + ], + "endpoint_id": 1, + "available": true, + "group_id": null + }, + "state": { + "class_name": "BinaryOutputSwitch", + "state": true, + "available": true + } + } + ] + }, + "neighbors": [], + "routes": [] +} \ No newline at end of file From cf3d2e10c2929b8977e563bd982fdd94a513513b Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 24 Jun 2025 21:47:05 -0400 Subject: [PATCH 08/11] Replace unnecessary translation key with a unit test exemption --- tests/test_registries.py | 2 ++ zha/application/platforms/switch.py | 2 -- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_registries.py b/tests/test_registries.py index 756144a22..efd72a04a 100644 --- a/tests/test_registries.py +++ b/tests/test_registries.py @@ -15,6 +15,7 @@ from zha.application.platforms.binary_sensor import BinaryInputWithDescription, IASZone from zha.application.platforms.number import AnalogOutputNumber from zha.application.platforms.sensor import AnalogInputSensor +from zha.application.platforms.switch import BinaryOutputSwitch from zha.application.registries import ( PLATFORM_ENTITIES, MatchRule, @@ -578,6 +579,7 @@ def test_entity_names() -> None: assert entity_class in ( IASZone, BinaryInputWithDescription, + BinaryOutputSwitch, AnalogInputSensor, AnalogOutputNumber, ) diff --git a/zha/application/platforms/switch.py b/zha/application/platforms/switch.py index 93b92039a..9a038c727 100644 --- a/zha/application/platforms/switch.py +++ b/zha/application/platforms/switch.py @@ -145,8 +145,6 @@ def handle_cluster_handler_attribute_updated( class BinaryOutputSwitch(PlatformEntity, BaseSwitch): """BinaryOutputCluster switch.""" - _attr_translation_key = "switch" - def __init__( self, cluster_handlers: list[ClusterHandler], From 3a29257a1a5cca93fbfe0619de188355faeb7cc2 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 24 Jun 2025 22:03:34 -0400 Subject: [PATCH 09/11] Add an explicit unit test --- tests/test_switch.py | 41 +++++++++++++++++++++++++++++ zha/application/platforms/switch.py | 2 +- 2 files changed, 42 insertions(+), 1 deletion(-) diff --git a/tests/test_switch.py b/tests/test_switch.py index b5718525c..6e8c30ab6 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -18,6 +18,7 @@ from zigpy.quirks.v2 import CustomDeviceV2, QuirkBuilder import zigpy.types as t from zigpy.zcl.clusters import closures, general +from zigpy.zcl.clusters.general import BinaryOutput from zigpy.zcl.clusters.manufacturer_specific import ManufacturerSpecificCluster import zigpy.zcl.foundation as zcl_f @@ -33,6 +34,7 @@ join_zigpy_device, send_attributes_report, update_attribute_cache, + zigpy_device_from_json, ) from zha.application import Platform from zha.application.gateway import Gateway @@ -851,3 +853,42 @@ async def test_cover_inversion_switch_not_created(zha_gateway: Gateway) -> None: # entity should not be created when mode or config status aren't present with pytest.raises(KeyError): get_entity(zha_device, platform=Platform.SWITCH) + + +async def test_binary_output_cluster(zha_gateway: Gateway) -> None: + """Test ZHA switch platform with binary output cluster.""" + + zigpy_device = await zigpy_device_from_json( + zha_gateway.application_controller, + "tests/data/devices/espressif-zigbeebinaryoutputdevice.json", + ) + zha_device = await join_zigpy_device(zha_gateway, zigpy_device) + switch_entity = get_entity(zha_device, platform=Platform.SWITCH) + cluster = zigpy_device.endpoints[1].binary_output + + assert switch_entity.info_object.fallback_name == "Entity Description" + assert switch_entity.state["state"] is True + + # Turn it off + cluster.write_attributes.reset_mock() + await switch_entity.async_turn_off() + assert cluster.write_attributes.mock_calls == [ + call({"present_value": False}, manufacturer=None) + ] + assert switch_entity.state["state"] is False + + # Turn it on + cluster.write_attributes.reset_mock() + await switch_entity.async_turn_on() + assert cluster.write_attributes.mock_calls == [ + call({"present_value": True}, manufacturer=None) + ] + assert switch_entity.state["state"] is True + + # Report an attribute change + await send_attributes_report( + zha_gateway, + cluster, + {BinaryOutput.AttributeDefs.present_value.id: t.Bool(False)}, + ) + assert switch_entity.state["state"] is False diff --git a/zha/application/platforms/switch.py b/zha/application/platforms/switch.py index 9a038c727..279bf198d 100644 --- a/zha/application/platforms/switch.py +++ b/zha/application/platforms/switch.py @@ -184,7 +184,7 @@ def is_on(self) -> bool: """Return if the switch is on.""" if self._binary_output_cluster_handler.present_value is None: return False - return self._binary_output_cluster_handler.present_value + return bool(self._binary_output_cluster_handler.present_value) async def async_turn_on(self, **kwargs: Any) -> None: # pylint: disable=unused-argument """Turn the entity on.""" From f69b94ac0c6629528d4c2e853b6e68c51816e035 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 24 Jun 2025 22:17:29 -0400 Subject: [PATCH 10/11] Increase test coverage to encompass entity handling of `None` value --- tests/test_switch.py | 21 ++++++++++++--------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/tests/test_switch.py b/tests/test_switch.py index 6e8c30ab6..41eabcca4 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -862,19 +862,14 @@ async def test_binary_output_cluster(zha_gateway: Gateway) -> None: zha_gateway.application_controller, "tests/data/devices/espressif-zigbeebinaryoutputdevice.json", ) + cluster = zigpy_device.endpoints[1].binary_output zha_device = await join_zigpy_device(zha_gateway, zigpy_device) switch_entity = get_entity(zha_device, platform=Platform.SWITCH) - cluster = zigpy_device.endpoints[1].binary_output - assert switch_entity.info_object.fallback_name == "Entity Description" - assert switch_entity.state["state"] is True + # Clear out the attribute first + cluster.update_attribute(BinaryOutput.AttributeDefs.present_value.id, None) - # Turn it off - cluster.write_attributes.reset_mock() - await switch_entity.async_turn_off() - assert cluster.write_attributes.mock_calls == [ - call({"present_value": False}, manufacturer=None) - ] + assert switch_entity.info_object.fallback_name == "Entity Description" assert switch_entity.state["state"] is False # Turn it on @@ -885,6 +880,14 @@ async def test_binary_output_cluster(zha_gateway: Gateway) -> None: ] assert switch_entity.state["state"] is True + # Turn it off + cluster.write_attributes.reset_mock() + await switch_entity.async_turn_off() + assert cluster.write_attributes.mock_calls == [ + call({"present_value": False}, manufacturer=None) + ] + assert switch_entity.state["state"] is False + # Report an attribute change await send_attributes_report( zha_gateway, From cd1926659fc8137fd8a327232bac5f9d8ba49a12 Mon Sep 17 00:00:00 2001 From: puddly <32534428+puddly@users.noreply.github.com> Date: Tue, 24 Jun 2025 22:32:38 -0400 Subject: [PATCH 11/11] Extend unit test to handle `async_update` as well --- tests/test_switch.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/tests/test_switch.py b/tests/test_switch.py index 41eabcca4..09493864f 100644 --- a/tests/test_switch.py +++ b/tests/test_switch.py @@ -866,7 +866,7 @@ async def test_binary_output_cluster(zha_gateway: Gateway) -> None: zha_device = await join_zigpy_device(zha_gateway, zigpy_device) switch_entity = get_entity(zha_device, platform=Platform.SWITCH) - # Clear out the attribute first + # Clear out the attribute first, to test handling of the missing state cluster.update_attribute(BinaryOutput.AttributeDefs.present_value.id, None) assert switch_entity.info_object.fallback_name == "Entity Description" @@ -895,3 +895,19 @@ async def test_binary_output_cluster(zha_gateway: Gateway) -> None: {BinaryOutput.AttributeDefs.present_value.id: t.Bool(False)}, ) assert switch_entity.state["state"] is False + + # Force an update + cluster.read_attributes.reset_mock() + cluster.PLUGGED_ATTR_READS = {BinaryOutput.AttributeDefs.present_value.name: True} + + await switch_entity.async_update() + assert switch_entity.state["state"] is True + + assert cluster.read_attributes.mock_calls == [ + call( + [BinaryOutput.AttributeDefs.present_value.name], + allow_cache=False, + only_cache=False, + manufacturer=None, + ) + ]