Repository: zigpy/zha-device-handlers
Pull request #3541 (2024-11-24 23:04:16)

Forward Philips Hue contact sensor changes to OnOff cluster
## Proposed change
This forwards all attribute updates from the custom Hue `contact` attribute to the `OnOff` cluster.
The advantage is that if commands from/to the `OnOff` cluster are missed, we'll get an attribute report in (max) 15 minutes from the custom Hue `contact` attribute.

We do this to avoid creating another entity and causing a breaking change if we removed the `OnOff` for this device.
The custom cluster is also only bound and sets up attribute reporting when running Home Assistant Core 2024.11.0 or later. Some devices may have been set up before. These devices will still work with this PR, as the `OnOff` changes are also still received.
Using this approach, we also stay flexible if we do further changes to the `OnOff` entities in the future (e.g. only creating on a whitelist for supported devices, so we don't create them for remotes).

cc @mguaylam
Please test the changes from this PR again, as they're slightly different. Just install `soc001.py` as a custom quirk again.

## Additional information
Closes https://github.com/zigpy/zha-device-handlers/pull/3464
Fixes https://github.com/zigpy/zha-device-handlers/issues/3314 (no entity for for last contact/tamper change though)


## Checklist

- [ ] 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/3541?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.62%. Comparing base [(`f239f30`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/f239f302ed2858fac3d11e230fc2006de8fff195?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) to head [(`5db2de4`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/5db2de45ccba9a2eb430c4848ea0c9dc216d8972?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    #3541   +/-   ##
=======================================
  Coverage   89.61%   89.62%           
=======================================
  Files         313      313           
  Lines       10218    10224    +6     
=======================================
+ Hits         9157     9163    +6     
  Misses       1061     1061           
```

</details>

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

----
🚨 Try these New Features:

- [Flaky Tests Detection](https://docs.codecov.com/docs/test-result-ingestion-beta) - Detect and resolve failed and flaky tests

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 6dbd217..5bf4b76 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -17,7 +17,7 @@ repos:
       - id: mypy
   
   - repo: https://github.com/astral-sh/ruff-pre-commit
-    rev: v0.7.4
+    rev: v0.8.0
     hooks:
       - id: ruff
         args: ["--fix", "--exit-non-zero-on-fix", "--config", "pyproject.toml"]
diff --git a/zhaquirks/philips/soc001.py b/zhaquirks/philips/soc001.py
index d51b65b..3a7bd84 100644
--- a/zhaquirks/philips/soc001.py
+++ b/zhaquirks/philips/soc001.py
@@ -3,6 +3,7 @@
 from zigpy import types
 from zigpy.quirks import CustomCluster
 from zigpy.quirks.v2 import BinarySensorDeviceClass, EntityType, QuirkBuilder
+from zigpy.zcl.clusters.general import OnOff
 from zigpy.zcl.foundation import BaseAttributeDefs, ZCLAttributeDef
 
 
@@ -37,6 +38,22 @@ class PhilipsContactCluster(CustomCluster):
             is_manufacturer_specific=True,
         )
 
+    # catch when contact attribute is updated and forward to OnOff cluster
+    def _update_attribute(self, attrid, value):
+        super()._update_attribute(attrid, value)
+
+        on_off_cluster = self.endpoint.out_clusters[OnOff.cluster_id]
+        if (
+            attrid == self.AttributeDefs.contact.id
+            and on_off_cluster.get(OnOff.AttributeDefs.on_off.id) != value
+        ):
+            # This seems to happen after the real OnOff attribute change,
+            # so we can avoid a duplicate event by checking the current value.
+            # We'll only update the OnOff cluster if the value is different then,
+            # this is likely only the case when an update was missed, and we later
+            # get an attribute report for the custom contact attribute.
+            on_off_cluster.update_attribute(OnOff.AttributeDefs.on_off.id, value)
+
 
 (
     #  <SimpleDescriptor endpoint=2 profile=260 device_type=1026
diff --git a/zhaquirks/philips/wall_switch.py b/zhaquirks/philips/wall_switch.py
index d865456..576d61a 100644
--- a/zhaquirks/philips/wall_switch.py
+++ b/zhaquirks/philips/wall_switch.py
@@ -40,8 +40,6 @@ from zhaquirks.philips import (
     PressType,
 )
 
-DEVICE_SPECIFIC_UNKNOWN = 64512
-
 
 class PhilipsWallSwitchBasicCluster(PhilipsBasicCluster):
     """Philips wall switch Basic cluster."""
@@ -99,7 +97,7 @@ class PhilipsWallSwitch(CustomDevice):
                     Basic.cluster_id,
                     PowerConfiguration.cluster_id,
                     Identify.cluster_id,
-                    DEVICE_SPECIFIC_UNKNOWN,
+                    PhilipsWallSwitchRemoteCluster.cluster_id,
                 ],
                 OUTPUT_CLUSTERS: [
                     Identify.cluster_id,
@@ -137,3 +135,58 @@ class PhilipsWallSwitch(CustomDevice):
     device_automation_triggers = (
         PhilipsWallSwitchRemoteCluster.generate_device_automation_triggers()
     )
+
+
+class PhilipsWallSwitchRDM004(CustomDevice):
+    """Philips RDM004 variant."""
+
+    signature = {
+        MODELS_INFO: [(SIGNIFY, "RDM004")],
+        ENDPOINTS: {
+            1: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.NON_COLOR_SCENE_CONTROLLER,
+                INPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    PowerConfiguration.cluster_id,
+                    Identify.cluster_id,
+                    PhilipsWallSwitchRemoteCluster.cluster_id,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    OnOff.cluster_id,
+                    LevelControl.cluster_id,
+                    Ota.cluster_id,
+                ],
+            },
+        },
+    }
+
+    replacement = {
+        ENDPOINTS: {
+            1: {
+                PROFILE_ID: zha.PROFILE_ID,
+                DEVICE_TYPE: zha.DeviceType.NON_COLOR_SCENE_CONTROLLER,
+                INPUT_CLUSTERS: [
+                    PhilipsWallSwitchBasicCluster,
+                    PowerConfiguration.cluster_id,
+                    Identify.cluster_id,
+                    PhilipsWallSwitchRemoteCluster,
+                ],
+                OUTPUT_CLUSTERS: [
+                    Basic.cluster_id,
+                    Identify.cluster_id,
+                    Groups.cluster_id,
+                    OnOff.cluster_id,
+                    LevelControl.cluster_id,
+                    Ota.cluster_id,
+                ],
+            },
+        },
+    }
+
+    device_automation_triggers = (
+        PhilipsWallSwitchRemoteCluster.generate_device_automation_triggers()
+    )
diff --git a/zhaquirks/schneiderelectric/__init__.py b/zhaquirks/schneiderelectric/__init__.py
index 7efb815..9f8246d 100644
--- a/zhaquirks/schneiderelectric/__init__.py
+++ b/zhaquirks/schneiderelectric/__init__.py
@@ -167,7 +167,7 @@ class SEBallast(CustomCluster, Ballast):
             id=0xE000, type=SEControlMode, is_manufacturer_specific=True
         )
         se_wiring_mode: Final = ZCLAttributeDef(
-            id=0xE002, type=SEWiringMode, is_manufacturer_specific=True
+            id=0xE001, type=SEWiringMode, is_manufacturer_specific=True
         )
         se_dimming_curve: Final = ZCLAttributeDef(
             id=0xE002, type=SEDimmingCurve, is_manufacturer_specific=True
diff --git a/zhaquirks/sinope/light.py b/zhaquirks/sinope/light.py
index 328315f..a738bd2 100644
--- a/zhaquirks/sinope/light.py
+++ b/zhaquirks/sinope/light.py
@@ -301,7 +301,7 @@ class SinopeTechnologieslight(CustomDevice):
     device_automation_triggers = LIGHT_DEVICE_TRIGGERS
 
 
-class SinopeDM2500ZB(SinopeTechnologieslight):
+class SinopeDM2500ZB(CustomDevice):
     """DM2500ZB, DM2500ZB-G2 Dimmers."""
 
     signature = {
@@ -366,7 +366,7 @@ class SinopeDM2500ZB(SinopeTechnologieslight):
     device_automation_triggers = LIGHT_DEVICE_TRIGGERS
 
 
-class SinopeDM2550ZB(SinopeTechnologieslight):
+class SinopeDM2550ZB(CustomDevice):
     """DM2550ZB, DM2550ZB-G2 Dimmers."""
 
     signature = {
diff --git a/zhaquirks/sinope/thermostat.py b/zhaquirks/sinope/thermostat.py
index 5ba0384..3a0d351 100644
--- a/zhaquirks/sinope/thermostat.py
+++ b/zhaquirks/sinope/thermostat.py
@@ -402,7 +402,7 @@ class SinopeTechnologiesThermostat(CustomDevice):
     }
 
 
-class SinopeTH1400ZB(SinopeTechnologiesThermostat):
+class SinopeTH1400ZB(CustomDevice):
     """TH1400ZB thermostat."""
 
     signature = {
@@ -460,7 +460,7 @@ class SinopeTH1400ZB(SinopeTechnologiesThermostat):
     }
 
 
-class SinopeTH1300ZB(SinopeTechnologiesThermostat):
+class SinopeTH1300ZB(CustomDevice):
     """TH1300ZB thermostat."""
 
     signature = {
@@ -520,7 +520,7 @@ class SinopeTH1300ZB(SinopeTechnologiesThermostat):
     }
 
 
-class SinopeLineThermostats(SinopeTechnologiesThermostat):
+class SinopeLineThermostats(CustomDevice):
     """TH1123ZB, TH1124ZB, TH1500ZB and OTH3600-GA-ZB thermostats."""
 
     signature = {
@@ -585,7 +585,7 @@ class SinopeLineThermostats(SinopeTechnologiesThermostat):
     }
 
 
-class SinopeG2Thermostats(SinopeTechnologiesThermostat):
+class SinopeG2Thermostats(CustomDevice):
     """TH1123ZB-G2 and TH1124ZB-G2 thermostats."""
 
     signature = {
@@ -648,8 +648,8 @@ class SinopeG2Thermostats(SinopeTechnologiesThermostat):
     }
 
 
-class SinopeHPThermostats(SinopeTechnologiesThermostat):
-    """HP6000ZB-MA and HP6000ZB-GE thermostats."""
+class SinopeHPThermostats(CustomDevice):
+    """HP6000ZB-GE, HP6000ZB-HS and HP6000ZB-MA thermostats."""
 
     signature = {
         # <SimpleDescriptor endpoint=1 profile=260 device_type=775 device_version=1
diff --git a/zhaquirks/sonoff/trvzb.py b/zhaquirks/sonoff/trvzb.py
new file mode 100644
index 0000000..1c01f9a
--- /dev/null
+++ b/zhaquirks/sonoff/trvzb.py
@@ -0,0 +1,125 @@
+"""Sonoff TRVZB - Zigbee Thermostatic Radiator Valve."""
+
+from zigpy.quirks import CustomCluster
+from zigpy.quirks.v2 import QuirkBuilder
+from zigpy.quirks.v2.homeassistant import UnitOfTemperature
+import zigpy.types as t
+from zigpy.zcl.foundation import BaseAttributeDefs, ZCLAttributeDef
+
+
+class CustomSonoffCluster(CustomCluster):
+    """Custom Sonoff cluster."""
+
+    cluster_id = 0xFC11
+
+    class AttributeDefs(BaseAttributeDefs):
+        """Attribute definitions."""
+
+        child_lock = ZCLAttributeDef(
+            id=0x0000,
+            type=t.Bool,
+        )
+
+        open_window = ZCLAttributeDef(
+            id=0x6000,
+            type=t.Bool,
+        )
+
+        frost_protection_temperature = ZCLAttributeDef(
+            id=0x6002,
+            type=t.int16s,
+        )
+
+        idle_steps = ZCLAttributeDef(
+            id=0x6003,
+            type=t.uint16_t,
+            access="r",
+        )
+
+        closing_steps = ZCLAttributeDef(
+            id=0x6004,
+            type=t.uint16_t,
+            access="r",
+        )
+
+        valve_opening_limit_voltage = ZCLAttributeDef(
+            id=0x6005,
+            type=t.uint16_t,
+            access="r",
+        )
+
+        valve_closing_limit_voltage = ZCLAttributeDef(
+            id=0x6006,
+            type=t.uint16_t,
+            access="r",
+        )
+
+        valve_motor_running_voltage = ZCLAttributeDef(
+            id=0x6007,
+            type=t.uint16_t,
+            access="r",
+        )
+
+        valve_opening_degree = ZCLAttributeDef(
+            id=0x600B,
+            type=t.uint8_t,
+        )
+
+        valve_closing_degree = ZCLAttributeDef(
+            id=0x600C,
+            type=t.uint8_t,
+        )
+
+    @property
+    def _is_manuf_specific(self):
+        return False
+
+
+(
+    QuirkBuilder("SONOFF", "TRVZB")
+    .replaces(CustomSonoffCluster)
+    .switch(
+        CustomSonoffCluster.AttributeDefs.child_lock.name,
+        CustomSonoffCluster.cluster_id,
+        translation_key="child_lock",
+        fallback_name="Child lock",
+    )
+    .switch(
+        CustomSonoffCluster.AttributeDefs.open_window.name,
+        CustomSonoffCluster.cluster_id,
+        translation_key="open_window",
+        fallback_name="Open window",
+    )
+    .number(
+        CustomSonoffCluster.AttributeDefs.frost_protection_temperature.name,
+        CustomSonoffCluster.cluster_id,
+        min_value=4.0,
+        max_value=35.0,
+        step=0.5,
+        unit=UnitOfTemperature.CELSIUS,
+        multiplier=0.01,
+        translation_key="frost_protection_temperature",
+        fallback_name="Frost protection temperature",
+    )
+    .number(
+        CustomSonoffCluster.AttributeDefs.valve_opening_degree.name,
+        CustomSonoffCluster.cluster_id,
+        min_value=0.0,
+        max_value=100.0,
+        step=1.0,
+        translation_key="valve_opening_degree",
+        fallback_name="Valve opening degree",
+        initially_disabled=True,
+    )
+    .number(
+        CustomSonoffCluster.AttributeDefs.valve_closing_degree.name,
+        CustomSonoffCluster.cluster_id,
+        min_value=0.0,
+        max_value=100.0,
+        step=1.0,
+        translation_key="valve_closing_degree",
+        fallback_name="Valve closing degree",
+        initially_disabled=True,
+    )
+    .add_to_registry()
+)
diff --git a/zhaquirks/tuya/mcu/__init__.py b/zhaquirks/tuya/mcu/__init__.py
index e9d73d3..26a11fa 100644
--- a/zhaquirks/tuya/mcu/__init__.py
+++ b/zhaquirks/tuya/mcu/__init__.py
@@ -65,7 +65,7 @@ class TuyaClusterData(t.Struct):
     endpoint_id: int
     cluster_name: str
     cluster_attr: str
-    attr_value: int | str  # Maybe also others types?
+    attr_value: int  # Maybe also others types?
     expect_reply: bool
     manufacturer: int
 


Test patch:
diff --git a/tests/test_philips.py b/tests/test_philips.py
index 3fdebf0..fa5cb72 100644
--- a/tests/test_philips.py
+++ b/tests/test_philips.py
@@ -3,8 +3,10 @@
 from unittest import mock
 
 import pytest
+from zigpy.zcl.clusters.general import OnOff
 from zigpy.zcl.foundation import ZCLHeader
 
+from tests.common import ClusterListener
 import zhaquirks
 from zhaquirks.const import (
     BUTTON_1,
@@ -738,3 +740,31 @@ def test_rdm002_triggers():
     actual_triggers = PhilipsRDM002.device_automation_triggers
 
     assert actual_triggers == expected_triggers
+
+
+def test_contact_sensor(zigpy_device_from_v2_quirk):
+    """Test that the Hue contact attribute is forwarded to the OnOff cluster."""
+    quirk = zigpy_device_from_v2_quirk(
+        "Signify Netherlands B.V.", "SOC001", endpoint_ids=[1, 2]
+    )
+    # Add output OnOff cluster to the endpoint. The device has this,
+    # but the quirk doesn't modify that cluster, so we need to add it manually.
+    quirk.endpoints[2].add_output_cluster(OnOff.cluster_id)
+
+    hue_cluster = quirk.endpoints[2].philips_contact_cluster
+    on_off_cluster = quirk.endpoints[2].out_clusters[OnOff.cluster_id]
+    on_off_listener = ClusterListener(on_off_cluster)
+
+    # update the contact attribute and check that it is forwarded to the OnOff cluster
+    hue_cluster.update_attribute(hue_cluster.AttributeDefs.contact.id, 0)
+    assert on_off_listener.attribute_updates[0] == (0, 0)
+
+    hue_cluster.update_attribute(hue_cluster.AttributeDefs.contact.id, 1)
+    assert on_off_listener.attribute_updates[1] == (0, 1)
+
+    # check we didn't exceed the number of expected updates
+    assert len(on_off_listener.attribute_updates) == 2
+
+    # update again with the same value and except no new update
+    hue_cluster.update_attribute(hue_cluster.AttributeDefs.contact.id, 1)
+    assert len(on_off_listener.attribute_updates) == 2