From daffd2d952ab48bbc044a5e76e47c8fbba6be480 Mon Sep 17 00:00:00 2001 From: Julien Deveaux Date: Tue, 4 Nov 2025 14:21:25 +0100 Subject: [PATCH 1/4] Add Tuya Moes Garage Opener (ZM-102-M) --- zhaquirks/tuya/ts0601_garage.py | 87 +++++++++++++++++++++++++++++---- 1 file changed, 77 insertions(+), 10 deletions(-) diff --git a/zhaquirks/tuya/ts0601_garage.py b/zhaquirks/tuya/ts0601_garage.py index 3bb9c4fd8f..a601f348fb 100644 --- a/zhaquirks/tuya/ts0601_garage.py +++ b/zhaquirks/tuya/ts0601_garage.py @@ -5,7 +5,7 @@ import zigpy.types as t from zigpy.zcl import foundation from zigpy.zcl.clusters.general import Basic, GreenPowerProxy, Groups, Ota, Scenes, Time - +from zigpy.zcl.clusters.security import IasZone from zhaquirks.const import ( DEVICE_TYPE, ENDPOINTS, @@ -14,11 +14,22 @@ OUTPUT_CLUSTERS, PROFILE_ID, ) -from zhaquirks.tuya.mcu import DPToAttributeMapping, TuyaMCUCluster +from zhaquirks.tuya import TuyaLocalCluster +from zhaquirks.tuya.mcu import DPToAttributeMapping, TuyaMCUCluster, TuyaOnOffNM TUYA_MANUFACTURER_GARAGE = "tuya_manufacturer_garage" +class ContactSwitchCluster(TuyaLocalCluster, IasZone): + """Contact sensor cluster for door status.""" + + _CONSTANT_ATTRIBUTES = {0x0001: IasZone.ZoneType.Contact_Switch} + + def _update_attribute(self, attrid, value): + self.debug("_update_attribute '%s': %s", attrid, value) + super()._update_attribute(attrid, value) + + class TuyaGarageManufCluster(TuyaMCUCluster): """Tuya garage door opener.""" @@ -30,22 +41,22 @@ class AttributeDefs(TuyaMCUCluster.AttributeDefs): button = foundation.ZCLAttributeDef( id=0xEF01, type=t.Bool, is_manufacturer_specific=True ) - dp_2 = foundation.ZCLAttributeDef( + countdown = foundation.ZCLAttributeDef( id=0xEF02, type=t.uint32_t, is_manufacturer_specific=True ) contact_sensor = foundation.ZCLAttributeDef( id=0xEF03, type=t.Bool, is_manufacturer_specific=True ) - dp_4 = foundation.ZCLAttributeDef( + run_time = foundation.ZCLAttributeDef( id=0xEF04, type=t.uint32_t, is_manufacturer_specific=True ) - dp_5 = foundation.ZCLAttributeDef( + open_alarm_time = foundation.ZCLAttributeDef( id=0xEF05, type=t.uint32_t, is_manufacturer_specific=True ) dp_11 = foundation.ZCLAttributeDef( id=0xEF0B, type=t.Bool, is_manufacturer_specific=True ) - dp_12 = foundation.ZCLAttributeDef( + status = foundation.ZCLAttributeDef( id=0xEF0C, type=t.enum8, is_manufacturer_specific=True ) @@ -57,7 +68,7 @@ class AttributeDefs(TuyaMCUCluster.AttributeDefs): ), 2: DPToAttributeMapping( TUYA_MANUFACTURER_GARAGE, - "dp_2", + "countdown", ), 3: DPToAttributeMapping( TUYA_MANUFACTURER_GARAGE, @@ -65,11 +76,11 @@ class AttributeDefs(TuyaMCUCluster.AttributeDefs): ), 4: DPToAttributeMapping( TUYA_MANUFACTURER_GARAGE, - "dp_4", + "run_time", ), 5: DPToAttributeMapping( TUYA_MANUFACTURER_GARAGE, - "dp_5", + "open_alarm_time", ), 11: DPToAttributeMapping( TUYA_MANUFACTURER_GARAGE, @@ -78,7 +89,7 @@ class AttributeDefs(TuyaMCUCluster.AttributeDefs): # garage door status (open, closed, ...) 12: DPToAttributeMapping( TUYA_MANUFACTURER_GARAGE, - "dp_12", + "status", ), } @@ -149,3 +160,59 @@ class TuyaGarageSwitchTO(CustomDevice): }, }, } + +class TuyaMoesGarageSwitch(CustomDevice): + """Tuya Garage switch.""" + + signature = { + MODELS_INFO: [ + ("_TZE204_jktmrpoj", "TS0601"), + ], + ENDPOINTS: { + 1: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.SMART_PLUG, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + TuyaGarageManufCluster.cluster_id, + ], + OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], + }, + 242: { + PROFILE_ID: zgp.PROFILE_ID, + DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC, + INPUT_CLUSTERS: [], + OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id], + }, + }, + } + + replacement = { + ENDPOINTS: { + 1: { + DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT, + INPUT_CLUSTERS: [ + Basic.cluster_id, + Groups.cluster_id, + Scenes.cluster_id, + TuyaGarageManufCluster, + TuyaOnOffNM, + ], + OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], + }, + 2: { + PROFILE_ID: zha.PROFILE_ID, + DEVICE_TYPE: zha.DeviceType.IAS_ZONE, + INPUT_CLUSTERS: [ContactSwitchCluster], + OUTPUT_CLUSTERS: [], + }, + 242: { + PROFILE_ID: zgp.PROFILE_ID, + DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC, + INPUT_CLUSTERS: [], + OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id], + }, + }, + } From 74d2cebb8df8a71e5365e06169552cf4333974f7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 4 Nov 2025 23:21:32 +0000 Subject: [PATCH 2/4] Apply pre-commit auto fixes --- zhaquirks/tuya/ts0601_garage.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/zhaquirks/tuya/ts0601_garage.py b/zhaquirks/tuya/ts0601_garage.py index a601f348fb..752bb28efb 100644 --- a/zhaquirks/tuya/ts0601_garage.py +++ b/zhaquirks/tuya/ts0601_garage.py @@ -6,6 +6,7 @@ from zigpy.zcl import foundation from zigpy.zcl.clusters.general import Basic, GreenPowerProxy, Groups, Ota, Scenes, Time from zigpy.zcl.clusters.security import IasZone + from zhaquirks.const import ( DEVICE_TYPE, ENDPOINTS, @@ -161,6 +162,7 @@ class TuyaGarageSwitchTO(CustomDevice): }, } + class TuyaMoesGarageSwitch(CustomDevice): """Tuya Garage switch.""" From 9fd1f825cfe7cd2752d5d5b50a4f68e4e368f5a7 Mon Sep 17 00:00:00 2001 From: Julien Deveaux Date: Wed, 5 Nov 2025 14:32:05 +0100 Subject: [PATCH 3/4] more iso to working file --- zhaquirks/tuya/ts0601_garage.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zhaquirks/tuya/ts0601_garage.py b/zhaquirks/tuya/ts0601_garage.py index a601f348fb..dc0fc36ae6 100644 --- a/zhaquirks/tuya/ts0601_garage.py +++ b/zhaquirks/tuya/ts0601_garage.py @@ -182,7 +182,7 @@ class TuyaMoesGarageSwitch(CustomDevice): }, 242: { PROFILE_ID: zgp.PROFILE_ID, - DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC, + DEVICE_TYPE: 97, INPUT_CLUSTERS: [], OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id], }, @@ -209,7 +209,7 @@ class TuyaMoesGarageSwitch(CustomDevice): OUTPUT_CLUSTERS: [], }, 242: { - PROFILE_ID: zgp.PROFILE_ID, + PROFILE_ID: 0xA1E0, DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC, INPUT_CLUSTERS: [], OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id], From 1afbd5b27b10b274f63c661cfb721efff185f844 Mon Sep 17 00:00:00 2001 From: Julien Deveaux Date: Fri, 7 Nov 2025 16:29:52 +0100 Subject: [PATCH 4/4] working version --- tests/test_tuya_garage.py | 147 ++++++++++++++++++++++++++++++++ zhaquirks/tuya/ts0601_garage.py | 85 +++++++++++++++--- 2 files changed, 221 insertions(+), 11 deletions(-) create mode 100644 tests/test_tuya_garage.py diff --git a/tests/test_tuya_garage.py b/tests/test_tuya_garage.py new file mode 100644 index 0000000000..93a5a74c84 --- /dev/null +++ b/tests/test_tuya_garage.py @@ -0,0 +1,147 @@ +"""Tests for Tuya garage door quirks.""" + +from unittest import mock + +import pytest +from zigpy.zcl import foundation +from zigpy.zcl.clusters.security import IasZone + +from tests.common import ClusterListener +import zhaquirks +from zhaquirks.tuya.mcu import TuyaMCUCluster +from zhaquirks.tuya.ts0601_garage import ContactSwitchCluster, TuyaMoesGarageSwitch + +zhaquirks.setup() + + +def test_tuya_moes_garage_signature(assert_signature_matches_quirk): + """Test Tuya Moes Garage Door Opener signature is matched to its quirk.""" + + signature = { + "node_descriptor": "NodeDescriptor(logical_type=, complex_descriptor_available=0, user_descriptor_available=0, reserved=0, aps_flags=0, frequency_band=, mac_capability_flags=, manufacturer_code=4417, maximum_buffer_size=66, maximum_incoming_transfer_size=66, server_mask=11264, maximum_outgoing_transfer_size=66, descriptor_capability_field=, *allocate_address=True, *is_alternate_pan_coordinator=False, *is_coordinator=False, *is_end_device=False, *is_full_function_device=True, *is_mains_powered=True, *is_receiver_on_when_idle=True, *is_router=True, *is_security_capable=False)", + "endpoints": { + "1": { + "profile_id": 260, + "device_type": "0x0051", + "in_clusters": ["0x0000", "0x0004", "0x0005", "0xef00"], + "out_clusters": ["0x000a", "0x0019"], + }, + "242": { + "profile_id": 41440, + "device_type": "0x0061", + "in_clusters": [], + "out_clusters": ["0x0021"], + }, + }, + "manufacturer": "_TZE204_jktmrpoj", + "model": "TS0601", + "class": "tuya.ts0601_garage.TuyaMoesGarageSwitch", + } + + assert_signature_matches_quirk(TuyaMoesGarageSwitch, signature) + + +async def test_moes_garage_switch_on_off(zigpy_device_from_quirk): + """Test Tuya Moes garage door opener switch on/off control.""" + + device = zigpy_device_from_quirk(TuyaMoesGarageSwitch) + tuya_cluster = device.endpoints[1].in_clusters[TuyaMCUCluster.cluster_id] + switch_cluster = device.endpoints[1].on_off + tuya_listener = ClusterListener(tuya_cluster) + + # Test initial state + assert len(tuya_listener.cluster_commands) == 0 + assert len(tuya_listener.attribute_updates) == 0 + + # Test switch on command + with mock.patch.object( + tuya_cluster.endpoint, "request", return_value=foundation.Status.SUCCESS + ): + rsp = await switch_cluster.command(0x0001) + assert rsp.status == foundation.Status.SUCCESS + + # Test switch off command + with mock.patch.object( + tuya_cluster.endpoint, "request", return_value=foundation.Status.SUCCESS + ): + rsp = await switch_cluster.command(0x0000) + assert rsp.status == foundation.Status.SUCCESS + + +@pytest.mark.parametrize( + ("data", "expected_zone_status"), + [ + ( + # DP3 = True (door open/alarm) + b"\x09\x00\x02\x00\x00\x03\x01\x00\x01\x01", + IasZone.ZoneStatus.Alarm_1, + ), + ( + # DP3 = False (door closed) + b"\x09\x00\x02\x00\x00\x03\x01\x00\x01\x00", + 0x0000, + ), + ], +) +async def test_moes_garage_door_contact_sensor( + zigpy_device_from_quirk, data, expected_zone_status +): + """Test Tuya Moes garage door contact sensor reports.""" + + device = zigpy_device_from_quirk(TuyaMoesGarageSwitch) + tuya_cluster = device.endpoints[1].in_clusters[TuyaMCUCluster.cluster_id] + contact_cluster = device.endpoints[2].in_clusters[ContactSwitchCluster.cluster_id] + + contact_listener = ClusterListener(contact_cluster) + + # Deserialize and handle the message + hdr, payload = tuya_cluster.deserialize(data) + tuya_cluster.handle_message(hdr, payload) + + # Verify the contact sensor received the update + assert len(contact_listener.attribute_updates) == 1 + assert ( + contact_listener.attribute_updates[0][0] + == ContactSwitchCluster.AttributeDefs.zone_status.id + ) + assert contact_listener.attribute_updates[0][1] == expected_zone_status + + +@pytest.mark.parametrize( + ("dp_id", "dp_data", "attr_name", "expected_value"), + [ + (2, b"\x00\x00\x00\x3c", "countdown", 60), # DP2: countdown = 60 seconds + (4, b"\x00\x00\x00\x0a", "run_time", 10), # DP4: run_time = 10 + (5, b"\x00\x00\x00\x78", "open_alarm_time", 120), # DP5: alarm time = 120 + (12, b"\x02", "status", 0x02), # DP12: status = 2 + ], +) +async def test_moes_garage_attributes( + zigpy_device_from_quirk, dp_id, dp_data, attr_name, expected_value +): + """Test Tuya Moes garage door attribute updates.""" + + device = zigpy_device_from_quirk(TuyaMoesGarageSwitch) + tuya_cluster = device.endpoints[1].in_clusters[TuyaMCUCluster.cluster_id] + tuya_listener = ClusterListener(tuya_cluster) + + # Build the Tuya message with the DP + # Format: header + DP ID + type + length + data + if isinstance(dp_data, bytes) and len(dp_data) == 1: + # ENUM type (0x04) + data = b"\x09\x00\x02\x00\x00" + bytes([dp_id]) + b"\x04\x00\x01" + dp_data + else: + # VALUE type (0x02) + data = b"\x09\x00\x02\x00\x00" + bytes([dp_id]) + b"\x02\x00\x04" + dp_data + + # Deserialize and handle the message + hdr, payload = tuya_cluster.deserialize(data) + tuya_cluster.handle_message(hdr, payload) + + # Verify the attribute was updated + assert len(tuya_listener.attribute_updates) >= 1 + attr_id = tuya_cluster.attributes_by_name[attr_name].id + # Find the update for this attribute + updates = [u for u in tuya_listener.attribute_updates if u[0] == attr_id] + assert len(updates) == 1 + assert updates[0][1] == expected_value diff --git a/zhaquirks/tuya/ts0601_garage.py b/zhaquirks/tuya/ts0601_garage.py index bdc906a97e..a71b7a9fe6 100644 --- a/zhaquirks/tuya/ts0601_garage.py +++ b/zhaquirks/tuya/ts0601_garage.py @@ -42,22 +42,22 @@ class AttributeDefs(TuyaMCUCluster.AttributeDefs): button = foundation.ZCLAttributeDef( id=0xEF01, type=t.Bool, is_manufacturer_specific=True ) - countdown = foundation.ZCLAttributeDef( + dp_2 = foundation.ZCLAttributeDef( id=0xEF02, type=t.uint32_t, is_manufacturer_specific=True ) contact_sensor = foundation.ZCLAttributeDef( id=0xEF03, type=t.Bool, is_manufacturer_specific=True ) - run_time = foundation.ZCLAttributeDef( + dp_4 = foundation.ZCLAttributeDef( id=0xEF04, type=t.uint32_t, is_manufacturer_specific=True ) - open_alarm_time = foundation.ZCLAttributeDef( + dp_5 = foundation.ZCLAttributeDef( id=0xEF05, type=t.uint32_t, is_manufacturer_specific=True ) dp_11 = foundation.ZCLAttributeDef( id=0xEF0B, type=t.Bool, is_manufacturer_specific=True ) - status = foundation.ZCLAttributeDef( + dp_12 = foundation.ZCLAttributeDef( id=0xEF0C, type=t.enum8, is_manufacturer_specific=True ) @@ -69,7 +69,7 @@ class AttributeDefs(TuyaMCUCluster.AttributeDefs): ), 2: DPToAttributeMapping( TUYA_MANUFACTURER_GARAGE, - "countdown", + "dp_2", ), 3: DPToAttributeMapping( TUYA_MANUFACTURER_GARAGE, @@ -77,11 +77,11 @@ class AttributeDefs(TuyaMCUCluster.AttributeDefs): ), 4: DPToAttributeMapping( TUYA_MANUFACTURER_GARAGE, - "run_time", + "dp_4", ), 5: DPToAttributeMapping( TUYA_MANUFACTURER_GARAGE, - "open_alarm_time", + "dp_5", ), 11: DPToAttributeMapping( TUYA_MANUFACTURER_GARAGE, @@ -90,7 +90,7 @@ class AttributeDefs(TuyaMCUCluster.AttributeDefs): # garage door status (open, closed, ...) 12: DPToAttributeMapping( TUYA_MANUFACTURER_GARAGE, - "status", + "dp_12", ), } @@ -163,8 +163,71 @@ class TuyaGarageSwitchTO(CustomDevice): } +class TuyaMoesGarageManufCluster(TuyaMCUCluster): + """Tuya Moes garage door opener manufacturer cluster.""" + + ep_attribute = TUYA_MANUFACTURER_GARAGE + + class AttributeDefs(TuyaMCUCluster.AttributeDefs): + """Attribute Definitions.""" + + countdown = foundation.ZCLAttributeDef( + id=0xEF02, type=t.uint32_t, is_manufacturer_specific=True + ) + garage_door_contact = foundation.ZCLAttributeDef( + id=0xEF03, type=t.Bool, is_manufacturer_specific=True + ) + run_time = foundation.ZCLAttributeDef( + id=0xEF04, type=t.uint32_t, is_manufacturer_specific=True + ) + open_alarm_time = foundation.ZCLAttributeDef( + id=0xEF05, type=t.uint32_t, is_manufacturer_specific=True + ) + status = foundation.ZCLAttributeDef( + id=0xEF0C, type=t.enum8, is_manufacturer_specific=True + ) + + dp_to_attribute: dict[int, DPToAttributeMapping] = { + 1: DPToAttributeMapping( + TuyaOnOffNM.ep_attribute, + "on_off", + ), + 2: DPToAttributeMapping( + TUYA_MANUFACTURER_GARAGE, + "countdown", + ), + 3: DPToAttributeMapping( + ContactSwitchCluster.ep_attribute, + "zone_status", + lambda x: IasZone.ZoneStatus.Alarm_1 if x else 0, + endpoint_id=2, + ), + 4: DPToAttributeMapping( + TUYA_MANUFACTURER_GARAGE, + "run_time", + ), + 5: DPToAttributeMapping( + TUYA_MANUFACTURER_GARAGE, + "open_alarm_time", + ), + 12: DPToAttributeMapping( + TUYA_MANUFACTURER_GARAGE, + "status", + ), + } + + data_point_handlers = { + 1: "_dp_2_attr_update", + 2: "_dp_2_attr_update", + 3: "_dp_2_attr_update", + 4: "_dp_2_attr_update", + 5: "_dp_2_attr_update", + 12: "_dp_2_attr_update", + } + + class TuyaMoesGarageSwitch(CustomDevice): - """Tuya Garage switch.""" + """Tuya Moes Garage Door Opener.""" signature = { MODELS_INFO: [ @@ -178,7 +241,7 @@ class TuyaMoesGarageSwitch(CustomDevice): Basic.cluster_id, Groups.cluster_id, Scenes.cluster_id, - TuyaGarageManufCluster.cluster_id, + TuyaMoesGarageManufCluster.cluster_id, ], OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id], }, @@ -199,7 +262,7 @@ class TuyaMoesGarageSwitch(CustomDevice): Basic.cluster_id, Groups.cluster_id, Scenes.cluster_id, - TuyaGarageManufCluster, + TuyaMoesGarageManufCluster, TuyaOnOffNM, ], OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],