Repository: zigpy/zha-device-handlers
Pull request #3346 (2024-09-03 21:10:01)

Add initial Sonoff smart water valve quirk
## Proposed change

Add quirk for Sonoff SWV.

## Additional information

This exposes the `water_valve_state` attribute in the custom cluster and ~~adds a sensor to HA with the same information.~~
EDIT: The enum sensor will not be added with this initial PR. We'd like two get two different binary sensors to parse the same attribute. Since quirks v2 can't do this yet, we'll need to do this work in a future PR.
This initial PR will just expose the attribute.


## 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/3346?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 `94.11765%` with `1 line` in your changes missing coverage. Please review.
> Project coverage is 88.50%. Comparing base [(`5c4e08d`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/5c4e08d94091a4c5f37cea7b21fd918474e98f82?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) to head [(`3c7e8e3`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/3c7e8e357e06ba86900ea5688593e179ae2a506d?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy).
> Report is 2 commits behind head on dev.

| [Files with missing lines](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3346?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/sonoff/swv.py](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3346?src=pr&el=tree&filepath=zhaquirks%2Fsonoff%2Fswv.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy#diff-emhhcXVpcmtzL3Nvbm9mZi9zd3YucHk=) | 94.11% | [1 Missing :warning: ](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3346?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    #3346      +/-   ##
==========================================
+ Coverage   88.48%   88.50%   +0.01%     
==========================================
  Files         305      306       +1     
  Lines        9623     9638      +15     
==========================================
+ Hits         8515     8530      +15     
  Misses       1108     1108              
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3346?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/hzc/doubledimmerswitch.py b/zhaquirks/hzc/doubledimmerswitch.py
new file mode 100644
index 0000000..95fb2c3
--- /dev/null
+++ b/zhaquirks/hzc/doubledimmerswitch.py
@@ -0,0 +1,20 @@
+"""Quirk for EcoDim 05 two gang dimmer (e.g. HZC Smart Double Dimmer D686-ZG)."""
+
+from zigpy.quirks import CustomCluster
+from zigpy.quirks.v2 import QuirkBuilder
+from zigpy.zcl.clusters.general import OnOff
+
+from zhaquirks import NoReplyMixin
+
+
+class HzcOnOff(NoReplyMixin, CustomCluster, OnOff):
+    """HZC On Off Cluster."""
+
+    void_input_commands = {cmd.id for cmd in OnOff.commands_by_name.values()}
+
+
+(
+    QuirkBuilder("EcoDim BV", "EcoDim-Zigbee 3.0")
+    .replace_cluster_occurrences(HzcOnOff, replace_client_instances=False)
+    .add_to_registry()
+)
diff --git a/zhaquirks/sonoff/swv.py b/zhaquirks/sonoff/swv.py
new file mode 100644
index 0000000..3389b8b
--- /dev/null
+++ b/zhaquirks/sonoff/swv.py
@@ -0,0 +1,40 @@
+"""Sonoff SWV - Zigbee smart water valve."""
+
+from zigpy.quirks import CustomCluster
+from zigpy.quirks.v2 import QuirkBuilder
+import zigpy.types as t
+from zigpy.zcl.foundation import BaseAttributeDefs, ZCLAttributeDef
+
+
+class ValveState(t.enum8):
+    """Water valve state."""
+
+    Normal = 0
+    Water_Shortage = 1
+    Water_Leakage = 2
+    Water_Shortage_And_Leakage = 3
+
+
+class EwelinkCluster(CustomCluster):
+    """Ewelink specific cluster."""
+
+    cluster_id = 0xFC11
+
+    class AttributeDefs(BaseAttributeDefs):
+        """Attribute definitions."""
+
+        water_valve_state = ZCLAttributeDef(
+            id=0x500C,
+            type=ValveState,
+        )
+
+    @property
+    def _is_manuf_specific(self):
+        return False
+
+
+(
+    QuirkBuilder("SONOFF", "SWV")
+    .replaces(EwelinkCluster)
+    .add_to_registry()
+)  # fmt: skip
diff --git a/zhaquirks/xiaomi/aqara/motion_agl1.py b/zhaquirks/xiaomi/aqara/motion_agl1.py
new file mode 100644
index 0000000..0132924
--- /dev/null
+++ b/zhaquirks/xiaomi/aqara/motion_agl1.py
@@ -0,0 +1,192 @@
+"""Quirk for aqara lumi.sensor_occupy.agl1."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from zigpy import types
+from zigpy.quirks.v2 import (
+    NumberDeviceClass,
+    QuirkBuilder,
+    SensorDeviceClass,
+    SensorStateClass,
+)
+from zigpy.quirks.v2.homeassistant import EntityType, UnitOfLength
+from zigpy.zcl.clusters.general import DeviceTemperature
+from zigpy.zcl.clusters.measurement import OccupancySensing
+from zigpy.zcl.clusters.security import IasZone
+from zigpy.zcl.foundation import BaseAttributeDefs, ZCLAttributeDef
+
+from zhaquirks import LocalDataCluster
+from zhaquirks.xiaomi import XiaomiAqaraE1Cluster
+
+
+class AqaraMotion(types.enum8):
+    """Aqara motion attribute values."""
+
+    Idle = 0x02
+    Moving = 0x03
+    Still = 0x04
+
+
+class AqaraMotionSensitivity(types.enum8):
+    """Aqara motion sensitivity attribute values."""
+
+    Low = 0x01
+    Medium = 0x02
+    High = 0x03
+
+
+class AqaraOccupancy(types.enum8):
+    """Aqara occupancy attribute values."""
+
+    Unoccupied = 0x00
+    Occupied = 0x01
+
+
+class IasZoneLocal(LocalDataCluster, IasZone):
+    """Virtual cluster for IasZone."""
+
+    _CONSTANT_ATTRIBUTES = {
+        IasZone.AttributeDefs.zone_type.id: IasZone.ZoneType.Motion_Sensor
+    }
+    _VALID_ATTRIBUTES = {IasZone.AttributeDefs.zone_status.id}
+
+
+class OccupancySensingLocal(LocalDataCluster, OccupancySensing):
+    """Virtual cluster for OccupancySensing."""
+
+    _VALID_ATTRIBUTES = {OccupancySensing.AttributeDefs.occupancy.id}
+
+
+class OppleCluster(XiaomiAqaraE1Cluster):
+    """Aqara manufacturer cluster for the presence sensor FP1E."""
+
+    class AttributeDefs(BaseAttributeDefs):
+        """Manufacturer specific attributes."""
+
+        # The configurable maximum detection distance in millimeters (default 600 = 6 meters).
+        approach_distance = ZCLAttributeDef(
+            id=0x015B,
+            type=types.uint32_t,
+            access="rw",
+            is_manufacturer_specific=True,
+        )
+
+        # Detected motion
+        motion = ZCLAttributeDef(
+            id=0x0160,
+            type=AqaraMotion,
+            access="rp",
+            is_manufacturer_specific=True,
+        )
+
+        # Distance to the detected motion in millimeters
+        motion_distance = ZCLAttributeDef(
+            id=0x015F,
+            type=types.uint32_t,
+            access="rp",
+            is_manufacturer_specific=True,
+        )
+
+        # The configurable detection sensitivity
+        motion_sensitivity = ZCLAttributeDef(
+            id=0x010C,
+            type=AqaraMotionSensitivity,
+            access="rw",
+            is_manufacturer_specific=True,
+        )
+
+        # Detected occupancy
+        occupancy = ZCLAttributeDef(
+            id=0x0142,
+            type=AqaraOccupancy,
+            access="rp",
+            is_manufacturer_specific=True,
+        )
+
+        # Trigger AI spatial learning (write 1)
+        reset_no_presence_status = ZCLAttributeDef(
+            id=0x0157,
+            type=types.uint8_t,
+            access="w",
+            is_manufacturer_specific=True,
+        )
+
+        # Trigger device restart (write 0)
+        restart_device = ZCLAttributeDef(
+            id=0x00E8,
+            type=types.Bool,
+            access="w",
+            is_manufacturer_specific=True,
+        )
+
+    def _update_attribute(self, attrid: int, value: Any) -> None:
+        super()._update_attribute(attrid, value)
+        if attrid == self.AttributeDefs.occupancy.id:
+            self.endpoint.occupancy.update_attribute(
+                OccupancySensing.AttributeDefs.occupancy.id,
+                OccupancySensing.Occupancy.Occupied
+                if value == AqaraOccupancy.Occupied
+                else OccupancySensing.Occupancy.Unoccupied,
+            )
+        elif attrid == self.AttributeDefs.motion.id:
+            self.endpoint.ias_zone.update_attribute(
+                IasZone.AttributeDefs.zone_status.id,
+                IasZone.ZoneStatus.Alarm_1 if value == AqaraMotion.Moving else 0,
+            )
+
+
+(
+    QuirkBuilder("aqara", "lumi.sensor_occupy.agl1")
+    .friendly_name(manufacturer="Aqara", model="Presence Sensor FP1E")
+    .adds(DeviceTemperature)
+    .adds(OccupancySensingLocal)
+    .adds(IasZoneLocal)
+    .replaces(OppleCluster)
+    .number(
+        OppleCluster.AttributeDefs.approach_distance.name,
+        OppleCluster.cluster_id,
+        min_value=0,
+        max_value=6,
+        step=0.1,
+        unit=UnitOfLength.METERS,
+        multiplier=0.01,
+        device_class=NumberDeviceClass.DISTANCE,
+        translation_key="approach_distance",
+        fallback_name="Approach distance",
+    )
+    .sensor(
+        OppleCluster.AttributeDefs.motion_distance.name,
+        OppleCluster.cluster_id,
+        unit=UnitOfLength.METERS,
+        multiplier=0.01,
+        device_class=SensorDeviceClass.DISTANCE,
+        state_class=SensorStateClass.MEASUREMENT,
+        translation_key="motion_distance",
+        fallback_name="Motion distance",
+    )
+    .enum(
+        OppleCluster.AttributeDefs.motion_sensitivity.name,
+        AqaraMotionSensitivity,
+        OppleCluster.cluster_id,
+        translation_key="motion_sensitivity",
+        fallback_name="Motion sensitivity",
+    )
+    .write_attr_button(
+        OppleCluster.AttributeDefs.reset_no_presence_status.name,
+        1,
+        OppleCluster.cluster_id,
+        translation_key="reset_no_presence_status",
+        fallback_name="Presence status reset",
+    )
+    .write_attr_button(
+        OppleCluster.AttributeDefs.restart_device.name,
+        0,
+        OppleCluster.cluster_id,
+        entity_type=EntityType.DIAGNOSTIC,
+        translation_key="restart_device",
+        fallback_name="Restart device",
+    )
+    .add_to_registry()
+)


Test patch:
diff --git a/tests/test_xiaomi.py b/tests/test_xiaomi.py
index 6827973..ef2c7ad 100644
--- a/tests/test_xiaomi.py
+++ b/tests/test_xiaomi.py
@@ -1756,3 +1756,51 @@ def test_aqara_acn014_signature_match(assert_signature_matches_quirk):
     assert_signature_matches_quirk(
         zhaquirks.xiaomi.aqara.light_acn.LumiLightAcn014, signature
     )
+
+
+@pytest.mark.parametrize(
+    "occupancy_value, expected_occ_status, motion_value, expected_motion_status",
+    [
+        (0, OccupancySensing.Occupancy.Unoccupied, 2, 0),
+        (1, OccupancySensing.Occupancy.Occupied, 3, IasZone.ZoneStatus.Alarm_1),
+        (1, OccupancySensing.Occupancy.Occupied, 4, 0),
+    ],
+)
+async def test_aqara_fp1e_sensor(
+    zigpy_device_from_v2_quirk,
+    occupancy_value,
+    expected_occ_status,
+    motion_value,
+    expected_motion_status,
+):
+    """Test Aqara FP1E sensor."""
+    quirk = zigpy_device_from_v2_quirk("aqara", "lumi.sensor_occupy.agl1")
+
+    opple_cluster = quirk.endpoints[1].opple_cluster
+    ias_cluster = quirk.endpoints[1].ias_zone
+    occupancy_cluster = quirk.endpoints[1].occupancy
+
+    opple_listener = ClusterListener(opple_cluster)
+    ias_listener = ClusterListener(ias_cluster)
+    occupancy_listener = ClusterListener(occupancy_cluster)
+
+    # update custom occupancy attribute id
+    opple_cluster.update_attribute(0x0142, occupancy_value)
+    assert len(opple_listener.attribute_updates) == 1
+
+    # confirm occupancy cluster is updated
+    assert len(occupancy_listener.attribute_updates) == 1
+    assert (
+        occupancy_listener.attribute_updates[0][0]
+        == OccupancySensing.AttributeDefs.occupancy.id
+    )
+    assert occupancy_listener.attribute_updates[0][1] == expected_occ_status
+
+    # update custom motion attribute id
+    opple_cluster.update_attribute(0x0160, motion_value)
+    assert len(opple_listener.attribute_updates) == 2
+
+    # confirm ias cluster is updated
+    assert len(ias_listener.attribute_updates) == 1
+    assert ias_listener.attribute_updates[0][0] == IasZone.AttributeDefs.zone_status.id
+    assert ias_listener.attribute_updates[0][1] == expected_motion_status