Repository: zigpy/zha-device-handlers
Pull request #3796 (2025-01-28 12:01:25)

Convert Tuya door quirk to v2, add `tuya_vibration`
## Proposed change
<!--
  Explain your proposed change below.
-->

Converts the quirk for `_TZE200_kzm5w4iz` to v2, updates tests and add `tuya_vibration` to TuyaQuirkBuilder.
This PR also refactors sensors a bit, moving door sensors into `tuya_contact.py`.

## Additional information
<!--
  Please include any additional information that is important to this PR.
  For example, if this PR is a potentially breaking change, mention that here.
  If this PR requires other PRs to be merged in HA Core or other projects, mention that.
  Lastly, if this PR fixes a specific issue, please include "Fixes #xxxx".
-->


## Checklist
<!--
  Put an 'x' in all boxes that apply.
  Note: You do not need to tick all boxes before creating a PR.
-->

- [x] The changes are tested and work correctly
- [x] `pre-commit` checks pass / the code has been formatted using Black
- [x] Tests have been added to verify that the new code works


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3796?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 90.89%. Comparing base [(`ab4e4f5`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/ab4e4f5e1b3184aa73caf8f4e7e87a1f4ad9576c?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) to head [(`d55b7d8`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/d55b7d81e29ecd1418b7983a4b4d55825c14adbb?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##              dev    #3796      +/-   ##
==========================================
- Coverage   90.91%   90.89%   -0.02%     
==========================================
  Files         324      323       -1     
  Lines       10562    10542      -20     
==========================================
- Hits         9602     9582      -20     
  Misses        960      960              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3796?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).


Patch:
diff --git a/zhaquirks/tuya/builder/__init__.py b/zhaquirks/tuya/builder/__init__.py
index fbcb50f..e8b40dc 100644
--- a/zhaquirks/tuya/builder/__init__.py
+++ b/zhaquirks/tuya/builder/__init__.py
@@ -87,6 +87,14 @@ class TuyaIasFire(IasZone, TuyaLocalCluster):
     }
 
 
+class TuyaIasVibration(IasZone, TuyaLocalCluster):
+    """Tuya local IAS vibration cluster."""
+
+    _CONSTANT_ATTRIBUTES = {
+        IasZone.AttributeDefs.zone_type.id: IasZone.ZoneType.Vibration_Movement_Sensor
+    }
+
+
 class TuyaPM25Concentration(PM25, TuyaLocalCluster):
     """Tuya PM25 concentration measurement."""
 
@@ -435,6 +443,15 @@ class TuyaQuirkBuilder(QuirkBuilder):
         self.adds(temp_cfg)
         return self
 
+    def tuya_vibration(self, dp_id: int):
+        """Add a Tuya IAS vibration sensor."""
+        self.tuya_ias(
+            dp_id=dp_id,
+            ias_cfg=TuyaIasVibration,
+            converter=lambda x: IasZone.ZoneStatus.Alarm_1 if x != 0 else 0,
+        )
+        return self
+
     def tuya_voc(
         self,
         dp_id: int,
diff --git a/zhaquirks/tuya/ts601_door.py b/zhaquirks/tuya/ts601_door.py
deleted file mode 100644
index 40f6f1c..0000000
--- a/zhaquirks/tuya/ts601_door.py
+++ /dev/null
@@ -1,198 +0,0 @@
-"""Device handler for Tuya Zigbee Vibration Door Sensor.
-
-The device reports:
-- Vibration
-- Open/Closed door/window
-- Remaining battery percentage
-
-Extra details: https://www.aliexpress.com/item/1005004443361928.html?spm=a2g0o.order_list.order_list_main.53.35a81802wiZASs
-"""
-
-from zigpy.profiles import zha
-from zigpy.quirks import CustomCluster, CustomDevice
-from zigpy.zcl.clusters.general import (
-    Basic,
-    Groups,
-    Ota,
-    PowerConfiguration,
-    Scenes,
-    Time,
-)
-from zigpy.zcl.clusters.security import IasZone, ZoneStatus, ZoneType
-
-from zhaquirks.const import (
-    DEVICE_TYPE,
-    ENDPOINTS,
-    INPUT_CLUSTERS,
-    MODELS_INFO,
-    OUTPUT_CLUSTERS,
-    PROFILE_ID,
-    ZONE_STATE,
-)
-from zhaquirks.tuya import (
-    TUYA_CLUSTER_ID,
-    DPToAttributeMapping,
-    TuyaLocalCluster,
-    TuyaNewManufCluster,
-)
-
-DOOR_HANDLE_DP_ID = 1
-BATTERY_STATE_DP_ID = 3
-VIBRATION_DP_ID = 10
-
-DP_HANDLER_EP_ID = 1
-DOOR_HANDLE_EP_ID = 2
-VIBRATION_EP_ID = 3
-
-
-class CustomBasicCluster(CustomCluster, Basic):
-    """Custom Basic cluster to report power source."""
-
-    _CONSTANT_ATTRIBUTES = {
-        Basic.AttributeDefs.power_source.id: Basic.PowerSource.Battery
-    }
-
-
-class CustomTuyaPowerConfigurationCluster(TuyaLocalCluster, PowerConfiguration):
-    """Custom Power Configuration cluster that represents battery reports."""
-
-    _CONSTANT_ATTRIBUTES = {
-        PowerConfiguration.AttributeDefs.battery_size.id: PowerConfiguration.BatterySize.AAA,
-        PowerConfiguration.AttributeDefs.battery_quantity.id: 2,
-    }
-
-
-class CustomTuyaContactSwitchCluster(TuyaLocalCluster, IasZone):
-    """Custom IasZone cluster that represents the Open/Closed sensor."""
-
-    _CONSTANT_ATTRIBUTES = {
-        IasZone.AttributeDefs.zone_state.id: ZONE_STATE,
-        IasZone.AttributeDefs.zone_type.id: ZoneType.Contact_Switch,
-    }
-
-
-class CustomTuyaVibrationCluster(TuyaLocalCluster, IasZone):
-    """Custom IasZone cluster that represents the vibration sensor."""
-
-    _CONSTANT_ATTRIBUTES = {
-        IasZone.AttributeDefs.zone_state.id: ZONE_STATE,
-        IasZone.AttributeDefs.zone_type.id: ZoneType.Vibration_Movement_Sensor,
-    }
-
-
-class CustomTuyaDPProcessor(TuyaNewManufCluster):
-    """Custom cluster that translates Tuya's data point reporting.
-
-    Tuya data points are parsed and their values are used to update the
-    specified endpoint's (endpoint_id) cluster's (ep_attribute)
-    attribute (attribute_name).
-    """
-
-    dp_to_attribute: dict[int, DPToAttributeMapping] = {
-        DOOR_HANDLE_DP_ID: DPToAttributeMapping(
-            endpoint_id=DOOR_HANDLE_EP_ID,
-            ep_attribute=IasZone.ep_attribute,
-            attribute_name=IasZone.AttributeDefs.zone_status.name,
-            converter=lambda x: ZoneStatus.Alarm_1 & x,
-        ),
-        VIBRATION_DP_ID: DPToAttributeMapping(
-            endpoint_id=VIBRATION_EP_ID,
-            ep_attribute=IasZone.ep_attribute,
-            attribute_name=IasZone.AttributeDefs.zone_status.name,
-            converter=lambda x: ZoneStatus.Alarm_1 & x,
-        ),
-        BATTERY_STATE_DP_ID: DPToAttributeMapping(
-            endpoint_id=DP_HANDLER_EP_ID,
-            ep_attribute=PowerConfiguration.ep_attribute,
-            attribute_name=PowerConfiguration.AttributeDefs.battery_percentage_remaining.name,
-            converter=lambda x: 2 * x,
-            # Device measures battery in 1% steps, while the ZCL specifies it in 0.5% steps
-        ),
-    }
-
-    data_point_handlers = {
-        DOOR_HANDLE_DP_ID: "_dp_2_attr_update",
-        VIBRATION_DP_ID: "_dp_2_attr_update",
-        BATTERY_STATE_DP_ID: "_dp_2_attr_update",
-    }
-
-
-class TS0601Door(CustomDevice):
-    """Quirk for Tuya Zigbee Vibration Door Sensor.
-
-    The device reports:
-    - Vibration
-    - Open/closed door/window
-    - Remaining battery percentage
-
-    The original device has a single endpoint that corresponds to the Tuya
-    manufacturer specific cluster. The replacement has 3 endpoints, where
-        - the first is responsible for handling the Tuya data point reports,
-        - the second represents the open/closed sensor,
-        - and the third represents the vibration sensor.
-    The latter two are both IasZone cluster extensions, that's why they had to
-    be put into different endpoints (otherwise we would have two of the same
-    cluster for an endpoint, which is prohibited by the Zigbee standard).
-
-    Extra details about the physical device: https://www.aliexpress.com/item/1005004443361928.html?spm=a2g0o.order_list.order_list_main.53.35a81802wiZASs
-    """
-
-    signature = {
-        # <SimpleDescriptor endpoint=1 profile=260 device_type=81
-        # device_version=1
-        # input_clusters=[4, 5, 61184, 0]
-        # output_clusters=[25, 10]>
-        MODELS_INFO: [("_TZE200_kzm5w4iz", "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,
-                    TUYA_CLUSTER_ID,
-                ],
-                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
-            },
-        },
-    }
-
-    replacement = {
-        ENDPOINTS: {
-            DP_HANDLER_EP_ID: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.SMART_PLUG,
-                INPUT_CLUSTERS: [
-                    CustomBasicCluster,
-                    Groups.cluster_id,
-                    Scenes.cluster_id,
-                    CustomTuyaPowerConfigurationCluster,
-                    CustomTuyaDPProcessor,
-                ],
-                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
-            },
-            DOOR_HANDLE_EP_ID: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.IAS_ZONE,
-                INPUT_CLUSTERS: [
-                    CustomBasicCluster,
-                    Groups.cluster_id,
-                    Scenes.cluster_id,
-                    CustomTuyaContactSwitchCluster,
-                ],
-                OUTPUT_CLUSTERS: [Time.cluster_id],
-            },
-            VIBRATION_EP_ID: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.IAS_ZONE,
-                INPUT_CLUSTERS: [
-                    CustomBasicCluster,
-                    Groups.cluster_id,
-                    Scenes.cluster_id,
-                    CustomTuyaVibrationCluster,
-                ],
-                OUTPUT_CLUSTERS: [Time.cluster_id],
-            },
-        },
-    }
diff --git a/zhaquirks/tuya/tuay_contact.py b/zhaquirks/tuya/tuay_contact.py
new file mode 100644
index 0000000..f165443
--- /dev/null
+++ b/zhaquirks/tuya/tuay_contact.py
@@ -0,0 +1,24 @@
+"""Tuya contact sensors."""
+
+from zhaquirks.const import BatterySize
+from zhaquirks.tuya.builder import TuyaQuirkBuilder
+
+(
+    TuyaQuirkBuilder("_TZE200_pay2byax", "TS0601")  # Cusam ZG-102ZL
+    .applies_to("_TZE200_n8dljorx", "TS0601")
+    .tuya_illuminance(dp_id=101)
+    .tuya_contact(dp_id=1)
+    .tuya_battery(dp_id=2, battery_type=BatterySize.CR2032, battery_qty=1)
+    .skip_configuration()
+    .add_to_registry()
+)
+
+
+(
+    TuyaQuirkBuilder("_TZE200_kzm5w4iz", "TS0601")
+    .tuya_contact(dp_id=1)
+    .tuya_battery(dp_id=3, battery_type=BatterySize.AAA, battery_qty=2)
+    .tuya_vibration(dp_id=10)
+    .skip_configuration()
+    .add_to_registry()
+)
diff --git a/zhaquirks/tuya/tuya_sensor.py b/zhaquirks/tuya/tuya_sensor.py
index f63f6b1..dbcec43 100644
--- a/zhaquirks/tuya/tuya_sensor.py
+++ b/zhaquirks/tuya/tuya_sensor.py
@@ -8,7 +8,6 @@ from zigpy.quirks.v2.homeassistant.sensor import SensorDeviceClass
 import zigpy.types as t
 from zigpy.zcl import foundation
 
