Repository: zigpy/zha-device-handlers
Pull request #3982 (2025-03-26 04:05:59)

Ignore invalid high values for some Tuya PM2.5 sensors
## Proposed change
`_TZE200_dwcarsat and `_TZE204_dwcarsat` can apparently send values over 1000. These should be ignored.
The PR does that.

## Additional information
Should fix comment: https://github.com/zigpy/zha-device-handlers/issues/1563#issuecomment-2681239880


## Checklist

- [ ] 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/3982?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 `50.00000%` with `3 lines` in your changes missing coverage. Please review.
> Project coverage is 91.10%. Comparing base [(`8ca9bdc`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/8ca9bdc74ec5c7b05993997387c41a4416197a19?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) to head [(`f95ed61`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/f95ed610a901e3420493de13ee642021a1069b8f?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/3982?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/tuya\_co.py](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3982?src=pr&el=tree&filepath=zhaquirks%2Ftuya%2Ftuya_co.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy#diff-emhhcXVpcmtzL3R1eWEvdHV5YV9jby5weQ==) | 50.00% | [3 Missing :warning: ](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3982?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    #3982      +/-   ##
==========================================
- Coverage   91.13%   91.10%   -0.03%     
==========================================
  Files         334      334              
  Lines       10790    10796       +6     
==========================================
+ Hits         9833     9836       +3     
- Misses        957      960       +3     
```

</details>

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

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>

Patch:
diff --git a/zhaquirks/tuya/tuya_co.py b/zhaquirks/tuya/tuya_co.py
index 4462f7e..d33056e 100644
--- a/zhaquirks/tuya/tuya_co.py
+++ b/zhaquirks/tuya/tuya_co.py
@@ -1,10 +1,13 @@
 """Tuya Air Quality sensor."""
 
+from typing import Any
+
 import zigpy.types as t
 
 from zhaquirks.tuya.builder import (
     MOL_VOL_AIR_NTP,
     TuyaFormaldehydeConcentration,
+    TuyaPM25Concentration,
     TuyaQuirkBuilder,
     TuyaTemperatureMeasurement,
 )
@@ -22,6 +25,16 @@ class CustomTemperature(t.Struct):
         return cls.deserialize(value.serialize())[0]
 
 
+class TuyaPM25ConcentrationIgnoreValues(TuyaPM25Concentration):
+    """Tuya PM25 concentration measurement cluster that ignores invalid high values."""
+
+    def _update_attribute(self, attrid: int | t.uint16_t, value: Any) -> None:
+        """Update an attribute on this cluster and ignore values over 1000."""
+        if attrid == self.AttributeDefs.measured_value.id and value > 1000:
+            return
+        super()._update_attribute(attrid, value)
+
+
 base_air_quality = (
     TuyaQuirkBuilder()
     .tuya_dp(
@@ -41,7 +54,7 @@ base_air_quality = (
     # 18 and 19 from base
     .applies_to("_TZE200_dwcarsat", "TS0601")
     .applies_to("_TZE204_dwcarsat", "TS0601")
-    .tuya_pm25(dp_id=2)
+    .tuya_pm25(dp_id=2, pm25_cfg=TuyaPM25ConcentrationIgnoreValues)
     .tuya_formaldehyde(
         dp_id=20,
         converter=lambda x: round(


Test patch:
diff --git a/tests/test_tuya_air.py b/tests/test_tuya_air.py
index 52dd760..5cfd337 100644
--- a/tests/test_tuya_air.py
+++ b/tests/test_tuya_air.py
@@ -6,7 +6,9 @@ from unittest.mock import MagicMock
 import pytest
 import zigpy.profiles.zha
 import zigpy.types as t
+from zigpy.zcl.clusters.measurement import PM25
 
+from tests.common import ClusterListener
 import zhaquirks
 from zhaquirks.tuya import TuyaNewManufCluster
 
@@ -79,6 +81,7 @@ def test_co2_sensor(air_quality_device, data, ep_attr, expected_value):
     assert cluster.get("measured_value") == expected_value
 
 
+# XXX: variant 00 is not used, PM2.5 is untested for sensors
 TUYA_AIR_TEST_VAR00 = (
     (
         b"\t\x02\x01\x00\x00\x12\x02\x00\x04\x00\x00\x01 ",
@@ -310,3 +313,22 @@ def test_smart_air_sensor(zigpy_device_from_v2_quirk, model, manuf, test_plan):
             )
             cluster = getattr(dev.endpoints[1], ep_attr)
             assert cluster.get("measured_value") == expected_value
+
+
+async def test_smart_air_pm25_dropping_high_values(zigpy_device_from_v2_quirk):
+    """Test Tuya Smart Air Sensor _TZE200_dwcarsat dropping high PM2.5 values."""
+
+    dev = zigpy_device_from_v2_quirk("_TZE200_dwcarsat", "TS0601")
+    pm25_cluster = dev.endpoints[1].pm25
+    pm25_listener = ClusterListener(pm25_cluster)
+
+    # check that valid value updates the attribute
+    # We call the Tuya TuyaLocalCluster update_attribute method which accepts a string,
+    # it'll call the underlying _update_attribute method with the id then.
+    pm25_cluster.update_attribute(PM25.AttributeDefs.measured_value.name, 1000)
+    assert len(pm25_listener.attribute_updates) == 1
+    assert pm25_listener.attribute_updates[0][1] == 1000
+
+    # check that invalid value is ignored
+    pm25_cluster.update_attribute(PM25.AttributeDefs.measured_value.name, 1001)
+    assert len(pm25_listener.attribute_updates) == 1