Repository: zigpy/zha-device-handlers
Pull request #2829 (2023-12-10 23:33:29)

Improve support for sinope Calypso and Eco Mode
## Proposed change
<!--
  Explain your proposed change below.
-->
- Code cleanup for calypso controller.
- Add support for Eco Mode on thermostats, Eco Sinopé
- Add  many attributes for sensor and light in manufacturer cluster

## 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
- [ ] Tests have been added to verify that the new code works


codecov[bot]: ## [Codecov](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/2829?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 [(`5291121`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/529112195dcdc3c8108df05e3a838478ee75ac6e?el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) 87.37% compared to head [(`608dcfb`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/2829?src=pr&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) 87.37%.


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


```diff
@@            Coverage Diff             @@
##              dev    #2829      +/-   ##
==========================================
- Coverage   87.37%   87.37%   -0.01%     
==========================================
  Files         287      287              
  Lines        8847     8845       -2     
==========================================
- Hits         7730     7728       -2     
  Misses       1117     1117              
```



</details>

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

claudegel: It was an error as the TankSize finally is not in the attribute but only in Neviweb cloud. It is not used by the Calypso. I've left the attribute there as other could be able to find out the usage.
I have many other attribute that are still unknown. is it better to leave them out until I find the usage or put them in the quirks so other have access to play with them.

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/sinope/light.py b/zhaquirks/sinope/light.py
index 75e5f15..d4d672c 100644
--- a/zhaquirks/sinope/light.py
+++ b/zhaquirks/sinope/light.py
@@ -88,6 +88,7 @@ class SinopeTechnologiesManufacturerCluster(CustomCluster):
         0x0055: ("min_intensity", t.uint16_t, True),
         0x0056: ("phase_control", PhaseControl, True),
         0x0058: ("double_up_full", DoubleFull, True),
+        0x0090: ("current_summation_delivered", t.uint32_t, True),
         0x00A0: ("timer", t.uint32_t, True),
         0x00A1: ("timer_countdown", t.uint32_t, True),
         0x0119: ("connected_load", t.uint16_t, True),
diff --git a/zhaquirks/sinope/sensor.py b/zhaquirks/sinope/sensor.py
index 0d7f0a6..0ed68f5 100644
--- a/zhaquirks/sinope/sensor.py
+++ b/zhaquirks/sinope/sensor.py
@@ -39,6 +39,9 @@ class SinopeManufacturerCluster(CustomCluster):
     attributes = {
         0x0003: ("firmware_number", t.uint16_t, True),
         0x0004: ("firmware_version", t.CharacterString, True),
+        0x0032: ("min_temperature_limit", t.int16s, True),
+        0x0033: ("max_temperature_limit", t.int16s, True),
+        0x0034: ("device_status", t.bitmap8, True),
         0x0200: ("status", t.bitmap32, True),
         0xFFFD: ("cluster_revision", t.uint16_t, True),
     }
diff --git a/zhaquirks/sinope/switch.py b/zhaquirks/sinope/switch.py
index 79967a1..2d17471 100644
--- a/zhaquirks/sinope/switch.py
+++ b/zhaquirks/sinope/switch.py
@@ -97,14 +97,6 @@ class SinopeManufacturerCluster(CustomCluster):
         Active = 0x00
         Off = 0x01
 
-    class TankSize(t.enum8):
-        """tank_size values."""
-
-        Gal_40 = 0x01
-        Gal_50 = 0x02
-        Gal_60 = 0x03
-        Gal_80 = 0x04
-
     class FlowDuration(t.uint32_t):
         """Abnormal flow duration."""
 
@@ -127,12 +119,14 @@ class SinopeManufacturerCluster(CustomCluster):
         0x0003: ("firmware_number", t.uint16_t, True),
         0x0004: ("firmware_version", t.CharacterString, True),
         0x0010: ("outdoor_temp", t.int16s, True),
-        0x0013: ("tank_size", TankSize, True),
+        0x0013: ("unknown_attr_1", t.enum8, True),
         0x0060: ("connected_load", t.uint16_t, True),
         0x0070: ("current_load", t.bitmap8, True),
         0x0076: ("dr_config_water_temp_min", t.uint8_t, True),
         0x0077: ("dr_config_water_temp_time", t.uint8_t, True),
         0x0078: ("dr_wt_time_on", t.uint16_t, True),