-from zhaquirks.const import BatterySize
 from zhaquirks.tuya import (
     TUYA_SET_TIME,
     TuyaPowerConfigurationCluster2AAA,
@@ -302,17 +301,6 @@ class NoManufTimeTuyaMCUCluster(TuyaMCUCluster):
 )
 
 
-(
-    TuyaQuirkBuilder("_TZE200_pay2byax", "TS0601")  # Cusam ZG-102ZL
-    .applies_to("_TZE200_n8dljorx", "TS0601")
-    .tuya_illuminance(dp_id=101)
-    .tuya_contact(dp_id=1)
-    .tuya_battery(dp_id=2, battery_type=BatterySize.CR2032, battery_qty=1)
-    .skip_configuration()
-    .add_to_registry()
-)
-
-
 (
     TuyaQuirkBuilder("_TZE204_upagmta9", "TS0601")
     .tuya_temperature(dp_id=1, scale=10)


Test patch:
diff --git a/tests/test_tuya.py b/tests/test_tuya.py
index 7cd634a..9b47fd7 100644
--- a/tests/test_tuya.py
+++ b/tests/test_tuya.py
@@ -36,7 +36,6 @@ import zhaquirks.tuya.ts011f_plug
 import zhaquirks.tuya.ts0501_fan_switch
 import zhaquirks.tuya.ts0601_electric_heating
 import zhaquirks.tuya.ts0601_trv
-import zhaquirks.tuya.ts601_door
 import zhaquirks.tuya.ts1201
 import zhaquirks.tuya.tuya_motion
 import zhaquirks.tuya.tuya_valve
@@ -1955,59 +1954,35 @@ async def test_ts1201_ir_blaster(zigpy_device_from_quirk):
         )
 
 
