Repository: zigpy/zha-device-handlers
Pull request #3315 (2024-08-25 02:06:26)

Add Tuya soil sensor variant `_TZE284_aao3yzhs` and `_TZE284_sgabhwa6`
## Proposed change
<!--
  Explain your proposed change below.
-->
Add support for TUYA TS0601 _TZE284_aao3yzhs

Also adds support for TS0601_TZE284_sgabhwa6.  @GeoffWy confirms suitability for TS0601_TZE284_sgabhwa6.

## 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".
-->

Fixes: https://github.com/zigpy/zha-device-handlers/issues/3305
Fixes: https://github.com/zigpy/zha-device-handlers/issues/3266


## 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
- [ ] Tests have been added to verify that the new code works


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3315?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.26%. Comparing base [(`156aaa6`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/156aaa6bb704a13fe9500b833c293405fc5ffd79?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) to head [(`5f04e99`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/5f04e99a128c13b4de5f9492a02fccf0b7ec0c79?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    #3315   +/-   ##
=======================================
  Coverage   88.25%   88.26%           
=======================================
  Files         302      302           
  Lines        9434     9440    +6     
=======================================
+ Hits         8326     8332    +6     
  Misses       1108     1108           
```

</details>

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

GeoffWy: This quirk also works for Tuya soil sensor variant _TZE284_sgabhwa6

Patch:
diff --git a/zhaquirks/tuya/__init__.py b/zhaquirks/tuya/__init__.py
index 9c9fca9..63b37cd 100644
--- a/zhaquirks/tuya/__init__.py
+++ b/zhaquirks/tuya/__init__.py
@@ -948,42 +948,30 @@ class TuyaPowerConfigurationCluster(PowerConfiguration, TuyaLocalCluster):
 class TuyaPowerConfigurationCluster2AAA(PowerConfiguration, TuyaLocalCluster):
     """PowerConfiguration cluster for devices with 2 AAA."""
 
-    BATTERY_SIZES = 0x0031
-    BATTERY_QUANTITY = 0x0033
-    BATTERY_RATED_VOLTAGE = 0x0034
-
     _CONSTANT_ATTRIBUTES = {
-        BATTERY_SIZES: 4,
-        BATTERY_QUANTITY: 2,
-        BATTERY_RATED_VOLTAGE: 15,
+        PowerConfiguration.AttributeDefs.battery_size.id: 4,
+        PowerConfiguration.AttributeDefs.battery_rated_voltage.id: 15,
+        PowerConfiguration.AttributeDefs.battery_quantity.id: 2,
     }
 
 
 class TuyaPowerConfigurationCluster2AA(TuyaPowerConfigurationCluster):
     """PowerConfiguration cluster for devices with 2 AA."""
 
-    BATTERY_SIZES = 0x0031
-    BATTERY_RATED_VOLTAGE = 0x0034
-    BATTERY_QUANTITY = 0x0033
-
     _CONSTANT_ATTRIBUTES = {
-        BATTERY_SIZES: 3,
-        BATTERY_RATED_VOLTAGE: 15,
-        BATTERY_QUANTITY: 2,
+        PowerConfiguration.AttributeDefs.battery_size.id: 3,
+        PowerConfiguration.AttributeDefs.battery_rated_voltage.id: 15,
+        PowerConfiguration.AttributeDefs.battery_quantity.id: 2,
     }
 
 
 class TuyaPowerConfigurationCluster3AA(TuyaPowerConfigurationCluster):
     """PowerConfiguration cluster for devices with 3 AA."""
 
-    BATTERY_SIZES = 0x0031
-    BATTERY_RATED_VOLTAGE = 0x0034
-    BATTERY_QUANTITY = 0x0033
-
     _CONSTANT_ATTRIBUTES = {
-        BATTERY_SIZES: 3,
-        BATTERY_RATED_VOLTAGE: 15,
-        BATTERY_QUANTITY: 3,
+        PowerConfiguration.AttributeDefs.battery_size.id: 3,
+        PowerConfiguration.AttributeDefs.battery_rated_voltage.id: 15,
+        PowerConfiguration.AttributeDefs.battery_quantity.id: 3,
     }
 
 
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/ts0601_sensor.py b/zhaquirks/tuya/ts0601_sensor.py
index 4cc711d..cc0190e 100644
--- a/zhaquirks/tuya/ts0601_sensor.py
+++ b/zhaquirks/tuya/ts0601_sensor.py
@@ -84,7 +84,7 @@ class TemperatureHumidityBatteryStatesManufCluster(TuyaMCUCluster):
         3: DPToAttributeMapping(
             TuyaPowerConfigurationCluster2AAA.ep_attribute,
             "battery_percentage_remaining",
-            converter=lambda x: {0: 25, 1: 50, 2: 100}[x],  # double reported percentage
+            converter=lambda x: {0: 50, 1: 100, 2: 200}[x],
         ),
     }
 
@@ -296,6 +296,7 @@ class TuyaTempHumiditySensorVar04(CustomDevice):
             ("_TZE204_yjjdcqsq", "TS0601"),
             ("_TZE200_utkemkbs", "TS0601"),
             ("_TZE204_utkemkbs", "TS0601"),
+            ("_TZE204_yjjdcqsq", "TS0601"),
         ],
         ENDPOINTS: {
             1: {
@@ -407,3 +408,79 @@ class TuyaSoilSensor(CustomDevice):
             }
         },
     }
+
+
+class SoilManufClusterVar02(TuyaMCUCluster):
+    """Tuya Manufacturer Cluster with Temperature and Humidity data points (variation 02)."""
+
+    dp_to_attribute: dict[int, DPToAttributeMapping] = {
+        5: DPToAttributeMapping(
+            TuyaTemperatureMeasurement.ep_attribute,
+            "measured_value",
+            converter=lambda x: x * 10,
+        ),
+        3: DPToAttributeMapping(
+            TuyaSoilMoisture.ep_attribute,
+            "measured_value",
+            converter=lambda x: x * 100,
+        ),
+        15: DPToAttributeMapping(
+            TuyaPowerConfigurationCluster2AAA.ep_attribute,
+            "battery_percentage_remaining",
+            converter=lambda x: x * 2,  # double reported percentage
+        ),
+    }
+
+    data_point_handlers = {
+        3: "_dp_2_attr_update",
+        5: "_dp_2_attr_update",
+        15: "_dp_2_attr_update",
+    }
+
+
+class TuyaSoilSensorVar02(CustomDevice):
+    """Tuya temp and humidity sensor (variation 05)."""
+
+    signature = {
+        # "profile_id": 260,
+        # "device_type": "0x0051",
+        # "in_clusters": ["0x0000","0x0004","0x0005","0xed00","0xef00"],
+        # "out_clusters": ["0x000a","0x0019"]
+        MODELS_INFO: [
+            ("_TZE284_aao3yzhs", "TS0601"),
+            ("_TZE284_sgabhwa6", "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,
+                    0xED00,
+                    TemperatureHumidityManufCluster.cluster_id,
+                ],
+                OUTPUT_CLUSTERS: [Ota.cluster_id, Time.cluster_id],
+            }
+        },
+    }
+
+    replacement = {
+        SKIP_CONFIGURATION: True,
+        ENDPOINTS: {
+            1: {
+                DEVICE_TYPE: zha.DeviceType.TEMPERATURE_SENSOR,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    Groups.cluster_id,
+                    Scenes.cluster_id,
+                    SoilManufClusterVar02,
+                    TuyaTemperatureMeasurement,
+                    TuyaSoilMoisture,
+                    TuyaPowerConfigurationCluster2AAA,
+                ],
+                OUTPUT_CLUSTERS: [Ota.cluster_id, Time.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