+        0x007C: ("min_measured_temp", t.int16s, True),
+        0x007D: ("max_measured_temp", t.int16s, True),
         0x0090: ("current_summation_delivered", t.uint32_t, True),
         0x00A0: ("timer", t.uint32_t, True),
         0x00A1: ("timer_countdown", t.uint32_t, True),
@@ -145,7 +139,9 @@ class SinopeManufacturerCluster(CustomCluster):
         0x0251: ("emergency_power_source", EmergencyPower, True),
         0x0252: ("abnormal_flow_duration", FlowDuration, True),
         0x0253: ("abnormal_flow_action", AbnormalAction, True),
+        0x0280: ("max_measured_value", t.int16s, True),
         0x0283: ("cold_load_pickup_status", ColdStatus, True),
+        0x0284: ("cold_load_pickup_remaining_time", t.uint16_t, True),
         0xFFFD: ("cluster_revision", t.uint16_t, True),
     }
 
@@ -157,6 +153,7 @@ class CustomBasicCluster(CustomCluster, Basic):
         """Power source."""
 
         Unknown = 0x0000
+        DC_mains = 0x0001
         Battery = 0x0003
         DC_source = 0x0004
         ACUPS_01 = 0x0081
@@ -585,16 +582,8 @@ class SinopeTechnologiesCalypso(CustomDevice):
                     Time.cluster_id,
                     Ota.cluster_id,
                 ],
-            },
-            2: {
-                PROFILE_ID: zha_p.PROFILE_ID,
-                DEVICE_TYPE: zha_p.DeviceType.ON_OFF_OUTPUT,
-                INPUT_CLUSTERS: [
-                    TemperatureMeasurement.cluster_id,
-                ],
-                OUTPUT_CLUSTERS: [],
-            },
-        },
+            }
+        }
     }
 
 
diff --git a/zhaquirks/sinope/thermostat.py b/zhaquirks/sinope/thermostat.py
index b9f2293..1ec03ee 100644
--- a/zhaquirks/sinope/thermostat.py
+++ b/zhaquirks/sinope/thermostat.py
@@ -20,6 +20,7 @@ from zigpy.zcl.clusters.homeautomation import Diagnostic, ElectricalMeasurement
 from zigpy.zcl.clusters.hvac import Thermostat, UserInterface
 from zigpy.zcl.clusters.measurement import TemperatureMeasurement
 from zigpy.zcl.clusters.smartenergy import Metering
+from zigpy.zcl.foundation import Array
 
 from zhaquirks.const import (
     DEVICE_TYPE,
@@ -122,9 +123,10 @@ class SinopeTechnologiesManufacturerCluster(CustomCluster):
         0x0012: ("config_2nd_display", Display, True),
         0x0020: ("secs_since_2k", t.uint32_t, True),
         0x0070: ("current_load", t.bitmap8, True),
-        0x0071: ("eco_mode", t.int8s, True),
-        0x0072: ("eco_mode_1", t.uint8_t, True),
-        0x0073: ("eco_mode_2", t.uint8_t, True),
+        0x0071: ("eco_delta_setpoint", t.int8s, True),
+        0x0072: ("eco_max_pi_heating_demand", t.uint8_t, True),
+        0x0073: ("eco_safety_temperature_delta", t.uint8_t, True),
+        0x0101: ("unknown_attr_1", Array, True),
         0x0104: ("setpoint", t.int16s, True),
         0x0105: ("air_floor_mode", FloorMode, True),
         0x0106: ("aux_output_mode", AuxMode, True),
@@ -174,6 +176,7 @@ class SinopeTechnologiesThermostatCluster(CustomCluster, Thermostat):
         Min_20 = 0x04B0
         Min_25 = 0x05DC
         Min_30 = 0x0708
+        Off = 0xFFFF
 
     attributes = Thermostat.attributes.copy()
     attributes.update(
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: {
diff --git a/zhaquirks/xiaomi/aqara/opple_switch.py b/zhaquirks/xiaomi/aqara/opple_switch.py
index cc60f55..278f090 100644
--- a/zhaquirks/xiaomi/aqara/opple_switch.py
+++ b/zhaquirks/xiaomi/aqara/opple_switch.py
@@ -141,7 +141,10 @@ class XiaomiOpple2ButtonSwitchBase(XiaomiCustomDevice):
                     ElectricalMeasurementCluster,
                     OppleSwitchCluster,
                 ],
-                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
+                OUTPUT_CLUSTERS: [
+                    Time.cluster_id,
+                    Ota.cluster_id,
+                ],
             },
             2: {
                 DEVICE_TYPE: zha.DeviceType.ON_OFF_SWITCH,
@@ -214,7 +217,9 @@ class XiaomiOpple2ButtonSwitchBase(XiaomiCustomDevice):
                 PROFILE_ID: zgp.PROFILE_ID,
                 DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC,
                 INPUT_CLUSTERS: [],
-                OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
+                OUTPUT_CLUSTERS: [
+                    GreenPowerProxy.cluster_id,
+                ],
             },
         },
     }