-def test_ts601_door_sensor_signature(assert_signature_matches_quirk):
-    """Test TS601 Vibration Door Sensor signature against quirk."""
-    signature = {
-        "node_descriptor": "NodeDescriptor(logical_type=<LogicalType.EndDevice: 2>, complex_descriptor_available=0, user_descriptor_available=0, reserved=0, aps_flags=0, frequency_band=<FrequencyBand.Freq2400MHz: 8>, mac_capability_flags=<MACCapabilityFlags.AllocateAddress: 128>, manufacturer_code=4417, maximum_buffer_size=66, maximum_incoming_transfer_size=66, server_mask=10752, maximum_outgoing_transfer_size=66, descriptor_capability_field=<DescriptorCapability.NONE: 0>, *allocate_address=True, *is_alternate_pan_coordinator=False, *is_coordinator=False, *is_end_device=True, *is_full_function_device=False, *is_mains_powered=False, *is_receiver_on_when_idle=False, *is_router=False, *is_security_capable=False)",
-        "endpoints": {
-            "1": {
-                "profile_id": 260,
-                "device_type": "0x0051",
-                "in_clusters": ["0x0000", "0x0004", "0x0005", "0xef00"],
-                "out_clusters": ["0x000a", "0x0019"],
-            }
-        },
-        "manufacturer": "_TZE200_kzm5w4iz",
-        "model": "TS0601",
-        "class": "zigpy.device.Device",
-    }
-    assert_signature_matches_quirk(zhaquirks.tuya.ts601_door.TS0601Door, signature)
-
-
 @pytest.mark.parametrize(
-    ("data", "endpoint_id", "ep_attr", "attribute", "expected_value"),
+    ("data", "ep_attr", "attribute", "expected_value"),
     [
         (
             b"\t\xfc\x02\x007\x01\x01\x00\x01\x01",
-            zhaquirks.tuya.ts601_door.DOOR_HANDLE_EP_ID,
             IasZone.ep_attribute,
             IasZone.AttributeDefs.zone_status.id,
             ZoneStatus.Alarm_1,
         ),
         (
             b"\t\xfc\x02\x007\x01\x01\x00\x01\x00",
-            zhaquirks.tuya.ts601_door.DOOR_HANDLE_EP_ID,
             IasZone.ep_attribute,
             IasZone.AttributeDefs.zone_status.id,
             0x0000,
         ),
         (
             b"\t\xfc\x02\x007\n\x04\x00\x01\x01",
-            zhaquirks.tuya.ts601_door.VIBRATION_EP_ID,
             IasZone.ep_attribute,
             IasZone.AttributeDefs.zone_status.id,
             ZoneStatus.Alarm_1,
         ),
         (
             b"\t\xfc\x02\x007\n\x04\x00\x01\x00",
-            zhaquirks.tuya.ts601_door.VIBRATION_EP_ID,
             IasZone.ep_attribute,
             IasZone.AttributeDefs.zone_status.id,
             0x0000,
         ),
         (
             b"\to\x02\x00P\x03\x02\x00\x04\x00\x00\x00T",
-            zhaquirks.tuya.ts601_door.DP_HANDLER_EP_ID,
             PowerConfiguration.ep_attribute,
             PowerConfiguration.AttributeDefs.battery_percentage_remaining.id,
             84 * 2,
@@ -2015,7 +1990,7 @@ def test_ts601_door_sensor_signature(assert_signature_matches_quirk):
     ],
 )
 async def test_ts601_door_sensor(
-    zigpy_device_from_quirk, data, endpoint_id, ep_attr, attribute, expected_value
+    zigpy_device_from_v2_quirk, data, ep_attr, attribute, expected_value
 ):
     """Test TS601 Vibration Door Sensor quirk.
 
@@ -2024,25 +1999,22 @@ async def test_ts601_door_sensor(
         - Vibration On/Off
         - Remaining battery percentage
     """
-    device: Device = zigpy_device_from_quirk(zhaquirks.tuya.ts601_door.TS0601Door)
+    device: Device = zigpy_device_from_v2_quirk("_TZE200_kzm5w4iz", "TS0601")
     device._packet_debouncer.filter = mock.MagicMock(return_value=False)
 
-    dp_processor_ep = zhaquirks.tuya.ts601_door.DP_HANDLER_EP_ID
-    cluster = device.endpoints[dp_processor_ep].in_clusters[
-        TuyaNewManufCluster.cluster_id
-    ]
+    cluster = device.endpoints[1].in_clusters[TuyaNewManufCluster.cluster_id]
 
     with mock.patch.object(cluster, "send_default_rsp"):
         device.packet_received(
             t.ZigbeePacket(
                 profile_id=zha.PROFILE_ID,
-                src_ep=dp_processor_ep,
+                src_ep=1,
                 cluster_id=TuyaNewManufCluster.cluster_id,
                 data=t.SerializableBytes(data),
             )
         )
 
-    cluster = getattr(device.endpoints[endpoint_id], ep_attr)
+    cluster = getattr(device.endpoints[1], ep_attr)
     attrs = await cluster.read_attributes(attributes=[attribute])
 
     assert attrs[0].get(attribute) == expected_value
diff --git a/tests/test_tuya_builder.py b/tests/test_tuya_builder.py
index a25fccb..97add16 100644
--- a/tests/test_tuya_builder.py
+++ b/tests/test_tuya_builder.py
@@ -26,6 +26,7 @@ from zhaquirks.tuya.builder import (
     TuyaIasContact,
     TuyaIasFire,
     TuyaIasGas,
+    TuyaIasVibration,
     TuyaIlluminance,
     TuyaPM25Concentration,
     TuyaQuirkBuilder,
@@ -56,6 +57,7 @@ zhaquirks.setup()
         ("tuya_co2", "carbon_dioxide_concentration", TuyaCO2Concentration),
         ("tuya_pm25", "pm25", TuyaPM25Concentration),
         ("tuya_voc", "voc_level", TuyaAirQualityVOC),
+        ("tuya_vibration", "ias_zone", TuyaIasVibration),
         (
             "tuya_formaldehyde",
             "formaldehyde_concentration",