Repository: zigpy/zha-device-handlers
Pull request #3246 (2024-07-08 21:04:03)

Add Tuya vibration door sensor `_TZE200_kzm5w4iz`
## Proposed change
  Added quirk for the [Tuya Vibration Door Sensor](https://www.aliexpress.com/item/1005004443361928.html?spm=a2g0o.order_list.order_list_main.53.35a81802wiZASs), which didn't have any matching quirks before.


## Additional information
  --


## Checklist
- [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/3246?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 88.23%. Comparing base [(`8317dc6`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/8317dc69c3b36e7ae5cabe40d7937c02ca259f77?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) to head [(`74cdc21`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/74cdc210ce960b095d66555f6f6b82600fe21859?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    #3246      +/-   ##
==========================================
+ Coverage   88.18%   88.23%   +0.05%     
==========================================
  Files         301      302       +1     
  Lines        9412     9455      +43     
==========================================
+ Hits         8300     8343      +43     
  Misses       1112     1112              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3246?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).

TheJulianJES: Thanks for adding tests btw!
adam-zlatniczki: _CONSTANT_ATTRIBUTES is a good proposal, I've moved the constant attributes.

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index e948674..26c8b86 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -1,6 +1,6 @@
 repos:
   - repo: https://github.com/codespell-project/codespell
-    rev: v2.2.6
+    rev: v2.3.0
     hooks:
       - id: codespell
         additional_dependencies: [tomli]
diff --git a/zhaquirks/thirdreality/night_light.py b/zhaquirks/thirdreality/night_light.py
index e1cf833..3622489 100644
--- a/zhaquirks/thirdreality/night_light.py
+++ b/zhaquirks/thirdreality/night_light.py
@@ -2,6 +2,7 @@
 
 from zigpy.profiles import zha
 from zigpy.quirks import CustomCluster, CustomDevice
+import zigpy.types as t
 from zigpy.zcl.clusters.general import (
     Basic,
     Groups,
@@ -16,6 +17,7 @@ from zigpy.zcl.clusters.lighting import Color
 from zigpy.zcl.clusters.lightlink import LightLink
 from zigpy.zcl.clusters.measurement import IlluminanceMeasurement
 from zigpy.zcl.clusters.security import IasZone
+from zigpy.zcl.foundation import BaseAttributeDefs, ZCLAttributeDef
 
 from zhaquirks import LocalDataCluster
 from zhaquirks.const import (
@@ -37,6 +39,25 @@ class ThirdRealitySpecificCluster(CustomCluster):
 
     cluster_id = THIRD_REALITY_CLUSTER_ID
 
+    class AttributeDefs(BaseAttributeDefs):
+        """Attribute definitions."""
+
+        cooldown_time = ZCLAttributeDef(
+            id=0x0003,
+            type=t.uint16_t,
+            is_manufacturer_specific=True,
+        )
+        local_routine_time = ZCLAttributeDef(
+            id=0x0004,
+            type=t.uint16_t,
+            is_manufacturer_specific=True,
+        )
+        lux_threshold = ZCLAttributeDef(
+            id=0x0005,
+            type=t.uint16_t,
+            is_manufacturer_specific=True,
+        )
+
     def _update_attribute(self, attrid, value):
         super()._update_attribute(attrid, value)
         if attrid == THIRD_REALITY_MOTION_EVENT_ATTR_ID:
diff --git a/zhaquirks/tuya/ts000f_switch.py b/zhaquirks/tuya/ts000f_switch.py
index ca98eef..6213685 100644
--- a/zhaquirks/tuya/ts000f_switch.py
+++ b/zhaquirks/tuya/ts000f_switch.py
@@ -26,6 +26,7 @@ from zhaquirks.tuya import (
     TuyaZB1888Cluster,
     TuyaZBE000Cluster,
     TuyaZBElectricalMeasurement,
+    TuyaZBExternalSwitchTypeCluster,
     TuyaZBMeteringCluster,
     TuyaZBOnOffAttributeCluster,
 )
@@ -97,3 +98,83 @@ class Tuya_1G_Wall_Switch_Metering(EnchantedDevice):
             },
         },
     }
+
+
+class Tuya_2G_Wall_Switch_Metering(EnchantedDevice):
+    """Tuya 2 gang wall switch with metering and restore power state support."""
+
+    signature = {
+        MODEL: "TS000F",
+        ENDPOINTS: {
+            1: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    Groups.cluster_id,
+                    Scenes.cluster_id,
+                    OnOff.cluster_id,
+                    TuyaZBE000Cluster.cluster_id,
+                    TuyaZBExternalSwitchTypeCluster.cluster_id,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Time.cluster_id,
+                    Ota.cluster_id,
+                ],
+            },
+            2: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
+                INPUT_CLUSTERS: [
+                    Groups.cluster_id,
+                    Scenes.cluster_id,
+                    OnOff.cluster_id,
+                    TuyaZBExternalSwitchTypeCluster.cluster_id,
+                ],
+                OUTPUT_CLUSTERS: [],
+            },
+            242: {
+                PROFILE_ID: zgp.PROFILE_ID,
+                DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC,
+                INPUT_CLUSTERS: [],
+                OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
+            },
+        },
+    }
+    replacement = {
+        ENDPOINTS: {
+            1: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    Groups.cluster_id,
+                    Scenes.cluster_id,
+                    TuyaZBOnOffAttributeCluster,
+                    TuyaZBE000Cluster,
+                    TuyaZBExternalSwitchTypeCluster,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Time.cluster_id,
+                    Ota.cluster_id,
+                ],
+            },
+            2: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
+                INPUT_CLUSTERS: [
+                    Groups.cluster_id,
+                    Scenes.cluster_id,
+                    TuyaZBOnOffAttributeCluster,
+                    TuyaZBExternalSwitchTypeCluster,
+                ],
+                OUTPUT_CLUSTERS: [],
+            },
+            242: {
+                PROFILE_ID: zgp.PROFILE_ID,
+                DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC,
+                INPUT_CLUSTERS: [],
+                OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
+            },
+        },
+    }
diff --git a/zhaquirks/tuya/ts011f_plug.py b/zhaquirks/tuya/ts011f_plug.py
index 3d80c13..24812e2 100644
--- a/zhaquirks/tuya/ts011f_plug.py
+++ b/zhaquirks/tuya/ts011f_plug.py
@@ -1170,6 +1170,63 @@ class Plug_v3(EnchantedDevice):
     }
 
 