@@ -297,7 +302,10 @@ class XiaomiOpple2ButtonSwitch1(XiaomiOpple2ButtonSwitchBase):
                     MultistateInput.cluster_id,
                     OppleSwitchCluster.cluster_id,
                 ],
-                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
+                OUTPUT_CLUSTERS: [
+                    Time.cluster_id,
+                    Ota.cluster_id,
+                ],
             },
             # input_clusters=[0, 3, 4, 5, 6, 18, 64704], output_clusters=[]
             2: {
@@ -363,7 +371,9 @@ class XiaomiOpple2ButtonSwitch1(XiaomiOpple2ButtonSwitchBase):
                 PROFILE_ID: zgp.PROFILE_ID,
                 DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC,
                 INPUT_CLUSTERS: [],
-                OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
+                OUTPUT_CLUSTERS: [
+                    GreenPowerProxy.cluster_id,
+                ],
             },
         },
     }
diff --git a/zhaquirks/xiaomi/aqara/switch_h1_double.py b/zhaquirks/xiaomi/aqara/switch_h1_double.py
index b897224..d2f7a72 100644
--- a/zhaquirks/xiaomi/aqara/switch_h1_double.py
+++ b/zhaquirks/xiaomi/aqara/switch_h1_double.py
@@ -13,7 +13,6 @@ from zigpy.zcl.clusters.general import (
     Scenes,
     Time,
 )
-from zigpy.zcl.clusters.smartenergy import Metering
 
 from zhaquirks.const import (
     DEVICE_TYPE,
@@ -24,7 +23,12 @@ from zhaquirks.const import (
     PROFILE_ID,
 )
 from zhaquirks.xiaomi import LUMI
-from zhaquirks.xiaomi.aqara.opple_switch import XiaomiOpple2ButtonSwitchBase
+from zhaquirks.xiaomi.aqara.opple_switch import (
+    OppleSwitchCluster,
+    XiaomiOpple2ButtonSwitch1,
+    XiaomiOpple2ButtonSwitch4,
+    XiaomiOpple2ButtonSwitchBase,
+)
 
 
 class AqaraH1DoubleRockerSwitchWithNeutral(XiaomiOpple2ButtonSwitchBase):
@@ -32,44 +36,16 @@ class AqaraH1DoubleRockerSwitchWithNeutral(XiaomiOpple2ButtonSwitchBase):
 
     signature = {
         MODELS_INFO: [(LUMI, "lumi.switch.n2aeu1")],
-        ENDPOINTS: {
-            #  input_clusters=[0, 2, 3, 4, 5, 6, 18, 64704], output_clusters=[10, 25]
-            1: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    DeviceTemperature.cluster_id,
-                    Identify.cluster_id,
-                    Groups.cluster_id,
-                    Scenes.cluster_id,
-                    OnOff.cluster_id,
-                    Alarms.cluster_id,
-                    Metering.cluster_id,
-                    0x0B04,
-                ],
-                OUTPUT_CLUSTERS: [Time.cluster_id, Ota.cluster_id],
-            },
-            #  input_clusters=[0, 3, 4, 5, 6, 18, 64704], output_clusters=[]
-            2: {
-                PROFILE_ID: zha.PROFILE_ID,
-                DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    Identify.cluster_id,
-                    Groups.cluster_id,
-                    Scenes.cluster_id,
-                    OnOff.cluster_id,
-                ],
-                OUTPUT_CLUSTERS: [],
-            },
-            242: {
-                PROFILE_ID: zgp.PROFILE_ID,
-                DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC,
-                INPUT_CLUSTERS: [],
-                OUTPUT_CLUSTERS: [GreenPowerProxy.cluster_id],
-            },
-        },
+        ENDPOINTS: XiaomiOpple2ButtonSwitch1.signature[ENDPOINTS],
+    }
+
+
+class AqaraH1DoubleRockerSwitchWithNeutralAlt(XiaomiOpple2ButtonSwitchBase):
+    """Aqara H1 Double Rocker Switch (with neutral) alternative signature."""
+
+    signature = {
+        MODELS_INFO: [(LUMI, "lumi.switch.n2aeu1")],
+        ENDPOINTS: XiaomiOpple2ButtonSwitch4.signature[ENDPOINTS],
     }
 
 
