Repository: zigpy/zha-device-handlers
Pull request #3635 (2024-12-24 20:27:59)

Convert Tuya gas sensors to v2, add `_TZE200_hr0tdd47`
## Proposed change
<!--
  Explain your proposed change below.
-->

Convert Tuya gas sensors (co) to v2, add _TZE200_hr0tdd47.

Requires a resolution to https://github.com/zigpy/zha/issues/327

## 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/3632
Closes: https://github.com/zigpy/zha-device-handlers/issues/3714 

Based on: https://github.com/Koenkk/zigbee-herdsman-converters/blob/223fad439e83893e0956688c822dda83d46210cf/src/devices/moes.ts#L482

Original quirk exposes just co detected, this adds co concentration, self test result, battery, and silence.

z2m doesn't list `_TZE200_ggev5fsl`, so not 100% certain that DPs other than co alarm are the same, but it's probable.

## Checklist
<!--
  Put an 'x' in all boxes that apply.
  Note: You do not need to tick all boxes before creating a PR.
-->

- [ ] 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/3635?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 89.93%. Comparing base [(`56001dd`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/56001dd08f154ed8446e09269162efd393b045b7?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) to head [(`2654cec`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/2654cecf1300350f24bafa2d897dccf0b539896b?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    #3635      +/-   ##
==========================================
+ Coverage   89.84%   89.93%   +0.08%     
==========================================
  Files         322      322              
  Lines       10371    10354      -17     
==========================================
- Hits         9318     9312       -6     
+ Misses       1053     1042      -11     
```

</details>

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

dpozimski: can't wait to use it 👍 

Patch:
diff --git a/zhaquirks/tuya/builder/__init__.py b/zhaquirks/tuya/builder/__init__.py
index 64780d2..d92e54d 100644
--- a/zhaquirks/tuya/builder/__init__.py
+++ b/zhaquirks/tuya/builder/__init__.py
@@ -53,11 +53,11 @@ BATTERY_VOLTAGES = {
 }
 
 
-class TuyaCO2Concetration(CarbonDioxideConcentration, TuyaLocalCluster):
+class TuyaCO2Concentration(CarbonDioxideConcentration, TuyaLocalCluster):
     """Tuya Carbon Dioxide concentration measurement."""
 
 
-class TuyaFormaldehydeConcetration(FormaldehydeConcentration, TuyaLocalCluster):
+class TuyaFormaldehydeConcentration(FormaldehydeConcentration, TuyaLocalCluster):
     """Tuya Formaldehyde concentration measurement."""
 
     MOLECULAR_MASS = 30.026
@@ -79,10 +79,18 @@ class TuyaIasFire(IasZone, TuyaLocalCluster):
     }
 
 
-class TuyaPM25Concetration(PM25, TuyaLocalCluster):
+class TuyaPM25Concentration(PM25, TuyaLocalCluster):
     """Tuya PM25 concentration measurement."""
 
 
+class TuyaIasGas(IasZone, TuyaLocalCluster):
+    """Tuya local IAS gas cluster."""
+
+    _CONSTANT_ATTRIBUTES = {
+        IasZone.AttributeDefs.zone_type.id: IasZone.ZoneType.Carbon_Monoxide_Sensor
+    }
+
+
 class TuyaRelativeHumidity(RelativeHumidity, TuyaLocalCluster):
     """Tuya local RelativeHumidity cluster."""
 
@@ -222,7 +230,7 @@ class TuyaQuirkBuilder(QuirkBuilder):
     def tuya_co2(
         self,
         dp_id: int,
-        co2_cfg: TuyaLocalCluster = TuyaCO2Concetration,
+        co2_cfg: TuyaLocalCluster = TuyaCO2Concentration,
         scale: float = 1e-6,
     ) -> QuirkBuilder:
         """Add a Tuya CO2 Configuration."""
@@ -238,10 +246,10 @@ class TuyaQuirkBuilder(QuirkBuilder):
     def tuya_formaldehyde(
         self,
         dp_id: int,
-        form_cfg: TuyaLocalCluster = TuyaFormaldehydeConcetration,
+        form_cfg: TuyaLocalCluster = TuyaFormaldehydeConcentration,
         # Convert from µg/m3 to ppm, note, ZHA will scale by 1e6
         converter: float = lambda x: round(
-            ((MOL_VOL_AIR_NTP * x) / TuyaFormaldehydeConcetration.MOLECULAR_MASS), 2
+            ((MOL_VOL_AIR_NTP * x) / TuyaFormaldehydeConcentration.MOLECULAR_MASS), 2
         )
         * 1e-6,
     ) -> QuirkBuilder:
@@ -258,7 +266,7 @@ class TuyaQuirkBuilder(QuirkBuilder):
     def tuya_pm25(
         self,
         dp_id: int,
-        pm25_cfg: TuyaLocalCluster = TuyaPM25Concetration,
+        pm25_cfg: TuyaLocalCluster = TuyaPM25Concentration,
         scale: float = 1,
     ) -> QuirkBuilder:
         """Add a Tuya PM25 Configuration."""
@@ -271,6 +279,15 @@ class TuyaQuirkBuilder(QuirkBuilder):
         self.adds(pm25_cfg)
         return self
 
+    def tuya_gas(self, dp_id: int):
+        """Add a Tuya IAS gas sensor."""
+        self.tuya_ias(
+            dp_id=dp_id,
+            ias_cfg=TuyaIasGas,
+            converter=lambda x: IasZone.ZoneStatus.Alarm_1 if x == 0 else 0,
+        )
+        return self
+
     def tuya_smoke(self, dp_id: int):
         """Add a Tuya IAS smoke/fire sensor."""
         self.tuya_ias(
diff --git a/zhaquirks/tuya/ts0601_gas.py b/zhaquirks/tuya/ts0601_gas.py
deleted file mode 100644
index 4af76bd..0000000
--- a/zhaquirks/tuya/ts0601_gas.py
+++ /dev/null
@@ -1,123 +0,0 @@
-"""Gas Sensor."""
-
-import logging
-
-import zigpy.profiles.zha
-from zigpy.quirks import CustomCluster, CustomDevice
-import zigpy.types as t
-from zigpy.zcl.clusters.general import Basic, Groups, Ota, Scenes, Time
-from zigpy.zcl.clusters.security import IasZone
-
-from zhaquirks import Bus
-from zhaquirks.const import (
-    DEVICE_TYPE,
-    ENDPOINTS,
-    INPUT_CLUSTERS,
-    MODELS_INFO,
-    OUTPUT_CLUSTERS,
-    PROFILE_ID,
-    ZONE_STATUS,
-    ZONE_TYPE,
-)
-
-from . import TuyaManufCluster, TuyaManufClusterAttributes
-
-_LOGGER = logging.getLogger(__name__)
-
-TUYA_GAS_DETECTED_ATTR = 0x0401  # [0]/[1] [Detected]/[Clear]!
-
-
-class TuyaGasDetectorCluster(TuyaManufClusterAttributes):
-    """Manufacturer Specific Cluster of the TS0601 gas detector."""
-
-    attributes = TuyaManufClusterAttributes.attributes.copy()
-    attributes.update(
-        {
-            TUYA_GAS_DETECTED_ATTR: ("gas_detected", t.uint8_t, True),
-        }
-    )
-
-    def _update_attribute(self, attrid, value):
-        super()._update_attribute(attrid, value)
-        if attrid == TUYA_GAS_DETECTED_ATTR:
-            if value == 0:
-                self.endpoint.device.ias_bus.listener_event(
-                    "update_zone_status", IasZone.ZoneStatus.Alarm_1
-                )
-            else:
-                self.endpoint.device.ias_bus.listener_event("update_zone_status", 0)
-        else:
-            _LOGGER.warning(
-                "[0x%04x:%s:0x%04x] unhandled attribute: 0x%04x",
-                self.endpoint.device.nwk,
-                self.endpoint.endpoint_id,
-                self.cluster_id,
-                attrid,
-            )
-
-
-class TuyaGasDetectorZone(CustomCluster, IasZone):
-    """IAS Zone."""
-
-    _CONSTANT_ATTRIBUTES = {ZONE_TYPE: IasZone.ZoneType.Carbon_Monoxide_Sensor}
-
-    def __init__(self, *args, **kwargs):
-        """Init."""
-        super().__init__(*args, **kwargs)
-        self.endpoint.device.ias_bus.add_listener(self)
-
-    def update_zone_status(self, value):
-        """Update IAS status."""
-        super()._update_attribute(ZONE_STATUS, value)
-
-
-class TuyaGasDetector0601(CustomDevice):
-    """TS0601 _TZE200_ggev5fsl quirk."""
-
-    def __init__(self, *args, **kwargs):
-        """Init."""
-        self.ias_bus = Bus()
-        super().__init__(*args, **kwargs)
-
-    signature = {
-        MODELS_INFO: [
-            ("_TZE200_ggev5fsl", "TS0601"),
-            ("_TZE200_rjxqso4a", "TS0601"),
-        ],
-        ENDPOINTS: {
-            1: {
-                PROFILE_ID: zigpy.profiles.zha.PROFILE_ID,
-                DEVICE_TYPE: zigpy.profiles.zha.DeviceType.SMART_PLUG,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    Groups.cluster_id,
-                    Scenes.cluster_id,
-                    TuyaManufCluster.cluster_id,
-                ],
-                OUTPUT_CLUSTERS: [
-                    Time.cluster_id,
-                    Ota.cluster_id,
-                ],
-            },
-        },
-    }
-
-    replacement = {
-        ENDPOINTS: {
-            1: {
-                PROFILE_ID: zigpy.profiles.zha.PROFILE_ID,
-                DEVICE_TYPE: zigpy.profiles.zha.DeviceType.IAS_ZONE,
-                INPUT_CLUSTERS: [
-                    Basic.cluster_id,
-                    Groups.cluster_id,
-                    Scenes.cluster_id,
-                    TuyaGasDetectorZone,
-                    TuyaGasDetectorCluster,
-                ],
-                OUTPUT_CLUSTERS: [
-                    Time.cluster_id,
-                    Ota.cluster_id,
-                ],
-            },
-        },
-    }
diff --git a/zhaquirks/tuya/tuya_gas.py b/zhaquirks/tuya/tuya_gas.py
new file mode 100644
index 0000000..ee556b2
--- /dev/null
+++ b/zhaquirks/tuya/tuya_gas.py
@@ -0,0 +1,55 @@
+"""Tuya Gas Sensor."""
+
+from zigpy.quirks.v2 import EntityPlatform, EntityType
+from zigpy.quirks.v2.homeassistant import CONCENTRATION_PARTS_PER_MILLION
+from zigpy.quirks.v2.homeassistant.sensor import SensorDeviceClass, SensorStateClass
+import zigpy.types as t
+
+from zhaquirks.tuya import TuyaPowerConfigurationCluster2AA
+from zhaquirks.tuya.builder import TuyaQuirkBuilder
+
+
+class TuyaSelfTestResult(t.enum8):
+    """Tuya self test result enum."""
+
+    Checking = 0x00
+    Success = 0x01
+    Failure = 0x02
+    Others = 0x03
+
+
+(
+    TuyaQuirkBuilder("_TZE200_ggev5fsl", "TS0601")
+    .applies_to("_TZE200_hr0tdd47", "TS0601")
+    .applies_to("_TZE200_rjxqso4a", "TS0601")
+    .applies_to("_TZE284_rjxqso4a", "TS0601")
+    .tuya_gas(dp_id=1)
+    .tuya_sensor(
+        dp_id=2,
+        attribute_name="co",
+        type=t.int16s,
+        device_class=SensorDeviceClass.CO,
+        state_class=SensorStateClass.MEASUREMENT,
+        unit=CONCENTRATION_PARTS_PER_MILLION,
+        fallback_name="CO concentration",
+    )
+    .tuya_enum(
+        dp_id=9,
+        attribute_name="self_test_result",
+        enum_class=TuyaSelfTestResult,
+        entity_type=EntityType.DIAGNOSTIC,
+        entity_platform=EntityPlatform.SENSOR,
+        translation_key="self_test_result",
+        fallback_name="Self test result",
+    )
+    .tuya_battery(dp_id=15, power_cfg=TuyaPowerConfigurationCluster2AA)
+    .tuya_switch(
+        dp_id=16,
+        attribute_name="mute_siren",
+        entity_type=EntityType.STANDARD,
+        translation_key="mute_siren",
+        fallback_name="Mute siren",
+    )
+    .skip_configuration()
+    .add_to_registry()
+)


Test patch:
diff --git a/tests/test_tuya_builder.py b/tests/test_tuya_builder.py
index 99031bf..f10eed0 100644
--- a/tests/test_tuya_builder.py
+++ b/tests/test_tuya_builder.py
@@ -18,11 +18,12 @@ from zhaquirks.tuya import (
 )
 from zhaquirks.tuya.builder import (
     TuyaAirQualityVOC,
-    TuyaCO2Concetration,
-    TuyaFormaldehydeConcetration,
+    TuyaCO2Concentration,
+    TuyaFormaldehydeConcentration,
     TuyaIasContact,
     TuyaIasFire,
-    TuyaPM25Concetration,
+    TuyaIasGas,
+    TuyaPM25Concentration,
     TuyaQuirkBuilder,
     TuyaRelativeHumidity,
     TuyaSoilMoisture,
@@ -45,14 +46,15 @@ zhaquirks.setup()
         ("tuya_humidity", "humidity", TuyaRelativeHumidity),
         ("tuya_smoke", "ias_zone", TuyaIasFire),
         ("tuya_contact", "ias_zone", TuyaIasContact),
-        ("tuya_co2", "carbon_dioxide_concentration", TuyaCO2Concetration),
-        ("tuya_pm25", "pm25", TuyaPM25Concetration),
+        ("tuya_co2", "carbon_dioxide_concentration", TuyaCO2Concentration),
+        ("tuya_pm25", "pm25", TuyaPM25Concentration),
         ("tuya_voc", "voc_level", TuyaAirQualityVOC),
         (
             "tuya_formaldehyde",
             "formaldehyde_concentration",
-            TuyaFormaldehydeConcetration,
+            TuyaFormaldehydeConcentration,
         ),
+        ("tuya_gas", "ias_zone", TuyaIasGas),
     ],
 )
 async def test_convenience_methods(device_mock, method_name, attr_name, exp_class):