+class Plug_v3_NoGP(EnchantedDevice):
+    """Tuya TS011F plug without GP. One plug is _TZ3000_cehuw1lw."""
+
+    quirk_id = TUYA_PLUG_ONOFF
+
+    signature = {
+        MODEL: "TS011F",
+        ENDPOINTS: {
+            1: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.SMART_PLUG,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    Scenes.cluster_id,
+                    OnOff.cluster_id,
+                    Time.cluster_id,
+                    Metering.cluster_id,
+                    ElectricalMeasurement.cluster_id,
+                    LightLink.cluster_id,
+                    TuyaZB1888Cluster.cluster_id,
+                    TuyaZBE000Cluster.cluster_id,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Ota.cluster_id,
+                ],
+            },
+        },
+    }
+
+    replacement = {
+        ENDPOINTS: {
+            1: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.SMART_PLUG,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    Scenes.cluster_id,
+                    TuyaZBOnOffAttributeCluster,
+                    Time.cluster_id,
+                    TuyaZBMeteringClusterWithUnit,
+                    TuyaZBElectricalMeasurement,
+                    LightLink.cluster_id,
+                    TuyaZB1888Cluster,
+                    TuyaZBE000Cluster,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Ota.cluster_id,
+                ],
+            },
+        },
+    }
+
+
 class Plug_2AC_var03(CustomDevice):
     """Tuya 2 socket wall outlet with child lock and power-restore state support."""
 