@@ -79,6 +55,7 @@ class AqaraH1DoubleRockerSwitchNoNeutral(XiaomiOpple2ButtonSwitchBase):
     signature = {
         MODELS_INFO: [(LUMI, "lumi.switch.l2aeu1")],
         ENDPOINTS: {
+            # input_clusters=[0, 2, 3, 4, 5, 6, 18, 64704], output_clusters=[10, 25]
             1: {
                 PROFILE_ID: zha.PROFILE_ID,
                 DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
@@ -90,13 +67,14 @@ class AqaraH1DoubleRockerSwitchNoNeutral(XiaomiOpple2ButtonSwitchBase):
                     Scenes.cluster_id,
                     OnOff.cluster_id,
                     MultistateInput.cluster_id,
-                    0xFCC0,
+                    OppleSwitchCluster.cluster_id,
                 ],
                 OUTPUT_CLUSTERS: [
                     Time.cluster_id,
                     Ota.cluster_id,
                 ],
             },
+            # input_clusters=[0, 3, 4, 5, 6, 18, 64704], output_clusters=[]
             2: {
                 PROFILE_ID: zha.PROFILE_ID,
                 DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
@@ -107,10 +85,11 @@ class AqaraH1DoubleRockerSwitchNoNeutral(XiaomiOpple2ButtonSwitchBase):
                     Scenes.cluster_id,
                     OnOff.cluster_id,
                     MultistateInput.cluster_id,
-                    0xFCC0,
+                    OppleSwitchCluster.cluster_id,
                 ],
                 OUTPUT_CLUSTERS: [],
             },
+            # input_clusters=[18], output_clusters=[]
             41: {
                 PROFILE_ID: zha.PROFILE_ID,
                 DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
@@ -119,6 +98,7 @@ class AqaraH1DoubleRockerSwitchNoNeutral(XiaomiOpple2ButtonSwitchBase):
                 ],
                 OUTPUT_CLUSTERS: [],
             },
+            # input_clusters=[18], output_clusters=[]
             42: {
                 PROFILE_ID: zha.PROFILE_ID,
                 DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
@@ -127,6 +107,7 @@ class AqaraH1DoubleRockerSwitchNoNeutral(XiaomiOpple2ButtonSwitchBase):
                 ],
                 OUTPUT_CLUSTERS: [],
             },
+            # input_clusters=[18], output_clusters=[]
             51: {
                 PROFILE_ID: zha.PROFILE_ID,
                 DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
@@ -153,6 +134,7 @@ class AqaraH1DoubleRockerSwitchNoNeutralAlt(XiaomiOpple2ButtonSwitchBase):
     signature = {
         MODELS_INFO: [(LUMI, "lumi.switch.l2aeu1")],
         ENDPOINTS: {
+            # input_clusters=[0, 2, 3, 4, 5, 6, 9], output_clusters=[10, 25]
             1: {
                 PROFILE_ID: zha.PROFILE_ID,
                 DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,
@@ -170,6 +152,7 @@ class AqaraH1DoubleRockerSwitchNoNeutralAlt(XiaomiOpple2ButtonSwitchBase):
                     Ota.cluster_id,
                 ],
             },
+            # input_clusters=[0, 3, 4, 5, 6], output_clusters=[]
             2: {
                 PROFILE_ID: zha.PROFILE_ID,
                 DEVICE_TYPE: zha.DeviceType.ON_OFF_LIGHT,


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