From f634a3417293011a0a777a9a9b3c6babc884a23b Mon Sep 17 00:00:00 2001 From: Cem Basoglu Date: Wed, 19 Nov 2025 17:57:36 +0100 Subject: [PATCH 1/5] add fp300 (lumi agl8) quirk --- zhaquirks/xiaomi/aqara/motion_agl8.py | 831 ++++++++++++++++++++++++++ 1 file changed, 831 insertions(+) create mode 100644 zhaquirks/xiaomi/aqara/motion_agl8.py diff --git a/zhaquirks/xiaomi/aqara/motion_agl8.py b/zhaquirks/xiaomi/aqara/motion_agl8.py new file mode 100644 index 0000000000..16d77a13d8 --- /dev/null +++ b/zhaquirks/xiaomi/aqara/motion_agl8.py @@ -0,0 +1,831 @@ +"""Quirk for LUMI lumi.motion.agl8.""" + +from typing import Any, Final + +from zigpy import types as t +from zigpy.quirks.v2 import QuirkBuilder, ReportingConfig +from zigpy.quirks.v2.homeassistant import EntityType, UnitOfTime +from zigpy.quirks.v2.homeassistant.binary_sensor import BinarySensorDeviceClass +from zigpy.quirks.v2.homeassistant.number import NumberDeviceClass +from zigpy.quirks.v2.homeassistant.sensor import SensorDeviceClass, SensorStateClass +from zigpy.zcl.foundation import BaseAttributeDefs, DataTypeId, ZCLAttributeDef + +from zhaquirks import LocalDataCluster +from zhaquirks.xiaomi import ( + XiaomiAqaraE1Cluster, + XiaomiPowerConfigurationPercent, + BATTERY_PERCENTAGE_REMAINING_ATTRIBUTE, + BATTERY_VOLTAGE_MV +) + +# Manufacturer-specific attribute keys present in the non-standard AQARA payloads +MANU_ATTR_BATTERY_VOLTAGE: Final = "0xff01-23" +MANU_ATTR_BATTERY_PERCENT: Final = "0xff01-24" + +# +# Enums matching Zigbee2MQTT converter semantics +# +class MotionSensitivity(t.enum8): + """Presence / motion sensitivity.""" + + LOW = 1 + MEDIUM = 2 + HIGH = 3 + + +class PresenceDetectionMode(t.enum8): + """Which sensors are used for presence.""" + + BOTH = 0 + MMWAVE_ONLY = 1 + PIR_ONLY = 2 + + +class TempHumiditySampling(t.enum8): + """Sampling frequency for temperature & humidity.""" + + OFF = 0 + LOW = 1 + MEDIUM = 2 + HIGH = 3 + CUSTOM = 4 + + +class ReportMode(t.enum8): + """Reporting mode for temp/humidity/illuminance in custom mode.""" + + THRESHOLD = 1 + INTERVAL = 2 + THRESHOLD_AND_INTERVAL = 3 + + +class LightSampling(t.enum8): + """Sampling frequency for illuminance.""" + + OFF = 0 + LOW = 1 + MEDIUM = 2 + HIGH = 3 + CUSTOM = 4 + + +# +# Manufacturer specific cluster (0xFCC0) +# +class AqaraFP300ManuCluster(XiaomiAqaraE1Cluster): + """Aqara FP300 manufacturer specific cluster (0xFCC0).""" + + class AttributeDefs(BaseAttributeDefs): + """Attribute definitions for Aqara FP300 manu cluster.""" + + # + # Presence / motion + # + presence: Final = ZCLAttributeDef( + id=0x0142, + type=t.Bool, + zcl_type=DataTypeId.uint8, + access="rp", + is_manufacturer_specific=True, + ) + + pir_detection: Final = ZCLAttributeDef( + id=0x014D, + type=t.Bool, + zcl_type=DataTypeId.uint8, + access="rp", + is_manufacturer_specific=True, + ) + + motion_sensitivity: Final = ZCLAttributeDef( + id=0x010C, + type=MotionSensitivity, + zcl_type=DataTypeId.uint8, + access="rwp", + is_manufacturer_specific=True, + ) + + absence_delay_timer: Final = ZCLAttributeDef( + id=0x0197, + type=t.uint32_t, + zcl_type=DataTypeId.uint32, + access="rwp", + is_manufacturer_specific=True, + ) + + pir_detection_interval: Final = ZCLAttributeDef( + id=0x014F, + type=t.uint16_t, + zcl_type=DataTypeId.uint16, + access="rwp", + is_manufacturer_specific=True, + ) + + presence_detection_options: Final = ZCLAttributeDef( + id=0x0199, + type=PresenceDetectionMode, + zcl_type=DataTypeId.uint8, + access="rwp", + is_manufacturer_specific=True, + ) + + detection_range_raw: Final = ZCLAttributeDef( + id=0x019A, + type=t.LVBytes, + zcl_type=DataTypeId.octstr, + access="rpw", + is_manufacturer_specific=True, + ) + + # + # AI helpers + # + ai_interference_source_selfidentification: Final = ZCLAttributeDef( + id=0x015E, + type=t.uint8_t, + zcl_type=DataTypeId.uint8, + access="rwp", + is_manufacturer_specific=True, + ) + + ai_sensitivity_adaptive: Final = ZCLAttributeDef( + id=0x015D, + type=t.uint8_t, + zcl_type=DataTypeId.uint8, + access="rwp", + is_manufacturer_specific=True, + ) + + # + # Target distance / tracking + # + target_distance: Final = ZCLAttributeDef( + id=0x015F, + type=t.uint32_t, + zcl_type=DataTypeId.uint32, + access="rp", + is_manufacturer_specific=True, + ) + + track_target_distance: Final = ZCLAttributeDef( + id=0x0198, + type=t.uint8_t, + zcl_type=DataTypeId.uint8, + access="w", + is_manufacturer_specific=True, + ) + + # + # Temp/humidity sampling + reporting + # + temp_humidity_sampling: Final = ZCLAttributeDef( + id=0x0170, + type=TempHumiditySampling, + zcl_type=DataTypeId.uint8, + access="rwp", + is_manufacturer_specific=True, + ) + + temp_humidity_sampling_period: Final = ZCLAttributeDef( + id=0x0162, + type=t.uint32_t, + zcl_type=DataTypeId.uint32, + access="rwp", + is_manufacturer_specific=True, + ) + + temp_reporting_interval: Final = ZCLAttributeDef( + id=0x0163, + type=t.uint32_t, + zcl_type=DataTypeId.uint32, + access="rwp", + is_manufacturer_specific=True, + ) + + temp_reporting_threshold: Final = ZCLAttributeDef( + id=0x0164, + type=t.uint16_t, + zcl_type=DataTypeId.uint16, + access="rwp", + is_manufacturer_specific=True, + ) + + temp_reporting_mode: Final = ZCLAttributeDef( + id=0x0165, + type=ReportMode, + zcl_type=DataTypeId.uint8, + access="rwp", + is_manufacturer_specific=True, + ) + + humidity_reporting_interval: Final = ZCLAttributeDef( + id=0x016A, + type=t.uint32_t, + zcl_type=DataTypeId.uint32, + access="rwp", + is_manufacturer_specific=True, + ) + + humidity_reporting_threshold: Final = ZCLAttributeDef( + id=0x016B, + type=t.uint16_t, + zcl_type=DataTypeId.uint16, + access="rwp", + is_manufacturer_specific=True, + ) + + humidity_reporting_mode: Final = ZCLAttributeDef( + id=0x016C, + type=ReportMode, + zcl_type=DataTypeId.uint8, + access="rwp", + is_manufacturer_specific=True, + ) + + # + # Illuminance sampling + reporting + # + light_sampling: Final = ZCLAttributeDef( + id=0x0192, + type=LightSampling, + zcl_type=DataTypeId.uint8, + access="rwp", + is_manufacturer_specific=True, + ) + + light_sampling_period: Final = ZCLAttributeDef( + id=0x0193, + type=t.uint32_t, + zcl_type=DataTypeId.uint32, + access="rwp", + is_manufacturer_specific=True, + ) + + light_reporting_interval: Final = ZCLAttributeDef( + id=0x0194, + type=t.uint32_t, + zcl_type=DataTypeId.uint32, + access="rwp", + is_manufacturer_specific=True, + ) + + light_reporting_threshold: Final = ZCLAttributeDef( + id=0x0195, + type=t.uint16_t, + zcl_type=DataTypeId.uint16, + access="rwp", + is_manufacturer_specific=True, + ) + + light_reporting_mode: Final = ZCLAttributeDef( + id=0x0196, + type=ReportMode, + zcl_type=DataTypeId.uint8, + access="rwp", + is_manufacturer_specific=True, + ) + + # + # Spatial learning / restart (FP1E-style maintenance actions) + # + spatial_learning: Final = ZCLAttributeDef( + id=0x0157, + type=t.uint8_t, + zcl_type=DataTypeId.uint8, + access="w", + is_manufacturer_specific=True, + ) + + restart_device: Final = ZCLAttributeDef( + id=0x00E8, + type=t.Bool, + zcl_type=DataTypeId.bool_, + access="w", + is_manufacturer_specific=True, + ) + + def _parse_aqara_attributes(self, value: Any) -> dict[str, Any]: + """Parse non-standard and fp300 specific attributes. + + Returns a mapping of attribute keys to values as extracted by the + parent implementation, with a couple of manufacturer-specific keys + renamed to common battery attribute names used in this project. + """ + attributes = super()._parse_aqara_attributes(value) + + if MANU_ATTR_BATTERY_VOLTAGE in attributes: + attributes[BATTERY_VOLTAGE_MV] = attributes.pop(MANU_ATTR_BATTERY_VOLTAGE) + + if MANU_ATTR_BATTERY_PERCENT in attributes: + attributes[BATTERY_PERCENTAGE_REMAINING_ATTRIBUTE] = attributes.pop(MANU_ATTR_BATTERY_PERCENT) + + return attributes + + def _update_attribute(self, attrid: int, value: Any) -> Any: + """Only delegate 0x019A to the FP300DetectionRangeCluster. + + If the attribute id corresponds to the raw detection-range payload we + forward that to the local detection-range cluster which decodes the + buffer into separate boolean range attributes. The result of the + parent implementation is returned to the caller. + """ + + if attrid == self.AttributeDefs.detection_range_raw.id: + dr_cluster = self.endpoint.in_clusters.get(FP300DetectionRangeCluster.cluster_id) + if dr_cluster is not None: + dr_cluster._update_from_raw(value) + + return super()._update_attribute(attrid, value) + + + +class FP300DetectionRangeCluster(LocalDataCluster): + """Local cluster for detection range handling.""" + + cluster_id = 0xFC30 + + class AttributeDefs(BaseAttributeDefs): + """Attribute definitions for FP300 detection range cluster.""" + + prefix: Final = ZCLAttributeDef( + id=0x0000, + type=t.uint16_t, + zcl_type=DataTypeId.uint16, + access="rpw", + ) + + range_0_1m: Final = ZCLAttributeDef( + id=0x0001, + type=t.Bool, + zcl_type=DataTypeId.bool_, + access="rwp", + ) + range_1_2m: Final = ZCLAttributeDef( + id=0x0002, + type=t.Bool, + zcl_type=DataTypeId.bool_, + access="rwp", + ) + range_2_3m: Final = ZCLAttributeDef( + id=0x0003, + type=t.Bool, + zcl_type=DataTypeId.bool_, + access="rwp", + ) + range_3_4m: Final = ZCLAttributeDef( + id=0x0004, + type=t.Bool, + zcl_type=DataTypeId.bool_, + access="rwp", + ) + range_4_5m: Final = ZCLAttributeDef( + id=0x0005, + type=t.Bool, + zcl_type=DataTypeId.bool_, + access="rwp", + ) + range_5_6m: Final = ZCLAttributeDef( + id=0x0006, + type=t.Bool, + zcl_type=DataTypeId.bool_, + access="rwp", + ) + + + def _update_from_raw(self, raw: t.LVBytes | bytes | bytearray | None) -> None: + """updates from raw buffer of 0x019A attribute from manu cluster.""" + + if isinstance(raw, t.LVBytes): + data = bytes(raw) + elif isinstance(raw, (bytes, bytearray)): + data = bytes(raw) + else: + data = b"" + + if len(data) >= 5: + prefix = int.from_bytes(data[0:2], "little") + mask = int.from_bytes(data[2:5], "little") & ((1 << 24) - 1) + else: + prefix = 0x0300 + mask = (1 << 24) - 1 # 0xFFFFFF + + super()._update_attribute(self.AttributeDefs.prefix.id, prefix) + + seg_defs = [ + (self.AttributeDefs.range_0_1m.id, 0), + (self.AttributeDefs.range_1_2m.id, 4), + (self.AttributeDefs.range_2_3m.id, 8), + (self.AttributeDefs.range_3_4m.id, 12), + (self.AttributeDefs.range_4_5m.id, 16), + (self.AttributeDefs.range_5_6m.id, 20), + ] + + for attr_id, start_bit in seg_defs: + seg_mask = ((1 << 4) - 1) << start_bit + enabled = (mask & seg_mask) != 0 + super()._update_attribute(attr_id, bool(enabled)) + + + def _build_raw(self) -> t.LVBytes: + """builds raw buffer for 0x019A attribute for manu cluster from local range switches.""" + + prefix = self._attr_cache.get(self.AttributeDefs.prefix.id, 0x0300) + try: + prefix_int = int(prefix) & 0xFFFF + except (TypeError, ValueError): + prefix_int = 0x0300 + + seg_defs = [ + (self.AttributeDefs.range_0_1m.id, 0), + (self.AttributeDefs.range_1_2m.id, 4), + (self.AttributeDefs.range_2_3m.id, 8), + (self.AttributeDefs.range_3_4m.id, 12), + (self.AttributeDefs.range_4_5m.id, 16), + (self.AttributeDefs.range_5_6m.id, 20), + ] + + mask = 0 + for attr_id, start_bit in seg_defs: + enabled = bool(self._attr_cache.get(attr_id, True)) + if enabled: + mask |= ((1 << 4) - 1) << start_bit + + buf = prefix_int.to_bytes(2, "little") + mask.to_bytes(3, "little") + return t.LVBytes(buf) + + async def write_attributes( + self, + attributes: dict[int, Any], + manufacturer: int | None = None, + **kwargs: Any, + ) -> Any: + """Override write_attributes to also update manu cluster.""" + + res = await super().write_attributes(attributes, manufacturer=manufacturer, **kwargs) + + raw = self._build_raw() + + manu = self.endpoint.in_clusters.get(AqaraFP300ManuCluster.cluster_id) + if manu is not None: + await manu.write_attributes( + {AqaraFP300ManuCluster.AttributeDefs.detection_range_raw.id: raw}, + manufacturer=manufacturer, + ) + + return res + + +# +# QuirkBuilder definition +# +FP300_QUIRK = ( + QuirkBuilder("Aqara", "lumi.sensor_occupy.agl8") + .friendly_name(manufacturer="Aqara", model="Presence Sensor FP300") + .replaces(AqaraFP300ManuCluster) + .adds(XiaomiPowerConfigurationPercent) + .adds(FP300DetectionRangeCluster) + # Main presence entity (mmWave) + .binary_sensor( + attribute_name=AqaraFP300ManuCluster.AttributeDefs.presence.name, + cluster_id=AqaraFP300ManuCluster.cluster_id, + endpoint_id=1, + device_class=BinarySensorDeviceClass.OCCUPANCY, + entity_type=EntityType.STANDARD, + translation_key="presence", + fallback_name="Presence", + reporting_config=ReportingConfig( + min_interval=1, + max_interval=300, + reportable_change=1, + ), + ) + # Diagnostic PIR detection + .binary_sensor( + attribute_name=AqaraFP300ManuCluster.AttributeDefs.pir_detection.name, + cluster_id=AqaraFP300ManuCluster.cluster_id, + endpoint_id=1, + device_class=BinarySensorDeviceClass.MOTION, + entity_type=EntityType.DIAGNOSTIC, + translation_key="pir_detection", + fallback_name="PIR detection", + reporting_config=ReportingConfig( + min_interval=1, + max_interval=300, + reportable_change=1, + ), + initially_disabled=True, + ) + # Target distance (from fp1eTargetDistance) + .sensor( + attribute_name=AqaraFP300ManuCluster.AttributeDefs.target_distance.name, + cluster_id=AqaraFP300ManuCluster.cluster_id, + endpoint_id=1, + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + unit="m", + multiplier=0.01, # raw = meters * 100 + entity_type=EntityType.DIAGNOSTIC, + translation_key="target_distance", + fallback_name="Target distance", + ) + # Button: start tracking current target distance + .write_attr_button( + attribute_name=AqaraFP300ManuCluster.AttributeDefs.track_target_distance.name, + attribute_value=1, + cluster_id=AqaraFP300ManuCluster.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="track_target_distance", + fallback_name="Start target distance tracking", + ) + # Motion / presence config + .enum( + attribute_name=AqaraFP300ManuCluster.AttributeDefs.motion_sensitivity.name, + enum_class=MotionSensitivity, + cluster_id=AqaraFP300ManuCluster.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="motion_sensitivity", + fallback_name="Motion sensitivity", + ) + .enum( + attribute_name=AqaraFP300ManuCluster.AttributeDefs.presence_detection_options.name, + enum_class=PresenceDetectionMode, + cluster_id=AqaraFP300ManuCluster.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="presence_detection_options", + fallback_name="Presence detection options", + ) + .number( + attribute_name=AqaraFP300ManuCluster.AttributeDefs.absence_delay_timer.name, + cluster_id=AqaraFP300ManuCluster.cluster_id, + endpoint_id=1, + device_class=NumberDeviceClass.DURATION, + entity_type=EntityType.CONFIG, + translation_key="absence_delay_timer", + fallback_name="Absence delay timer", + min_value=10, + max_value=300, + step=5, + unit=UnitOfTime.SECONDS, + ) + .number( + attribute_name=AqaraFP300ManuCluster.AttributeDefs.pir_detection_interval.name, + cluster_id=AqaraFP300ManuCluster.cluster_id, + endpoint_id=1, + device_class=NumberDeviceClass.DURATION, + entity_type=EntityType.CONFIG, + translation_key="pir_detection_interval", + fallback_name="PIR detection interval", + min_value=2, + max_value=300, + step=1, + unit=UnitOfTime.SECONDS, + ) + # AI helper switches + .switch( + attribute_name=AqaraFP300ManuCluster.AttributeDefs.ai_interference_source_selfidentification.name, + cluster_id=AqaraFP300ManuCluster.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="ai_interference_source_selfidentification", + fallback_name="AI interference source self-identification", + ) + .switch( + attribute_name=AqaraFP300ManuCluster.AttributeDefs.ai_sensitivity_adaptive.name, + cluster_id=AqaraFP300ManuCluster.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="ai_sensitivity_adaptive", + fallback_name="AI adaptive sensitivity", + ) + # Temp/humidity sampling & reporting + .enum( + attribute_name=AqaraFP300ManuCluster.AttributeDefs.temp_humidity_sampling.name, + enum_class=TempHumiditySampling, + cluster_id=AqaraFP300ManuCluster.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="temp_humidity_sampling", + fallback_name="Temp & humidity sampling", + ) + .number( + attribute_name=AqaraFP300ManuCluster.AttributeDefs.temp_humidity_sampling_period.name, + cluster_id=AqaraFP300ManuCluster.cluster_id, + endpoint_id=1, + device_class=NumberDeviceClass.DURATION, + entity_type=EntityType.CONFIG, + translation_key="temp_humidity_sampling_period", + fallback_name="Temp & humidity sampling period", + min_value=0.5, + max_value=3600.0, + step=0.5, + multiplier=0.001, # ms -> s + unit=UnitOfTime.SECONDS, + ) + .number( + attribute_name=AqaraFP300ManuCluster.AttributeDefs.temp_reporting_interval.name, + cluster_id=AqaraFP300ManuCluster.cluster_id, + endpoint_id=1, + device_class=NumberDeviceClass.DURATION, + entity_type=EntityType.CONFIG, + translation_key="temp_reporting_interval", + fallback_name="Temperature reporting interval", + min_value=600, + max_value=3600, + step=600, + multiplier=0.001, + unit=UnitOfTime.SECONDS, + ) + .number( + attribute_name=AqaraFP300ManuCluster.AttributeDefs.temp_reporting_threshold.name, + cluster_id=AqaraFP300ManuCluster.cluster_id, + endpoint_id=1, + device_class=NumberDeviceClass.TEMPERATURE, + entity_type=EntityType.CONFIG, + translation_key="temp_reporting_threshold", + fallback_name="Temperature reporting threshold", + min_value=0.2, + max_value=3.0, + step=0.1, + multiplier=0.01, + unit="°C", + ) + .enum( + attribute_name=AqaraFP300ManuCluster.AttributeDefs.temp_reporting_mode.name, + enum_class=ReportMode, + cluster_id=AqaraFP300ManuCluster.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="temp_reporting_mode", + fallback_name="Temperature reporting mode", + ) + .number( + attribute_name=AqaraFP300ManuCluster.AttributeDefs.humidity_reporting_interval.name, + cluster_id=AqaraFP300ManuCluster.cluster_id, + endpoint_id=1, + device_class=NumberDeviceClass.DURATION, + entity_type=EntityType.CONFIG, + translation_key="humidity_reporting_interval", + fallback_name="Humidity reporting interval", + min_value=600, + max_value=3600, + step=600, + multiplier=0.001, + unit=UnitOfTime.SECONDS, + ) + .number( + attribute_name=AqaraFP300ManuCluster.AttributeDefs.humidity_reporting_threshold.name, + cluster_id=AqaraFP300ManuCluster.cluster_id, + endpoint_id=1, + device_class=NumberDeviceClass.HUMIDITY, + entity_type=EntityType.CONFIG, + translation_key="humidity_reporting_threshold", + fallback_name="Humidity reporting threshold", + min_value=2.0, + max_value=20.0, + step=0.5, + multiplier=0.01, + unit="%", + ) + .enum( + attribute_name=AqaraFP300ManuCluster.AttributeDefs.humidity_reporting_mode.name, + enum_class=ReportMode, + cluster_id=AqaraFP300ManuCluster.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="humidity_reporting_mode", + fallback_name="Humidity reporting mode", + ) + # Illuminance sampling & reporting + .enum( + attribute_name=AqaraFP300ManuCluster.AttributeDefs.light_sampling.name, + enum_class=LightSampling, + cluster_id=AqaraFP300ManuCluster.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="light_sampling", + fallback_name="Light sampling", + ) + .number( + attribute_name=AqaraFP300ManuCluster.AttributeDefs.light_sampling_period.name, + cluster_id=AqaraFP300ManuCluster.cluster_id, + endpoint_id=1, + device_class=NumberDeviceClass.DURATION, + entity_type=EntityType.CONFIG, + translation_key="light_sampling_period", + fallback_name="Light sampling period", + min_value=0.5, + max_value=3600.0, + step=0.5, + multiplier=0.001, + unit=UnitOfTime.SECONDS, + ) + .number( + attribute_name=AqaraFP300ManuCluster.AttributeDefs.light_reporting_interval.name, + cluster_id=AqaraFP300ManuCluster.cluster_id, + endpoint_id=1, + device_class=NumberDeviceClass.DURATION, + entity_type=EntityType.CONFIG, + translation_key="light_reporting_interval", + fallback_name="Light reporting interval", + min_value=20, + max_value=3600, + step=20, + multiplier=0.001, + unit=UnitOfTime.SECONDS, + ) + .number( + attribute_name=AqaraFP300ManuCluster.AttributeDefs.light_reporting_threshold.name, + cluster_id=AqaraFP300ManuCluster.cluster_id, + endpoint_id=1, + # “percentage change” – omit device_class + entity_type=EntityType.CONFIG, + translation_key="light_reporting_threshold", + fallback_name="Light reporting threshold", + min_value=3.0, + max_value=20.0, + step=0.5, + multiplier=0.01, + unit="%", + ) + .enum( + attribute_name=AqaraFP300ManuCluster.AttributeDefs.light_reporting_mode.name, + enum_class=ReportMode, + cluster_id=AqaraFP300ManuCluster.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="light_reporting_mode", + fallback_name="Light reporting mode", + ) + # Maintenance buttons + .write_attr_button( + attribute_name=AqaraFP300ManuCluster.AttributeDefs.spatial_learning.name, + attribute_value=1, + cluster_id=AqaraFP300ManuCluster.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="spatial_learning", + fallback_name="Start spatial learning", + ) + .write_attr_button( + attribute_name=AqaraFP300ManuCluster.AttributeDefs.restart_device.name, + attribute_value=1, + cluster_id=AqaraFP300ManuCluster.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="restart_device", + fallback_name="Restart device", + ) + # Detection range switches + .switch( + attribute_name=FP300DetectionRangeCluster.AttributeDefs.range_0_1m.name, + cluster_id=FP300DetectionRangeCluster.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="detection_range_0_1m", + fallback_name="Detection range 0-1 m", + ) + .switch( + attribute_name=FP300DetectionRangeCluster.AttributeDefs.range_1_2m.name, + cluster_id=FP300DetectionRangeCluster.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="detection_range_1_2m", + fallback_name="Detection range 1-2 m", + ) + .switch( + attribute_name=FP300DetectionRangeCluster.AttributeDefs.range_2_3m.name, + cluster_id=FP300DetectionRangeCluster.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="detection_range_2_3m", + fallback_name="Detection range 2-3 m", + ) + .switch( + attribute_name=FP300DetectionRangeCluster.AttributeDefs.range_3_4m.name, + cluster_id=FP300DetectionRangeCluster.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="detection_range_3_4m", + fallback_name="Detection range 3-4 m", + ) + .switch( + attribute_name=FP300DetectionRangeCluster.AttributeDefs.range_4_5m.name, + cluster_id=FP300DetectionRangeCluster.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="detection_range_4_5m", + fallback_name="Detection range 4-5 m", + ) + .switch( + attribute_name=FP300DetectionRangeCluster.AttributeDefs.range_5_6m.name, + cluster_id=FP300DetectionRangeCluster.cluster_id, + endpoint_id=1, + entity_type=EntityType.CONFIG, + translation_key="detection_range_5_6m", + fallback_name="Detection range 5-6 m", + ) + .add_to_registry() +) From e27f6f51328c3f5f18d77b2e5e7519e52c8293c9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 19 Nov 2025 18:21:29 +0000 Subject: [PATCH 2/5] Apply pre-commit auto fixes --- zhaquirks/xiaomi/aqara/motion_agl8.py | 30 ++++++++++++++------------- 1 file changed, 16 insertions(+), 14 deletions(-) diff --git a/zhaquirks/xiaomi/aqara/motion_agl8.py b/zhaquirks/xiaomi/aqara/motion_agl8.py index 16d77a13d8..ce4e2bd73d 100644 --- a/zhaquirks/xiaomi/aqara/motion_agl8.py +++ b/zhaquirks/xiaomi/aqara/motion_agl8.py @@ -12,16 +12,17 @@ from zhaquirks import LocalDataCluster from zhaquirks.xiaomi import ( + BATTERY_PERCENTAGE_REMAINING_ATTRIBUTE, + BATTERY_VOLTAGE_MV, XiaomiAqaraE1Cluster, XiaomiPowerConfigurationPercent, - BATTERY_PERCENTAGE_REMAINING_ATTRIBUTE, - BATTERY_VOLTAGE_MV ) # Manufacturer-specific attribute keys present in the non-standard AQARA payloads MANU_ATTR_BATTERY_VOLTAGE: Final = "0xff01-23" MANU_ATTR_BATTERY_PERCENT: Final = "0xff01-24" + # # Enums matching Zigbee2MQTT converter semantics # @@ -317,7 +318,9 @@ def _parse_aqara_attributes(self, value: Any) -> dict[str, Any]: attributes[BATTERY_VOLTAGE_MV] = attributes.pop(MANU_ATTR_BATTERY_VOLTAGE) if MANU_ATTR_BATTERY_PERCENT in attributes: - attributes[BATTERY_PERCENTAGE_REMAINING_ATTRIBUTE] = attributes.pop(MANU_ATTR_BATTERY_PERCENT) + attributes[BATTERY_PERCENTAGE_REMAINING_ATTRIBUTE] = attributes.pop( + MANU_ATTR_BATTERY_PERCENT + ) return attributes @@ -331,14 +334,15 @@ def _update_attribute(self, attrid: int, value: Any) -> Any: """ if attrid == self.AttributeDefs.detection_range_raw.id: - dr_cluster = self.endpoint.in_clusters.get(FP300DetectionRangeCluster.cluster_id) + dr_cluster = self.endpoint.in_clusters.get( + FP300DetectionRangeCluster.cluster_id + ) if dr_cluster is not None: dr_cluster._update_from_raw(value) return super()._update_attribute(attrid, value) - class FP300DetectionRangeCluster(LocalDataCluster): """Local cluster for detection range handling.""" @@ -391,13 +395,10 @@ class AttributeDefs(BaseAttributeDefs): access="rwp", ) - def _update_from_raw(self, raw: t.LVBytes | bytes | bytearray | None) -> None: - """updates from raw buffer of 0x019A attribute from manu cluster.""" + """Updates from raw buffer of 0x019A attribute from manu cluster.""" - if isinstance(raw, t.LVBytes): - data = bytes(raw) - elif isinstance(raw, (bytes, bytearray)): + if isinstance(raw, t.LVBytes) or isinstance(raw, (bytes, bytearray)): data = bytes(raw) else: data = b"" @@ -425,9 +426,8 @@ def _update_from_raw(self, raw: t.LVBytes | bytes | bytearray | None) -> None: enabled = (mask & seg_mask) != 0 super()._update_attribute(attr_id, bool(enabled)) - def _build_raw(self) -> t.LVBytes: - """builds raw buffer for 0x019A attribute for manu cluster from local range switches.""" + """Builds raw buffer for 0x019A attribute for manu cluster from local range switches.""" prefix = self._attr_cache.get(self.AttributeDefs.prefix.id, 0x0300) try: @@ -459,9 +459,11 @@ async def write_attributes( manufacturer: int | None = None, **kwargs: Any, ) -> Any: - """Override write_attributes to also update manu cluster.""" + """Override write_attributes to also update manu cluster.""" - res = await super().write_attributes(attributes, manufacturer=manufacturer, **kwargs) + res = await super().write_attributes( + attributes, manufacturer=manufacturer, **kwargs + ) raw = self._build_raw() From 7e796eda5830397606f4fb83768731bfc6017aa0 Mon Sep 17 00:00:00 2001 From: Cem Basoglu Date: Thu, 20 Nov 2025 20:05:13 +0000 Subject: [PATCH 3/5] add tests --- tests/test_xiaomi.py | 171 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 171 insertions(+) diff --git a/tests/test_xiaomi.py b/tests/test_xiaomi.py index 37c566799c..35b5152369 100644 --- a/tests/test_xiaomi.py +++ b/tests/test_xiaomi.py @@ -89,6 +89,10 @@ import zhaquirks.xiaomi.aqara.motion_acn001 import zhaquirks.xiaomi.aqara.motion_agl02 import zhaquirks.xiaomi.aqara.motion_agl04 +from zhaquirks.xiaomi.aqara.motion_agl8 import ( + AqaraFP300ManuCluster, + FP300DetectionRangeCluster, +) import zhaquirks.xiaomi.aqara.motion_aq2 import zhaquirks.xiaomi.aqara.motion_aq2b import zhaquirks.xiaomi.aqara.plug @@ -2282,3 +2286,170 @@ async def test_lumi_magnet_sensor_aq2_bad_direction(zigpy_device_from_quirk, cap # Our matching logic should be forgiving assert listener.attribute_updates == [(0, t.Bool.true)] + + +def test_fp300_clusters(zigpy_device_from_v2_quirk): + """Test Aqara FP300 clusters.""" + # create device with endpoint 1 only and verify we don't get a KeyError + device = zigpy_device_from_v2_quirk(AQARA, "lumi.sensor_occupy.agl8") + + assert AqaraFP300ManuCluster.cluster_id in device.endpoints[1].in_clusters + assert FP300DetectionRangeCluster.cluster_id in device.endpoints[1].in_clusters + + +def test_aqara_fp300_battery_from_e1_tlv(zigpy_device_from_v2_quirk): + """Test Aqara FP300 battery reporting from Aqara E1 TLV (0x00F7).""" + + device = zigpy_device_from_v2_quirk(AQARA, "lumi.sensor_occupy.agl8") + + manu_cluster = device.endpoints[1].in_clusters[AqaraFP300ManuCluster.cluster_id] + + power_cluster = device.endpoints[1].power + power_listener = ClusterListener(power_cluster) + + zcl_power_voltage_id = PowerConfiguration.AttributeDefs.battery_voltage.id + zcl_power_percent_id = ( + PowerConfiguration.AttributeDefs.battery_percentage_remaining.id + ) + + manu_cluster.update_attribute( + XIAOMI_AQARA_ATTRIBUTE_E1, + create_aqara_attr_report({23: 306, 24: 100}), + ) + + assert len(power_listener.attribute_updates) == 2 + assert power_listener.attribute_updates[0][0] == zcl_power_voltage_id + assert power_listener.attribute_updates[0][1] == 3.1 + + assert power_listener.attribute_updates[1][0] == zcl_power_percent_id + assert power_listener.attribute_updates[1][1] == 200 # 100 % * 2 + + +@pytest.mark.parametrize( + "raw_payload, expected_segments", + ( + (t.LVBytes(bytes.fromhex("0003ffffff")), [True, True, True, True, True, True]), + (t.LVBytes(bytes.fromhex("0003f0ffff")), [False, True, True, True, True, True]), + ( + t.LVBytes(bytes.fromhex("000300ffff")), + [False, False, True, True, True, True], + ), + ( + t.LVBytes(bytes.fromhex("000300f0ff")), + [False, False, False, True, True, True], + ), + ( + t.LVBytes(bytes.fromhex("00030000ff")), + [False, False, False, False, True, True], + ), + ( + t.LVBytes(bytes.fromhex("00030000f0")), + [False, False, False, False, False, True], + ), + ( + t.LVBytes(bytes.fromhex("0003000000")), + [False, False, False, False, False, False], + ), + ( + t.LVBytes(), + [True, True, True, True, True, True], + ), # invalid case defaults to all enabled + ( + 123, + [True, True, True, True, True, True], + ), # invalid case defaults to all enabled + ), +) +def test_aqara_fp300_detection_range_decode( + zigpy_device_from_v2_quirk, raw_payload, expected_segments +): + """Test FP300 detection range decoding from 0x019A into 6 local switches.""" + + device = zigpy_device_from_v2_quirk(AQARA, "lumi.sensor_occupy.agl8") + + manu_cluster = device.endpoints[1].in_clusters[AqaraFP300ManuCluster.cluster_id] + dr_cluster = device.endpoints[1].in_clusters[FP300DetectionRangeCluster.cluster_id] + + manu_cluster.update_attribute( + AqaraFP300ManuCluster.AttributeDefs.detection_range_raw.id, + raw_payload, + ) + + seg_ids = [ + FP300DetectionRangeCluster.AttributeDefs.range_0_1m.id, + FP300DetectionRangeCluster.AttributeDefs.range_1_2m.id, + FP300DetectionRangeCluster.AttributeDefs.range_2_3m.id, + FP300DetectionRangeCluster.AttributeDefs.range_3_4m.id, + FP300DetectionRangeCluster.AttributeDefs.range_4_5m.id, + FP300DetectionRangeCluster.AttributeDefs.range_5_6m.id, + ] + + actual = [bool(dr_cluster._attr_cache.get(attr_id, False)) for attr_id in seg_ids] + assert actual == expected_segments + + +@pytest.mark.parametrize( + "prefix, segments, expected_mask", + ( + (0x0300, [True, True, True, True, True, True], 0xFFFFFF), + (0x0300, [False, True, True, True, True, True], 0xFFFFF0), + (0x0300, [False, False, True, True, True, True], 0xFFFF00), + (0x0300, [False, False, False, True, True, True], 0xFFF000), + (0x0300, [False, False, False, False, True, True], 0xFF0000), + (0x0300, [False, False, False, False, False, True], 0xF00000), + (0x0300, [False, False, False, False, False, False], 0x000000), + ("not_an_int", [False, False, True, True, True, True], 0xFFFF00), + ), +) +@pytest.mark.asyncio +async def test_aqara_fp300_detection_range_encode( + zigpy_device_from_v2_quirk, prefix, segments, expected_mask +): + """Test FP300 detection range encoding from 6 switches into raw 0x019A via write_attributes.""" + + device = zigpy_device_from_v2_quirk(AQARA, "lumi.sensor_occupy.agl8") + + manu_cluster = device.endpoints[1].in_clusters[AqaraFP300ManuCluster.cluster_id] + dr_cluster = device.endpoints[1].in_clusters[FP300DetectionRangeCluster.cluster_id] + + manu_cluster._write_attributes = mock.AsyncMock() + + # Prefix im LocalDataCluster setzen (wie Quirk-Default: 0x0300) + dr_cluster._update_attribute( + FP300DetectionRangeCluster.AttributeDefs.prefix.id, + prefix, + ) + + seg_ids = [ + FP300DetectionRangeCluster.AttributeDefs.range_0_1m.id, + FP300DetectionRangeCluster.AttributeDefs.range_1_2m.id, + FP300DetectionRangeCluster.AttributeDefs.range_2_3m.id, + FP300DetectionRangeCluster.AttributeDefs.range_3_4m.id, + FP300DetectionRangeCluster.AttributeDefs.range_4_5m.id, + FP300DetectionRangeCluster.AttributeDefs.range_5_6m.id, + ] + attr_dict = dict(zip(seg_ids, segments)) + + prefix_int = 0x0300 + expected_bytes = prefix_int.to_bytes(2, "little") + expected_mask.to_bytes( + 3, "little" + ) + + expected_attr_def = manu_cluster.find_attribute( + AqaraFP300ManuCluster.AttributeDefs.detection_range_raw.id + ) + expected = foundation.Attribute( + AqaraFP300ManuCluster.AttributeDefs.detection_range_raw.id, + foundation.TypeValue(), + ) + expected.value.type = foundation.DataType.from_python_type( + expected_attr_def.type + ).type_id + expected.value.value = expected_attr_def.type(expected_bytes) + + await dr_cluster.write_attributes(attr_dict, manufacturer=0x115F) + + manu_cluster._write_attributes.assert_awaited_with( + [expected], + manufacturer=0x115F, + ) From 2f8dd63ad8a00e8e198d6bd5b877e695b72b38b4 Mon Sep 17 00:00:00 2001 From: Cem Basoglu Date: Thu, 20 Nov 2025 20:09:35 +0000 Subject: [PATCH 4/5] fix mood --- zhaquirks/xiaomi/aqara/motion_agl8.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/zhaquirks/xiaomi/aqara/motion_agl8.py b/zhaquirks/xiaomi/aqara/motion_agl8.py index ce4e2bd73d..9f8956a6c1 100644 --- a/zhaquirks/xiaomi/aqara/motion_agl8.py +++ b/zhaquirks/xiaomi/aqara/motion_agl8.py @@ -396,7 +396,7 @@ class AttributeDefs(BaseAttributeDefs): ) def _update_from_raw(self, raw: t.LVBytes | bytes | bytearray | None) -> None: - """Updates from raw buffer of 0x019A attribute from manu cluster.""" + """Update local detection range from raw 0x019A buffer.""" if isinstance(raw, t.LVBytes) or isinstance(raw, (bytes, bytearray)): data = bytes(raw) @@ -427,7 +427,7 @@ def _update_from_raw(self, raw: t.LVBytes | bytes | bytearray | None) -> None: super()._update_attribute(attr_id, bool(enabled)) def _build_raw(self) -> t.LVBytes: - """Builds raw buffer for 0x019A attribute for manu cluster from local range switches.""" + """Build raw 0x019A buffer for the manufacturer cluster from local range switches.""" prefix = self._attr_cache.get(self.AttributeDefs.prefix.id, 0x0300) try: From c14435ebc1f602bd36183cb8e5bc58d5ae400a9c Mon Sep 17 00:00:00 2001 From: TheJulianJES Date: Wed, 26 Nov 2025 04:48:00 +0100 Subject: [PATCH 5/5] Move translation key and fallback name --- zhaquirks/xiaomi/aqara/motion_agl8.py | 48 +++++++++++++-------------- 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/zhaquirks/xiaomi/aqara/motion_agl8.py b/zhaquirks/xiaomi/aqara/motion_agl8.py index 9f8956a6c1..66390c5d76 100644 --- a/zhaquirks/xiaomi/aqara/motion_agl8.py +++ b/zhaquirks/xiaomi/aqara/motion_agl8.py @@ -493,13 +493,13 @@ async def write_attributes( endpoint_id=1, device_class=BinarySensorDeviceClass.OCCUPANCY, entity_type=EntityType.STANDARD, - translation_key="presence", - fallback_name="Presence", reporting_config=ReportingConfig( min_interval=1, max_interval=300, reportable_change=1, ), + translation_key="presence", + fallback_name="Presence", ) # Diagnostic PIR detection .binary_sensor( @@ -508,14 +508,14 @@ async def write_attributes( endpoint_id=1, device_class=BinarySensorDeviceClass.MOTION, entity_type=EntityType.DIAGNOSTIC, - translation_key="pir_detection", - fallback_name="PIR detection", reporting_config=ReportingConfig( min_interval=1, max_interval=300, reportable_change=1, ), initially_disabled=True, + translation_key="pir_detection", + fallback_name="PIR detection", ) # Target distance (from fp1eTargetDistance) .sensor( @@ -565,12 +565,12 @@ async def write_attributes( endpoint_id=1, device_class=NumberDeviceClass.DURATION, entity_type=EntityType.CONFIG, - translation_key="absence_delay_timer", - fallback_name="Absence delay timer", min_value=10, max_value=300, step=5, unit=UnitOfTime.SECONDS, + translation_key="absence_delay_timer", + fallback_name="Absence delay timer", ) .number( attribute_name=AqaraFP300ManuCluster.AttributeDefs.pir_detection_interval.name, @@ -578,12 +578,12 @@ async def write_attributes( endpoint_id=1, device_class=NumberDeviceClass.DURATION, entity_type=EntityType.CONFIG, - translation_key="pir_detection_interval", - fallback_name="PIR detection interval", min_value=2, max_value=300, step=1, unit=UnitOfTime.SECONDS, + translation_key="pir_detection_interval", + fallback_name="PIR detection interval", ) # AI helper switches .switch( @@ -618,13 +618,13 @@ async def write_attributes( endpoint_id=1, device_class=NumberDeviceClass.DURATION, entity_type=EntityType.CONFIG, - translation_key="temp_humidity_sampling_period", - fallback_name="Temp & humidity sampling period", min_value=0.5, max_value=3600.0, step=0.5, multiplier=0.001, # ms -> s unit=UnitOfTime.SECONDS, + translation_key="temp_humidity_sampling_period", + fallback_name="Temp & humidity sampling period", ) .number( attribute_name=AqaraFP300ManuCluster.AttributeDefs.temp_reporting_interval.name, @@ -632,13 +632,13 @@ async def write_attributes( endpoint_id=1, device_class=NumberDeviceClass.DURATION, entity_type=EntityType.CONFIG, - translation_key="temp_reporting_interval", - fallback_name="Temperature reporting interval", min_value=600, max_value=3600, step=600, multiplier=0.001, unit=UnitOfTime.SECONDS, + translation_key="temp_reporting_interval", + fallback_name="Temperature reporting interval", ) .number( attribute_name=AqaraFP300ManuCluster.AttributeDefs.temp_reporting_threshold.name, @@ -646,13 +646,13 @@ async def write_attributes( endpoint_id=1, device_class=NumberDeviceClass.TEMPERATURE, entity_type=EntityType.CONFIG, - translation_key="temp_reporting_threshold", - fallback_name="Temperature reporting threshold", min_value=0.2, max_value=3.0, step=0.1, multiplier=0.01, unit="°C", + translation_key="temp_reporting_threshold", + fallback_name="Temperature reporting threshold", ) .enum( attribute_name=AqaraFP300ManuCluster.AttributeDefs.temp_reporting_mode.name, @@ -669,13 +669,13 @@ async def write_attributes( endpoint_id=1, device_class=NumberDeviceClass.DURATION, entity_type=EntityType.CONFIG, - translation_key="humidity_reporting_interval", - fallback_name="Humidity reporting interval", min_value=600, max_value=3600, step=600, multiplier=0.001, unit=UnitOfTime.SECONDS, + translation_key="humidity_reporting_interval", + fallback_name="Humidity reporting interval", ) .number( attribute_name=AqaraFP300ManuCluster.AttributeDefs.humidity_reporting_threshold.name, @@ -683,13 +683,13 @@ async def write_attributes( endpoint_id=1, device_class=NumberDeviceClass.HUMIDITY, entity_type=EntityType.CONFIG, - translation_key="humidity_reporting_threshold", - fallback_name="Humidity reporting threshold", min_value=2.0, max_value=20.0, step=0.5, multiplier=0.01, unit="%", + translation_key="humidity_reporting_threshold", + fallback_name="Humidity reporting threshold", ) .enum( attribute_name=AqaraFP300ManuCluster.AttributeDefs.humidity_reporting_mode.name, @@ -716,13 +716,13 @@ async def write_attributes( endpoint_id=1, device_class=NumberDeviceClass.DURATION, entity_type=EntityType.CONFIG, - translation_key="light_sampling_period", - fallback_name="Light sampling period", min_value=0.5, max_value=3600.0, step=0.5, multiplier=0.001, unit=UnitOfTime.SECONDS, + translation_key="light_sampling_period", + fallback_name="Light sampling period", ) .number( attribute_name=AqaraFP300ManuCluster.AttributeDefs.light_reporting_interval.name, @@ -730,13 +730,13 @@ async def write_attributes( endpoint_id=1, device_class=NumberDeviceClass.DURATION, entity_type=EntityType.CONFIG, - translation_key="light_reporting_interval", - fallback_name="Light reporting interval", min_value=20, max_value=3600, step=20, multiplier=0.001, unit=UnitOfTime.SECONDS, + translation_key="light_reporting_interval", + fallback_name="Light reporting interval", ) .number( attribute_name=AqaraFP300ManuCluster.AttributeDefs.light_reporting_threshold.name, @@ -744,13 +744,13 @@ async def write_attributes( endpoint_id=1, # “percentage change” – omit device_class entity_type=EntityType.CONFIG, - translation_key="light_reporting_threshold", - fallback_name="Light reporting threshold", min_value=3.0, max_value=20.0, step=0.5, multiplier=0.01, unit="%", + translation_key="light_reporting_threshold", + fallback_name="Light reporting threshold", ) .enum( attribute_name=AqaraFP300ManuCluster.AttributeDefs.light_reporting_mode.name,