diff --git a/zhaquirks/tuya/ts0205.py b/zhaquirks/tuya/ts0205.py
new file mode 100644
index 0000000..73054dd
--- /dev/null
+++ b/zhaquirks/tuya/ts0205.py
@@ -0,0 +1,111 @@
+"""Tuya TS0205 smoke detector."""
+
+from zigpy.profiles import zha
+from zigpy.quirks import CustomDevice
+import zigpy.types as t
+from zigpy.zcl.clusters.general import (
+    Basic,
+    Groups,
+    Identify,
+    OnOff,
+    Ota,
+    PowerConfiguration,
+    Scenes,
+    Time,
+)
+from zigpy.zcl.clusters.lightlink import LightLink
+from zigpy.zcl.clusters.security import IasZone
+from zigpy.zcl.foundation import BaseAttributeDefs, ZCLAttributeDef
+
+from zhaquirks import LocalDataCluster
+from zhaquirks.const import (
+    DEVICE_TYPE,
+    ENDPOINTS,
+    INPUT_CLUSTERS,
+    MODELS_INFO,
+    OUTPUT_CLUSTERS,
+    PROFILE_ID,
+)
+from zhaquirks.tuya import TuyaManufClusterAttributes
+
+
+class TuyaSmokeDetectorCluster(TuyaManufClusterAttributes):
+    """Manufacturer Specific Cluster of the TS0205 smoke detector."""
+
+    class AttributeDefs(BaseAttributeDefs):
+        """Attribute definitions."""
+
+        smoke_detected = ZCLAttributeDef(
+            id=0x0401,  # [0]/[1] [Detected]/[Clear]
+            type=t.uint8_t,
+            is_manufacturer_specific=True,
+        )
+
+    def _update_attribute(self, attrid, value):
+        super()._update_attribute(attrid, value)
+        if attrid == self.AttributeDefs.smoke_detected.id:
+            if value == 0:
+                self.endpoint.ias_zone.update_attribute(
+                    IasZone.AttributeDefs.zone_status.id, IasZone.ZoneStatus.Alarm_1
+                )
+            else:
+                self.endpoint.ias_zone.update_attribute(
+                    IasZone.AttributeDefs.zone_status.id, 0
+                )
+
+
+class TuyaIasZone(LocalDataCluster, IasZone):
+    """IAS Zone."""
+
+    _CONSTANT_ATTRIBUTES = {
+        IasZone.AttributeDefs.zone_type.id: IasZone.ZoneType.Fire_Sensor
+    }
+
+
+class TuyaSmokeDetectorTS0205(CustomDevice):
+    """Tuya smoke sensor PST-YG500A."""
+
+    signature = {
+        MODELS_INFO: [("_TZ3210_up3pngle", "TS0205")],
+        ENDPOINTS: {
+            1: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.IAS_ZONE,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    PowerConfiguration.cluster_id,
+                    Groups.cluster_id,
+                    Scenes.cluster_id,
+                    IasZone.cluster_id,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    OnOff.cluster_id,
+                    Time.cluster_id,
+                    Ota.cluster_id,
+                    LightLink.cluster_id,
+                ],
+            }
+        },
+    }
+
+    replacement = {
+        ENDPOINTS: {
+            1: {
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    PowerConfiguration.cluster_id,
+                    Groups.cluster_id,
+                    Scenes.cluster_id,
+                    TuyaIasZone,
+                    TuyaSmokeDetectorCluster,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    Ota.cluster_id,
+                ],
+            }
+        }
+    }
diff --git a/zhaquirks/tuya/ts601_door.py b/zhaquirks/tuya/ts601_door.py
new file mode 100644
index 0000000..40f6f1c
--- /dev/null
+++ b/zhaquirks/tuya/ts601_door.py
@@ -0,0 +1,198 @@
+"""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],
+            },
+        },
+    }


Test patch:
diff --git a/tests/test_tuya.py b/tests/test_tuya.py
index 462cbe8..c85e15e 100644
--- a/tests/test_tuya.py
+++ b/tests/test_tuya.py
@@ -5,11 +5,13 @@ import datetime
 from unittest import mock
 
 import pytest
+from zigpy.device import Device
 from zigpy.profiles import zha
 from zigpy.quirks import CustomDevice, get_device
 import zigpy.types as t
 from zigpy.zcl import foundation
 from zigpy.zcl.clusters.general import PowerConfiguration
+from zigpy.zcl.clusters.security import IasZone, ZoneStatus
 
 from tests.common import ClusterListener, MockDatetime, wait_for_zigpy_tasks
 import zhaquirks
@@ -37,6 +39,7 @@ import zhaquirks.tuya.ts0601_motion
 import zhaquirks.tuya.ts0601_siren
 import zhaquirks.tuya.ts0601_trv
 import zhaquirks.tuya.ts0601_valve
+import zhaquirks.tuya.ts601_door
 
 zhaquirks.setup()
 
@@ -1657,3 +1660,96 @@ async def test_power_config_no_bind(zigpy_device_from_quirk, quirk):
 
         assert len(request_mock.mock_calls) == 0
         assert len(bind_mock.mock_calls) == 0
+
+
+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"),
+    [
+        (
+            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,
+        ),
+    ],
+)
+async def test_ts601_door_sensor(
+    zigpy_device_from_quirk, data, endpoint_id, ep_attr, attribute, expected_value
+):
+    """Test TS601 Vibration Door Sensor quirk.
+
+    The quirk is tested for:
+        - Open/Closed door
+        - Vibration On/Off
+        - Remaining battery percentage
+    """
+    device: Device = zigpy_device_from_quirk(zhaquirks.tuya.ts601_door.TS0601Door)
+    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
+    ]
+
+    with mock.patch.object(cluster, "send_default_rsp"):
+        device.packet_received(
+            t.ZigbeePacket(
+                profile_id=zha.PROFILE_ID,
+                src_ep=dp_processor_ep,
+                cluster_id=TuyaNewManufCluster.cluster_id,
+                data=t.SerializableBytes(data),
+            )
+        )
+
+    cluster = getattr(device.endpoints[endpoint_id], ep_attr)
+    attrs = await cluster.read_attributes(attributes=[attribute])
+
+    assert attrs[0].get(attribute) == expected_value
diff --git a/tests/test_tuya_smoke.py b/tests/test_tuya_smoke.py
new file mode 100644
index 0000000..1f0540e
--- /dev/null
+++ b/tests/test_tuya_smoke.py
@@ -0,0 +1,68 @@
+"""Tests for Tuya Smoke Detector."""
+
+import pytest
+from zigpy.zcl.clusters.security import IasZone
+
+from tests.common import ClusterListener
+import zhaquirks.tuya.ts0205
+
+zhaquirks.setup()
+
+
+def test_ts0205_signature(assert_signature_matches_quirk):
+    """Test signature."""
+
+    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": 0x0104,
+                "device_type": "0x0402",
+                "in_clusters": ["0x0000", "0x0001", "0x0004", "0x0005", "0x0500"],
+                "out_clusters": [
+                    "0x0003",
+                    "0x0004",
+                    "0x0006",
+                    "0x000a",
+                    "0x0019",
+                    "0x1000",
+                ],
+            }
+        },
+        "manufacturer": "_TZ3210_up3pngle",
+        "model": "TS0205",
+        "class": "ts0205.TuyaSmokeDetectorTS0205",
+    }
+    assert_signature_matches_quirk(
+        zhaquirks.tuya.ts0205.TuyaSmokeDetectorTS0205, signature
+    )
+
+
+@pytest.mark.parametrize("quirk", (zhaquirks.tuya.ts0205.TuyaSmokeDetectorTS0205,))
+async def test_tuya_smoke_sensor_attribute_update(zigpy_device_from_quirk, quirk):
+    """Test update_attribute on Tuya smoke sensor."""
+
+    device = zigpy_device_from_quirk(quirk)
+
+    opple_cluster = device.endpoints[1].tuya_manufacturer
+    opple_listener = ClusterListener(opple_cluster)
+
+    ias_cluster = device.endpoints[1].ias_zone
+    ias_listener = ClusterListener(ias_cluster)
+
+    zone_status_id = IasZone.AttributeDefs.zone_status.id
+
+    # check that updating smoke attribute also updates zone status on the Ias Zone cluster
+
+    # turn on smoke alarm
+    opple_cluster.update_attribute(0x0401, 1)
+    assert len(opple_listener.attribute_updates) == 1
+    assert len(ias_listener.attribute_updates) == 1
+    assert ias_listener.attribute_updates[0][0] == zone_status_id
+    assert ias_listener.attribute_updates[0][1] == 0
+    # turn off smoke alarm
+    opple_cluster.update_attribute(0x0401, 0)
+    assert len(opple_listener.attribute_updates) == 2
+    assert len(ias_listener.attribute_updates) == 2
+    assert ias_listener.attribute_updates[1][0] == zone_status_id
+    assert ias_listener.attribute_updates[1][1] == IasZone.ZoneStatus.Alarm_1