Repository: zigpy/zha-device-handlers
Pull request #2837 (2023-12-14 22:59:49)

Add PM2.5 for Tuya Smart Air House keeper 
## Proposed change
<!--
  Explain your proposed change below.
-->
Add capability to read PM25 values from Tuya Smart House keeper devices and split device into separate quirk.
This is my first pull request ever and also a first attempt at python so any guidance is appreciated.

## 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".
-->
also synced the supported models with those supported by Zigbee2MQTT

## 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/2837?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:
> Comparison is base [(`a0c4109`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/a0c41091e857bd28a070b96dc065f2fe29f08539?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) 87.37% compared to head [(`ecc9441`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/2837?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) 87.39%.


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


```diff
@@            Coverage Diff             @@
##              dev    #2837      +/-   ##
==========================================
+ Coverage   87.37%   87.39%   +0.01%     
==========================================
  Files         287      288       +1     
  Lines        8848     8860      +12     
==========================================
+ Hits         7731     7743      +12     
  Misses       1117     1117              
```



</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/2837?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/ikea/somrigsmartbtn.py b/zhaquirks/ikea/somrigsmartbtn.py
new file mode 100644
index 0000000..412249c
--- /dev/null
+++ b/zhaquirks/ikea/somrigsmartbtn.py
@@ -0,0 +1,165 @@
+"""Device handler for IKEA of Sweden SOMRIG shortcut button"""
+from zigpy.profiles import zha
+from zigpy.quirks import CustomDevice
+from zigpy.zcl.clusters.general import (
+    Basic,
+    Groups,
+    Identify,
+    LevelControl,
+    OnOff,
+    Ota,
+    PollControl,
+    PowerConfiguration,
+)
+from zigpy.zcl.clusters.lightlink import LightLink
+
+from zhaquirks.const import (
+    BUTTON_1,
+    BUTTON_2,
+    COMMAND,
+    COMMAND_M_INITIAL_PRESS,
+    COMMAND_M_LONG_PRESS,
+    COMMAND_M_LONG_RELEASE,
+    COMMAND_M_MULTI_PRESS_COMPLETE,
+    COMMAND_M_SHORT_RELEASE,
+    DEVICE_TYPE,
+    DOUBLE_PRESS,
+    ENDPOINT_ID,
+    ENDPOINTS,
+    INPUT_CLUSTERS,
+    LONG_PRESS,
+    LONG_RELEASE,
+    MODELS_INFO,
+    OUTPUT_CLUSTERS,
+    PRESSED,
+    PROFILE_ID,
+    SHORT_PRESS,
+)
+from zhaquirks.ikea import (
+    IKEA,
+    IKEA_CLUSTER_ID,
+    PowerConfig1AAACluster,
+    ShortcutV2Cluster,
+)
+
+
+class IkeaSomrigSmartButton(CustomDevice):
+    """Custom device representing IKEA SOMRIG shortcut button"""
+
+    signature = {
+        # <SimpleDescriptor endpoint=1 profile=260, device_type=6
+        # device_version=1
+        # input_clusters=[0, 1, 3, 4, 32, 4096, 64636, 64640]
+        # output_clusters=[3, 4, 6, 8, 25, 4096, 64640]>
+        MODELS_INFO: [(IKEA, "SOMRIG shortcut button")],
+        ENDPOINTS: {
+            1: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    PowerConfiguration.cluster_id,
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    PollControl.cluster_id,
+                    LightLink.cluster_id,
+                    IKEA_CLUSTER_ID,
+                    ShortcutV2Cluster.cluster_id,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    OnOff.cluster_id,
+                    LevelControl.cluster_id,
+                    Ota.cluster_id,
+                    LightLink.cluster_id,
+                    ShortcutV2Cluster.cluster_id,
+                ],
+            },
+            # <SimpleDescriptor endpoint=2  profile=260  device_type=6
+            # device_version=1
+            # input_clusters=[0, 3, 4, 64640]
+            # output_clusters=[3, 4, 6, 8, 64640]>
+            2: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    ShortcutV2Cluster.cluster_id,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    OnOff.cluster_id,
+                    LevelControl.cluster_id,
+                    ShortcutV2Cluster.cluster_id,
+                ],
+            },
+        },
+    }
+
+    replacement = {
+        ENDPOINTS: {
+            1: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    PowerConfig1AAACluster,
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    PollControl.cluster_id,
+                    LightLink.cluster_id,
+                    IKEA_CLUSTER_ID,
+                    ShortcutV2Cluster,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    OnOff.cluster_id,
+                    LevelControl.cluster_id,
+                    Ota.cluster_id,
+                    LightLink.cluster_id,
+                    ShortcutV2Cluster,
+                ],
+            },
+            2: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.REMOTE_CONTROL,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    ShortcutV2Cluster,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    OnOff.cluster_id,
+                    LevelControl.cluster_id,
+                    ShortcutV2Cluster,
+                ],
+            },
+        },
+    }
+
+    device_automation_triggers = {
+        (PRESSED, BUTTON_1): {ENDPOINT_ID: 1, COMMAND: COMMAND_M_INITIAL_PRESS},
+        (SHORT_PRESS, BUTTON_1): {ENDPOINT_ID: 1, COMMAND: COMMAND_M_SHORT_RELEASE},
+        (DOUBLE_PRESS, BUTTON_1): {
+            ENDPOINT_ID: 1,
+            COMMAND: COMMAND_M_MULTI_PRESS_COMPLETE,
+        },
+        (LONG_PRESS, BUTTON_1): {ENDPOINT_ID: 1, COMMAND: COMMAND_M_LONG_PRESS},
+        (LONG_RELEASE, BUTTON_1): {ENDPOINT_ID: 1, COMMAND: COMMAND_M_LONG_RELEASE},
+        (PRESSED, BUTTON_2): {ENDPOINT_ID: 2, COMMAND: COMMAND_M_INITIAL_PRESS},
+        (SHORT_PRESS, BUTTON_2): {ENDPOINT_ID: 2, COMMAND: COMMAND_M_SHORT_RELEASE},
+        (DOUBLE_PRESS, BUTTON_2): {
+            ENDPOINT_ID: 2,
+            COMMAND: COMMAND_M_MULTI_PRESS_COMPLETE,
+        },
+        (LONG_PRESS, BUTTON_2): {ENDPOINT_ID: 2, COMMAND: COMMAND_M_LONG_PRESS},
+        (LONG_RELEASE, BUTTON_2): {ENDPOINT_ID: 2, COMMAND: COMMAND_M_LONG_RELEASE},
+    }
diff --git a/zhaquirks/sonoff/snzb01p.py b/zhaquirks/sonoff/snzb01p.py
new file mode 100644
index 0000000..5248f09
--- /dev/null
+++ b/zhaquirks/sonoff/snzb01p.py
@@ -0,0 +1,91 @@
+"""Sonoff Smart Button SNZB-01P"""
+from zigpy.profiles import zha
+from zigpy.quirks import CustomDevice
+from zigpy.zcl.clusters.general import (
+    Basic,
+    Identify,
+    OnOff,
+    Ota,
+    PollControl,
+    PowerConfiguration,
+)
+
+from zhaquirks.const import (
+    BUTTON,
+    CLUSTER_ID,
+    COMMAND,
+    COMMAND_OFF,
+    COMMAND_ON,
+    COMMAND_TOGGLE,
+    DEVICE_TYPE,
+    DOUBLE_PRESS,
+    ENDPOINT_ID,
+    ENDPOINTS,
+    INPUT_CLUSTERS,
+    LONG_PRESS,
+    MODELS_INFO,
+    OUTPUT_CLUSTERS,
+    PROFILE_ID,
+    SHORT_PRESS,
+)
+
+SONOFF_CLUSTER_ID = 0xFC57
+
+
+class SonoffSmartButtonSNZB01P(CustomDevice):
+    """Sonoff smart button remote - model SNZB-01P"""
+
+    signature = {
+        #  <SimpleDescriptor endpoint=1 profile=260 device_type=0
+        #  device_version=1
+        #  input_clusters=[0, 1, 3, 32, 64599]
+        #  output_clusters=[3, 6, 25]>
+        MODELS_INFO: [
+            ("eWeLink", "SNZB-01P"),
+        ],
+        ENDPOINTS: {
+            1: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    PowerConfiguration.cluster_id,
+                    Identify.cluster_id,
+                    PollControl.cluster_id,
+                    SONOFF_CLUSTER_ID,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Identify.cluster_id,
+                    OnOff.cluster_id,
+                    Ota.cluster_id,
+                ],
+            },
+        },
+    }
+
+    replacement = {
+        ENDPOINTS: {
+            1: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    PowerConfiguration.cluster_id,
+                    Identify.cluster_id,
+                    PollControl.cluster_id,
+                    SONOFF_CLUSTER_ID,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Identify.cluster_id,
+                    OnOff.cluster_id,
+                    Ota.cluster_id,
+                ],
+            },
+        },
+    }
+
+    device_automation_triggers = {
+        (SHORT_PRESS, BUTTON): {COMMAND: COMMAND_TOGGLE, CLUSTER_ID: 6, ENDPOINT_ID: 1},
+        (DOUBLE_PRESS, BUTTON): {COMMAND: COMMAND_ON, CLUSTER_ID: 6, ENDPOINT_ID: 1},
+        (LONG_PRESS, BUTTON): {COMMAND: COMMAND_OFF, CLUSTER_ID: 6, ENDPOINT_ID: 1},
+    }
diff --git a/zhaquirks/tuya/air/__init__.py b/zhaquirks/tuya/air/__init__.py
index debbe4b..05d1437 100644
--- a/zhaquirks/tuya/air/__init__.py
+++ b/zhaquirks/tuya/air/__init__.py
@@ -4,6 +4,7 @@ from typing import Any, Dict
 
 import zigpy.types as t
 from zigpy.zcl.clusters.measurement import (
+    PM25,
     CarbonDioxideConcentration,
     FormaldehydeConcentration,
     RelativeHumidity,
@@ -67,6 +68,10 @@ class TuyaAirQualityHumidity(RelativeHumidity, TuyaLocalCluster):
     """Tuya relative humidity measurement."""
 
 
+class TuyaAirQualityPM25(PM25, TuyaLocalCluster):
+    """Tuya PM25 concentration measurement"""
+
+
 class TuyaAirQualityCO2(CarbonDioxideConcentration, TuyaLocalCluster):
     """Tuya Carbon Dioxide concentration measurement."""
 
@@ -92,6 +97,9 @@ class TuyaCO2ManufCluster(TuyaNewManufCluster):
         19: DPToAttributeMapping(
             TuyaAirQualityHumidity.ep_attribute, "measured_value", lambda x: x * 10
         ),
+        20: DPToAttributeMapping(
+            TuyaAirQualityPM25.ep_attribute, "measured_value", lambda x: x
+        ),
         21: DPToAttributeMapping(
             TuyaAirQualityVOC.ep_attribute, "measured_value", lambda x: x * 1e-6
         ),
@@ -106,6 +114,7 @@ class TuyaCO2ManufCluster(TuyaNewManufCluster):
         2: "_dp_2_attr_update",
         18: "_dp_2_attr_update",
         19: "_dp_2_attr_update",
+        20: "_dp_2_attr_update",
         21: "_dp_2_attr_update",
         22: "_dp_2_attr_update",
     }
