Repository: zigpy/zha-device-handlers
Pull request #3521 (2024-11-18 09:57:47)

Add Aqara FP1E presence sensor v2 quirk
## Proposed change
<!--
  Explain your proposed change below.
-->
This quirk adds support for the Aqara FP1E presence sensor, exposing its motion and occupancy sensors in addition to its  configuration parameters.

Fixes https://github.com/zigpy/zha-device-handlers/issues/3294 and is written using the quirks v2 format.

![image](https://github.com/user-attachments/assets/07e1b493-89c8-4f5a-92f0-ad40cfbb2ab0)



## 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".
-->

There are two issues that we'd ideally want to fix before merging this PR.

- https://github.com/zigpy/zha/issues/290
   - _Currently unable to specify the unit for the distance sensors_
   - **PR raised:** https://github.com/zigpy/zha/pull/307
- https://github.com/zigpy/zha/issues/291
   - _I'd like to make the _Restart device_ button a diagnostic rather than config function_
   - **PR raised:** https://github.com/zigpy/zha/pull/308

## 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/3521?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 `89.13043%` with `5 lines` in your changes missing coverage. Please review.
> Project coverage is 89.44%. Comparing base [(`3f4aee0`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/3f4aee04a2d6be56f63f355406f0a4a00c4e4093?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy) to head [(`a2c125b`)](https://app.codecov.io/gh/zigpy/zha-device-handlers/commit/a2c125bb9fbbcf45d3b0a1b82c4311899dc1c834?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/3521?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/xiaomi/aqara/motion\_agl1.py](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3521?src=pr&el=tree&filepath=zhaquirks%2Fxiaomi%2Faqara%2Fmotion_agl1.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=zigpy#diff-emhhcXVpcmtzL3hpYW9taS9hcWFyYS9tb3Rpb25fYWdsMS5weQ==) | 89.13% | [5 Missing :warning: ](https://app.codecov.io/gh/zigpy/zha-device-handlers/pull/3521?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    #3521      +/-   ##
==========================================
- Coverage   89.44%   89.44%   -0.01%     
==========================================
  Files         311      312       +1     
  Lines       10033    10079      +46     
==========================================
+ Hits         8974     9015      +41     
- Misses       1059     1064       +5     
```

</details>

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

jeverley: Hi @dmulcahey, do you have any feedback on the current implementation with regards to quirk v2 conventions?
jeverley: > I've also added a small test to cover the `_update_attribute` method: [f0d0754](https://github.com/zigpy/zha-device-handlers/commit/f0d075478de26c6200023ec4a1971a3e9e1b984c)

Thanks for adding these, I was still wrapping my head around how best to introduce a v2 quirk amongst the other xiaomi motion devices.
I think we need to make a small tweak to the expected statuses for motion in here, possible values being:

```
class AqaraMotion(types.enum8):
    """Aqara motion attribute values."""

    Idle = 0x02 # no presence
    Moving = 0x03 # present and moving
    Still = 0x04 # present and not moving
```
Therefore the parameters should be (if I'm understanding the test syntax correctly):

```
@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),
    ],
)

```
Will add a commit reflecting this shortly.


TheJulianJES: Looks like Codecov is broken across all repos again. This PR is fully covered.

Patch:
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