Repository: zigpy/zha-device-handlers
Pull request #3608 (2024-12-12 23:22:57)

Split Tuya IAS function into contact & smoke
## Proposed change
<!--
  Explain your proposed change below.
-->
Originally, we had tuya_ias with the intention of having one method to handle any IAS requirements. Looking at https://github.com/zigpy/zha-device-handlers/pull/3605, I think having separate convenience methods does make sense. Unfortunately, adding `tuya_contact` for contact sensors while retaining `tuya_ias` for Fire/smoke sensors would be confusing. This PR add `tuya_contact` and `tuya_smoke` and then modifies `tuya_ias` to accept a converter.

## 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
- [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/3608?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) Report
Attention: Patch coverage is `81.81818%` with `2 lines` in your changes missing coverage. Please review.
> Project coverage is 89.83%. Comparing base [(`5f1a7f2`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/5f1a7f27284b2e33f7b82b7bb178a3cf59f90409?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) to head [(`50651d1`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/50651d1f99ef8f852b5774e3e069ccc5113c508f?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).

| [Files with missing lines](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3608?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) | Patch % | Lines |
|---|---|---|
| [zhaquirks/tuya/builder/\_\_init\_\_.py](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3608?src=pr&el=tree&filepath=zhaquirks%2Ftuya%2Fbuilder%2F__init__.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy#diff-emhhcXVpcmtzL3R1eWEvYnVpbGRlci9fX2luaXRfXy5weQ==) | 81.81% | [2 Missing :warning: ](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3608?src=pr&el=tree&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    #3608      +/-   ##
==========================================
- Coverage   89.85%   89.83%   -0.02%     
==========================================
  Files         321      321              
  Lines       10366    10374       +8     
==========================================
+ Hits         9314     9320       +6     
- Misses       1052     1054       +2     
```

</details>

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


Patch:
diff --git a/zhaquirks/tuya/builder/__init__.py b/zhaquirks/tuya/builder/__init__.py
index 148c5b2..1078d80 100644
--- a/zhaquirks/tuya/builder/__init__.py
+++ b/zhaquirks/tuya/builder/__init__.py
@@ -30,8 +30,16 @@ from zhaquirks.tuya import (
 from zhaquirks.tuya.mcu import DPToAttributeMapping, TuyaMCUCluster, TuyaOnOffNM
 
 
-class TuyaIASFire(IasZone, TuyaLocalCluster):
-    """Tuya local IAS cluster."""
+class TuyaIasContact(IasZone, TuyaLocalCluster):
+    """Tuya local IAS contact cluster."""
+
+    _CONSTANT_ATTRIBUTES = {
+        IasZone.AttributeDefs.zone_type.id: IasZone.ZoneType.Contact_Switch
+    }
+
+
+class TuyaIasFire(IasZone, TuyaLocalCluster):
+    """Tuya local IAS smoke/fire cluster."""
 
     _CONSTANT_ATTRIBUTES = {
         IasZone.AttributeDefs.zone_type.id: IasZone.ZoneType.Fire_Sensor
@@ -99,35 +107,54 @@ class TuyaQuirkBuilder(QuirkBuilder):
         self.adds(power_cfg)
         return self
 
-    def tuya_metering(
-        self,
-        dp_id: int,
-        metering_cfg: TuyaLocalCluster = TuyaValveWaterConsumed,
-    ) -> QuirkBuilder:
-        """Add a Tuya Metering Configuration."""
-        self.tuya_dp(
-            dp_id,
-            metering_cfg.ep_attribute,
-            "current_summ_delivered",
+    def tuya_contact(self, dp_id: int):
+        """Add a Tuya IAS contact sensor."""
+        self.tuya_ias(
+            dp_id=dp_id,
+            ias_cfg=TuyaIasContact,
+            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(
+            dp_id=dp_id,
+            ias_cfg=TuyaIasFire,
+            converter=lambda x: IasZone.ZoneStatus.Alarm_1 if x == 0 else 0,
         )
-        self.adds(metering_cfg)
         return self
 
     def tuya_ias(
         self,
         dp_id: int,
-        ias_cfg: TuyaLocalCluster = TuyaIASFire,
+        ias_cfg: TuyaLocalCluster,
+        converter: Optional[Callable[[Any], Any]] = None,
     ) -> QuirkBuilder:
         """Add a Tuya IAS Configuration."""
         self.tuya_dp(
             dp_id,
             ias_cfg.ep_attribute,
             IasZone.AttributeDefs.zone_status.name,
-            converter=lambda x: IasZone.ZoneStatus.Alarm_1 if x == 0 else 0,
+            converter=converter,
         )
         self.adds(ias_cfg)
         return self
 
+    def tuya_metering(
+        self,
+        dp_id: int,
+        metering_cfg: TuyaLocalCluster = TuyaValveWaterConsumed,
+    ) -> QuirkBuilder:
+        """Add a Tuya Metering Configuration."""
+        self.tuya_dp(
+            dp_id,
+            metering_cfg.ep_attribute,
+            "current_summ_delivered",
+        )
+        self.adds(metering_cfg)
+        return self
+
     def tuya_onoff(
         self,
         dp_id: int,
diff --git a/zhaquirks/tuya/tuya_smoke.py b/zhaquirks/tuya/tuya_smoke.py
index cdd9ec8..fc01bc1 100644
--- a/zhaquirks/tuya/tuya_smoke.py
+++ b/zhaquirks/tuya/tuya_smoke.py
@@ -12,14 +12,14 @@ from zhaquirks.tuya.builder import TuyaPowerConfigurationCluster2AAA, TuyaQuirkB
     .applies_to("_TZE204_vawy74yh", "TS0601")
     .applies_to("_TZE284_0zaf1cr8", "TS0601")
     .applies_to("_TZ3210_up3pngle", "TS0205")
-    .tuya_ias(dp_id=1)
+    .tuya_smoke(dp_id=1)
     .skip_configuration()
     .add_to_registry()
 )
 
 (
     TuyaQuirkBuilder("_TZE204_ntcy3xu1", "TS0601")
-    .tuya_ias(dp_id=1)
+    .tuya_smoke(dp_id=1)
     .tuya_dp(
         dp_id=14,
         ep_attribute=TuyaPowerConfigurationCluster2AAA.ep_attribute,


Test patch:
diff --git a/tests/test_tuya_builder.py b/tests/test_tuya_builder.py
index 219ed20..a188348 100644
--- a/tests/test_tuya_builder.py
+++ b/tests/test_tuya_builder.py
@@ -13,6 +13,8 @@ from zigpy.zcl.clusters.general import Basic
 from tests.common import ClusterListener, wait_for_zigpy_tasks
 import zhaquirks
 from zhaquirks.tuya.builder import (
+    TuyaIasContact,
+    TuyaIasFire,
     TuyaPowerConfigurationCluster2AAA,
     TuyaQuirkBuilder,
     TuyaRelativeHumidity,
@@ -46,6 +48,41 @@ def real_device(MockAppController):
     return device
 
 
+@pytest.mark.parametrize(
+    "method_name,attr_name,exp_class",
+    [
+        ("tuya_battery", "power", TuyaPowerConfigurationCluster2AAA),
+        ("tuya_metering", "smartenergy_metering", TuyaValveWaterConsumed),
+        ("tuya_onoff", "on_off", TuyaOnOffNM),
+        ("tuya_soil_moisture", "soil_moisture", TuyaSoilMoisture),
+        ("tuya_temperature", "temperature", TuyaTemperatureMeasurement),
+        ("tuya_humidity", "humidity", TuyaRelativeHumidity),
+        ("tuya_smoke", "ias_zone", TuyaIasFire),
+        ("tuya_contact", "ias_zone", TuyaIasContact),
+    ],
+)
+async def test_convenience_methods(device_mock, method_name, attr_name, exp_class):
+    """Test TuyaQuirkBuilder convenience methods."""
+
+    registry = DeviceRegistry()
+
+    entry = TuyaQuirkBuilder(
+        device_mock.manufacturer, device_mock.model, registry=registry
+    )
+    entry = getattr(entry, method_name)(dp_id=1)
+    entry.skip_configuration().add_to_registry()
+
+    quirked = registry.get_device(device_mock)
+    assert isinstance(quirked, CustomDeviceV2)
+    assert quirked in registry
+
+    ep = quirked.endpoints[1]
+
+    ep_attr = getattr(ep, attr_name)
+    assert ep_attr is not None
+    assert isinstance(ep_attr, exp_class)
+
+
 async def test_tuya_quirkbuilder(device_mock):
     """Test adding a v2 Tuya Quirk to the registry and getting back a quirked device."""
 
@@ -60,10 +97,7 @@ async def test_tuya_quirkbuilder(device_mock):
     entry = (
         TuyaQuirkBuilder(device_mock.manufacturer, device_mock.model, registry=registry)
         .tuya_battery(dp_id=1)
-        .tuya_metering(dp_id=2)
         .tuya_onoff(dp_id=3)
-        .tuya_soil_moisture(dp_id=4)
-        .tuya_temperature(dp_id=5)
         .tuya_switch(
             dp_id=6,
             attribute_name="test_onoff",
@@ -97,7 +131,7 @@ async def test_tuya_quirkbuilder(device_mock):
             translation_key="test_enum",
             fallback_name="Test enum",
         )
-        .tuya_humidity(dp_id=11)
+        .skip_configuration()
         .add_to_registry()
     )
 
@@ -126,24 +160,6 @@ async def test_tuya_quirkbuilder(device_mock):
     assert tuya_cluster.attributes_by_name["test_sensor"].id == 0xEF09
     assert tuya_cluster.attributes_by_name["test_enum"].id == 0xEF0A
 
-    assert ep.power is not None
-    assert isinstance(ep.power, TuyaPowerConfigurationCluster2AAA)
-
-    assert ep.smartenergy_metering is not None
-    assert isinstance(ep.smartenergy_metering, TuyaValveWaterConsumed)
-
-    assert ep.on_off is not None
-    assert isinstance(ep.on_off, TuyaOnOffNM)
-
-    assert ep.soil_moisture is not None
-    assert isinstance(ep.soil_moisture, TuyaSoilMoisture)
-
-    assert ep.temperature is not None
-    assert isinstance(ep.temperature, TuyaTemperatureMeasurement)
-
-    assert ep.humidity is not None
-    assert isinstance(ep.humidity, TuyaRelativeHumidity)
-
     with mock.patch.object(
         tuya_cluster.endpoint, "request", return_value=foundation.Status.SUCCESS
     ) as m1: