Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Aqara FP1E presence sensor v2 quirk #3521

Merged
merged 18 commits into from
Nov 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 48 additions & 0 deletions tests/test_xiaomi.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
192 changes: 192 additions & 0 deletions zhaquirks/xiaomi/aqara/motion_agl1.py
Original file line number Diff line number Diff line change
@@ -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."""

TheJulianJES marked this conversation as resolved.
Show resolved Hide resolved
_CONSTANT_ATTRIBUTES = {
IasZone.AttributeDefs.zone_type.id: IasZone.ZoneType.Motion_Sensor
}
_VALID_ATTRIBUTES = {IasZone.AttributeDefs.zone_status.id}
Comment on lines +47 to +53
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note for other reviewers: We need a virtual cluster for IasZone here, as we need a virtual attribute for "motion". The Xiaomi motion attribute needs to be parsed in a way that we can't yet do with quirks v2.

Also, for motion/occupancy, it makes sense to use the integrated entities in ZHA IMO. We'd have to duplicate "motion" and "occupancy" quirk entities with all properties everywhere otherwise.

We already use virtual clusters in a lot of quirks and they'll always be needed for some (especially for Tuya and some Xiaomi devices), unless we have some kind of other structure we can map attributes to (like Z2M does).



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()
)