diff --git a/zhaquirks/tuya/air/ts0601_air_quality.py b/zhaquirks/tuya/air/ts0601_air_quality.py
index 790f261..af80ab3 100644
--- a/zhaquirks/tuya/air/ts0601_air_quality.py
+++ b/zhaquirks/tuya/air/ts0601_air_quality.py
@@ -33,8 +33,9 @@ class TuyaCO2Sensor(CustomDevice):
         # output_clusters=[25, 10])
         MODELS_INFO: [
             ("_TZE200_8ygsuhe1", "TS0601"),
+            ("_TZE200_ryfmq5rl", "TS0601"),
             ("_TZE200_yvx5lh6k", "TS0601"),
-            ("_TZE200_dwcarsat", "TS0601"),
+            ("_TZE200_c2fmom5z", "TS0601"),
         ],
         ENDPOINTS: {
             1: {
@@ -82,10 +83,9 @@ class TuyaCO2SensorGPP(CustomDevice):
         # input_clusters=[0, 4, 5, 61184],
         # output_clusters=[25, 10])
         MODELS_INFO: [
-            ("_TZE200_mja3fuja", "TS0601"),
+            ("_TZE200_8ygsuhe1", "TS0601"),
             ("_TZE200_ryfmq5rl", "TS0601"),
             ("_TZE200_yvx5lh6k", "TS0601"),
-            ("_TZE200_dwcarsat", "TS0601"),
             ("_TZE200_c2fmom5z", "TS0601"),
         ],
         ENDPOINTS: {
diff --git a/zhaquirks/tuya/air/ts0601_smart_air.py b/zhaquirks/tuya/air/ts0601_smart_air.py
new file mode 100644
index 0000000..9ba04ce
--- /dev/null
+++ b/zhaquirks/tuya/air/ts0601_smart_air.py
@@ -0,0 +1,137 @@
+"""Tuya Smart Air House keeper."""
+
+from zigpy.profiles import zgp, zha
+from zigpy.quirks import CustomDevice
+from zigpy.zcl.clusters.general import Basic, GreenPowerProxy, Groups, Ota, Scenes, Time
+
+from zhaquirks.const import (
+    DEVICE_TYPE,
+    ENDPOINTS,
+    INPUT_CLUSTERS,
+    MODELS_INFO,
+    OUTPUT_CLUSTERS,
+    PROFILE_ID,
+)
+from zhaquirks.tuya.air import (
+    TuyaAirQualityCO2,
+    TuyaAirQualityFormaldehyde,
+    TuyaAirQualityHumidity,
+    TuyaAirQualityPM25,
+    TuyaAirQualityTemperature,
+    TuyaAirQualityVOC,
+    TuyaCO2ManufCluster,
+)
+
+
+class TuyaSmartAirSensor(CustomDevice):
+    """Tuya Smart air house keeper device."""
+
+    signature = {
+        # NodeDescriptor(logical_type=<LogicalType.Router: 1>, complex_descriptor_available=0, user_descriptor_available=0, reserved=0, aps_flags=0, frequency_band=<FrequencyBand.Freq2400MHz: 8>, mac_capability_flags=<MACCapabilityFlags.FullFunctionDevice|MainsPowered|RxOnWhenIdle|AllocateAddress: 142>, 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=False, *is_full_function_device=True, *is_mains_powered=True, *is_receiver_on_when_idle=True, *is_router=True, *is_security_capable=False)
+        # SizePrefixedSimpleDescriptor(endpoint=1, profile=260, device_type=81, device_version=1,
+        # input_clusters=[0, 4, 5, 1026, 1029, 1037, 1067, 1070, 61184],
+        # output_clusters=[25, 10])
+        MODELS_INFO: [
+            ("_TZE200_mja3fuja", "TS0601"),
+            ("_TZE200_dwcarsat", "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,
+                    TuyaCO2ManufCluster.cluster_id,
+                ],
+                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
+            }
+        },
+    }
+
+    replacement = {
+        ENDPOINTS: {
+            1: {
+                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    Groups.cluster_id,
+                    Scenes.cluster_id,
+                    TuyaCO2ManufCluster,
+                    TuyaAirQualityCO2,
+                    TuyaAirQualityFormaldehyde,
+                    TuyaAirQualityHumidity,
+                    TuyaAirQualityPM25,
+                    TuyaAirQualityTemperature,
+                    TuyaAirQualityVOC,
+                ],
+                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
+            }
+        }
+    }
+
+
+class TuyaSmartAirSensorGPP(CustomDevice):
+    """Tuya Air quality device with GPP."""
+
+    signature = {
+        # NodeDescriptor(logical_type=<LogicalType.Router: 1>, complex_descriptor_available=0, user_descriptor_available=0, reserved=0, aps_flags=0, frequency_band=<FrequencyBand.Freq2400MHz: 8>, mac_capability_flags=<MACCapabilityFlags.AllocateAddress|RxOnWhenIdle|MainsPowered|FullFunctionDevice: 142>, manufacturer_code=4098, maximum_buffer_size=82, maximum_incoming_transfer_size=82, server_mask=11264, maximum_outgoing_transfer_size=82, descriptor_capability_field=<DescriptorCapability.0: 0>, *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)]
+        # device_version=1
+        # SizePrefixedSimpleDescriptor(endpoint=1, profile=260, device_type=81, device_version=1,
+        # input_clusters=[0, 4, 5, 1026, 1029, 1037, 1067, 1070, 61184],
+        # output_clusters=[10, 25])
+        MODELS_INFO: [
+            ("_TZE200_mja3fuja", "TS0601"),
+            ("_TZE200_dwcarsat", "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,
+                    TuyaCO2ManufCluster.cluster_id,
+                ],
+                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
+            },
+            242: {
+                # <SimpleDescriptor endpoint=242 profile=41440 device_type=97
+                # input_clusters=[]
+                # output_clusters=[33]
+                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,
+                    TuyaCO2ManufCluster,
+                    TuyaAirQualityCO2,
+                    TuyaAirQualityFormaldehyde,
+                    TuyaAirQualityHumidity,
+                    TuyaAirQualityPM25,
+                    TuyaAirQualityTemperature,
+                    TuyaAirQualityVOC,
+                ],
+                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],
+            },
+        }
+    }
diff --git a/zhaquirks/tuya/ts0601_gas.py b/zhaquirks/tuya/ts0601_gas.py
index d1e6885..5670426 100644
--- a/zhaquirks/tuya/ts0601_gas.py
+++ b/zhaquirks/tuya/ts0601_gas.py
@@ -79,7 +79,10 @@ class TuyaGasDetector0601(CustomDevice):
         super().__init__(*args, **kwargs)
 
     signature = {
-        MODELS_INFO: [("_TZE200_ggev5fsl", "TS0601")],
+        MODELS_INFO: [
+            ("_TZE200_ggev5fsl", "TS0601"),
+            ("_TZE200_rjxqso4a", "TS0601"),
+        ],
         ENDPOINTS: {
             1: {
                 PROFILE_ID: zigpy.profiles.zha.PROFILE_ID,
diff --git a/zhaquirks/tuya/ts0601_trv.py b/zhaquirks/tuya/ts0601_trv.py
index 1400888..d480b9c 100644
--- a/zhaquirks/tuya/ts0601_trv.py
+++ b/zhaquirks/tuya/ts0601_trv.py
@@ -1536,6 +1536,7 @@ class MoesHY368_Type1(TuyaThermostat):
             ("_TZE200_9sfg7gm0", "TS0601"),
             ("_TZE200_8whxpsiw", "TS0601"),
             ("_TZE200_8thwkzxl", "TS0601"),  # Tervix Pro Line EVA 2
+            ("_TZE200_xby0s3ta", "TS0601"),  # Sandy Beach HY367
         ],
         ENDPOINTS: {
             1: {


Test patch:
diff --git a/tests/test_sonoff.py b/tests/test_sonoff.py
new file mode 100644
index 0000000..2e5b47a
--- /dev/null
+++ b/tests/test_sonoff.py
@@ -0,0 +1,30 @@
+"""Tests for Sonoff quirks."""
+
+
+import zhaquirks
+import zhaquirks.sonoff.snzb01p
+
+zhaquirks.setup()
+
+
+def test_sonoff_snzb01p(assert_signature_matches_quirk):
+    """Test 'Sonoff SNZB-01P smart button' signature is matched to its 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=4742, maximum_buffer_size=82, maximum_incoming_transfer_size=255, server_mask=11264, maximum_outgoing_transfer_size=255, 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": "0x0000",
+                "in_clusters": ["0x0000", "0x0001", "0x0003", "0x0020", "0xfc57"],
+                "out_clusters": ["0x0003", "0x0006", "0x0019"],
+            }
+        },
+        "manufacturer": "eWeLink",
+        "model": "SNZB-01P",
+        "class": "sonoff.snzb01p.SonoffSmartButtonSNZB01P",
+    }
+
+    assert_signature_matches_quirk(
+        zhaquirks.sonoff.snzb01p.SonoffSmartButtonSNZB01P, signature
+    )
diff --git a/tests/test_tuya_air.py b/tests/test_tuya_air.py
index 427edc4..ded8c3f 100644
--- a/tests/test_tuya_air.py
+++ b/tests/test_tuya_air.py
@@ -8,6 +8,7 @@ import zigpy.profiles.zha
 import zhaquirks
 from zhaquirks.tuya import TuyaNewManufCluster
 from zhaquirks.tuya.air.ts0601_air_quality import TuyaCO2Sensor
+from zhaquirks.tuya.air.ts0601_smart_air import TuyaSmartAirSensor
 
 zhaquirks.setup()
 
@@ -69,3 +70,67 @@ def test_co2_sensor(air_quality_device, data, ep_attr, expected_value):
     )
     cluster = getattr(air_quality_device.endpoints[1], ep_attr)
     assert cluster.get("measured_value") == expected_value
+
+
+@pytest.fixture
+def smart_air_quality_device(zigpy_device_from_quirk):
+    """Tuya Smart Air Quality Sensor."""
+    dev = zigpy_device_from_quirk(TuyaSmartAirSensor)
+    cluster = dev.endpoints[1].in_clusters[TuyaNewManufCluster.cluster_id]
+    with mock.patch.object(cluster, "send_default_rsp"):
+        yield dev
+
+
+@pytest.mark.parametrize(
+    "data, ep_attr, expected_value",
+    (
+        (
+            b"\t2\x01\x00\x02\x02\x02\x00\x04\x00\x00\x01r",
+            "carbon_dioxide_concentration",
+            370 * 1e-6,
+        ),
+        (
+            b"\t$\x01\x00\x00\x13\x02\x00\x04\x00\x00\x02\xd6",
+            "humidity",
+            7260,
+        ),
+        (
+            b"\t\x03\x01\x00\x01\x15\x02\x00\x04\x00\x00\x00\x01",
+            "voc_level",
+            1 * 1e-6,
+        ),
+        (
+            b"\t\x02\x01\x00\x01\x16\x02\x00\x04\x00\x00\x00\x02",
+            "formaldehyde_concentration",
+            2 * 1e-6,
+        ),
+        (
+            b"\t\x02\x01\x00\x00\x12\x02\x00\x04\x00\x00\x01 ",
+            "temperature",
+            2880,
+        ),
+        (
+            b"\t\x02\x01\x00\x00\x12\x02\x00\x04\x00\x00\xff\xfb",
+            "temperature",
+            -50,
+        ),
+        (
+            b"\t\x02\x01\x00\x00\x12\x02\x00\x04\x00\x00\xff\xef",
+            "temperature",
+            -170,
+        ),
+        (
+            b"\t\xa5\x02\x00\x01\x14\x02\x00\x04\x00\x00\x00\x01",
+            "pm25",
+            1,
+        ),
+    ),
+)
+def test_smart_air_sensor(smart_air_quality_device, data, ep_attr, expected_value):
+    """Test Tuya Smart Air Sensor."""
+
+    smart_air_quality_device.handle_message(
+        zigpy.profiles.zha.PROFILE_ID, TuyaNewManufCluster.cluster_id, 1, 1, data
+    )
+    cluster = getattr(smart_air_quality_device.endpoints[1], ep_attr)
+    assert cluster.get("measured_value") == expected_value