From 622b5cc1c8c9e8b1597d8c491264819406c4ace5 Mon Sep 17 00:00:00 2001 From: Josh K <28068117+iamjoshk@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:30:39 -0400 Subject: [PATCH 1/6] migrate feeder_acn001 to quirk v2 --- zhaquirks/xiaomi/aqara/feeder_acn001.py | 209 ++++++++++++++---------- 1 file changed, 126 insertions(+), 83 deletions(-) diff --git a/zhaquirks/xiaomi/aqara/feeder_acn001.py b/zhaquirks/xiaomi/aqara/feeder_acn001.py index cf2ef74feb..ca87c7c9ed 100644 --- a/zhaquirks/xiaomi/aqara/feeder_acn001.py +++ b/zhaquirks/xiaomi/aqara/feeder_acn001.py @@ -1,35 +1,37 @@ -"""Quirk for Aqara aqara.feeder.acn001.""" +"""Quirk V2 for Aqara aqara.feeder.acn001.""" from __future__ import annotations +import contextlib +import json import logging +import string + +from datetime import datetime from typing import Any, Final from zigpy import types -from zigpy.profiles import zgp, zha from zigpy.zcl import AttributeReportedEvent, AttributeUpdatedEvent, foundation -from zigpy.zcl.clusters.general import ( - Basic, - GreenPowerProxy, - Groups, - Identify, - OnOff, - Ota, - Scenes, - Time, -) +from zigpy.zcl.clusters.general import UTC, ZIGBEE_EPOCH, OnOff, Time from zigpy.zcl.foundation import BaseAttributeDefs, ZCLAttributeDef +from zhaquirks.builder import ( + BinarySensorDeviceClass, + EntityPlatform, + EntityType, + QuirkBuilder, + SensorStateClass, + UnitOfMass, +) from zhaquirks.const import ( - DEVICE_TYPE, - ENDPOINTS, - INPUT_CLUSTERS, - MANUFACTURER, - MODEL, - OUTPUT_CLUSTERS, - PROFILE_ID, + ATTRIBUTE_ID, + ATTRIBUTE_NAME, + COMMAND_ATTRIBUTE_UPDATED, + UNKNOWN, + VALUE, + ZHA_SEND_EVENT, ) -from zhaquirks.xiaomi import XiaomiAqaraE1Cluster, XiaomiCustomDevice +from zhaquirks.xiaomi import XiaomiAqaraE1Cluster # 32 bit signed integer values that are encoded in FEEDER_ATTR = 0xFFF1 FEEDING = 0x04150055 @@ -86,8 +88,9 @@ class FeedingSource(types.enum8): """Feeding source.""" + Schedule = 0x00 Feeder = 0x01 - Remote = 0x02 + HomeAssistant = 0x02 class FeedingMode(types.enum8): @@ -274,65 +277,105 @@ async def write_attributes( return await super().write_attributes(attrs, update_cache=False, **kwargs) -class AqaraFeederAcn001(XiaomiCustomDevice): - """Aqara aqara.feeder.acn001 custom device implementation.""" - - signature = { - MODEL: "aqara.feeder.acn001", - ENDPOINTS: { - 1: { - PROFILE_ID: zha.PROFILE_ID, - DEVICE_TYPE: zha.DeviceType.ON_OFF_OUTPUT, - INPUT_CLUSTERS: [ - Basic.cluster_id, - Identify.cluster_id, - Groups.cluster_id, - Scenes.cluster_id, - OnOff.cluster_id, - OppleCluster.cluster_id, - ], - OUTPUT_CLUSTERS: [ - Identify.cluster_id, - Ota.cluster_id, - ], - }, - 242: { - PROFILE_ID: zgp.PROFILE_ID, - DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC, - INPUT_CLUSTERS: [], - OUTPUT_CLUSTERS: [ - GreenPowerProxy.cluster_id, - ], - }, - }, - } - - replacement = { - MANUFACTURER: "Aqara", - ENDPOINTS: { - 1: { - PROFILE_ID: zha.PROFILE_ID, - DEVICE_TYPE: zha.DeviceType.ON_OFF_OUTPUT, - INPUT_CLUSTERS: [ - Basic.cluster_id, - Identify.cluster_id, - Groups.cluster_id, - Scenes.cluster_id, - OppleCluster, - Time.cluster_id, - ], - OUTPUT_CLUSTERS: [ - Identify.cluster_id, - Ota.cluster_id, - ], - }, - 242: { - PROFILE_ID: zgp.PROFILE_ID, - DEVICE_TYPE: zgp.DeviceType.PROXY_BASIC, - INPUT_CLUSTERS: [], - OUTPUT_CLUSTERS: [ - GreenPowerProxy.cluster_id, - ], - }, - }, - } +( + QuirkBuilder(None, "aqara.feeder.acn001") + .friendly_name(manufacturer="Aqara", model="aqara.feeder.acn001") + .removes(OnOff.cluster_id) + .replaces(OppleCluster) + .enum( + attribute_name=OppleCluster.AttributeDefs.last_feeding_source.name, + enum_class=FeedingSource, + cluster_id=OppleCluster.cluster_id, + entity_platform=EntityPlatform.SENSOR, + entity_type=EntityType.STANDARD, + unique_id_suffix="64704-last_feeding_source", + translation_key="last_feeding_source", + fallback_name="Last feeding source", + ) + .sensor( + attribute_name=OppleCluster.AttributeDefs.last_feeding_size.name, + cluster_id=OppleCluster.cluster_id, + unique_id_suffix="64704-last_feeding_size", + translation_key="last_feeding_size", + fallback_name="Last feeding size", + ) + .sensor( + attribute_name=OppleCluster.AttributeDefs.portions_dispensed.name, + cluster_id=OppleCluster.cluster_id, + state_class=SensorStateClass.TOTAL_INCREASING, + unique_id_suffix="64704-portions_dispensed", + translation_key="portions_dispensed_today", + fallback_name="Portions dispensed today", + ) + .sensor( + attribute_name=OppleCluster.AttributeDefs.weight_dispensed.name, + cluster_id=OppleCluster.cluster_id, + unit=UnitOfMass.GRAMS, + state_class=SensorStateClass.TOTAL_INCREASING, + unique_id_suffix="64704-weight_dispensed", + translation_key="weight_dispensed_today", + fallback_name="Weight dispensed today", + ) + .switch( + attribute_name=OppleCluster.AttributeDefs.disable_led_indicator.name, + cluster_id=OppleCluster.cluster_id, + force_inverted=True, + unique_id_suffix="64704-disable_led_indicator", + translation_key="led_indicator", + fallback_name="LED indicator", + ) + .switch( + attribute_name=OppleCluster.AttributeDefs.child_lock.name, + cluster_id=OppleCluster.cluster_id, + unique_id_suffix="64704-child_lock", + translation_key="child_lock", + fallback_name="Child lock", + ) + .enum( + attribute_name=OppleCluster.AttributeDefs.feeding_mode.name, + enum_class=FeedingMode, + cluster_id=OppleCluster.cluster_id, + unique_id_suffix="64704-feeding_mode", + translation_key="feeding_mode", + fallback_name="Feeding mode", + ) + .number( + attribute_name=OppleCluster.AttributeDefs.serving_size.name, + cluster_id=OppleCluster.cluster_id, + min_value=1, + max_value=10, + mode="box", + unique_id_suffix="64704-serving_size", + translation_key="serving_size", + fallback_name="Serving size", + ) + .number( + attribute_name=OppleCluster.AttributeDefs.portion_weight.name, + cluster_id=OppleCluster.cluster_id, + min_value=1, + max_value=100, + unit=UnitOfMass.GRAMS, + mode="box", + unique_id_suffix="64704-portion_weight", + translation_key="portion_weight", + fallback_name="Portion weight", + ) + .binary_sensor( + attribute_name=OppleCluster.AttributeDefs.error_detected.name, + cluster_id=OppleCluster.cluster_id, + entity_type=EntityType.STANDARD, + device_class=BinarySensorDeviceClass.PROBLEM, + unique_id_suffix="64704-error_detected", + fallback_name="Error detected", + ) + .write_attr_button( + attribute_name=OppleCluster.AttributeDefs.feeding.name, + attribute_value=1, + cluster_id=OppleCluster.cluster_id, + entity_type=EntityType.STANDARD, + unique_id_suffix="64704-feeding", + translation_key="feed", + fallback_name="Feed", + ) + .add_to_registry() +) From 770bd1bc7af84868dee1f9ade4cc50a4adbe881a Mon Sep 17 00:00:00 2001 From: josh <28068117+iamjoshk@users.noreply.github.com> Date: Tue, 1 Sep 2026 16:52:16 -0400 Subject: [PATCH 2/6] migrate Aqara feeder_acn001 to quirk v2 --- tests/test_xiaomi.py | 32 ++++++++++++++++++++----- zhaquirks/xiaomi/aqara/feeder_acn001.py | 15 +----------- 2 files changed, 27 insertions(+), 20 deletions(-) diff --git a/tests/test_xiaomi.py b/tests/test_xiaomi.py index 318dd393c7..d43ac4375d 100644 --- a/tests/test_xiaomi.py +++ b/tests/test_xiaomi.py @@ -90,9 +90,9 @@ ZCL_PORTIONS_DISPENSED, ZCL_SERVING_SIZE, ZCL_WEIGHT_DISPENSED, - AqaraFeederAcn001, FeedingMode, FeedingSource, + OppleCluster, ) from zhaquirks.xiaomi.aqara.light_acn import AqaraLightT1M, LumiPowerOnStateMode import zhaquirks.xiaomi.aqara.magnet_ac01 @@ -1074,11 +1074,20 @@ async def test_xiaomi_total_active_power_clear(zigpy_device_from_quirk): ], ) async def test_aqara_feeder_write_attrs( - zigpy_device_from_quirk, attribute, value, expected_bytes + zigpy_device_from_v2_quirk, attribute, value, expected_bytes ): """Test Aqara C1 pet feeder attr writing.""" - device = zigpy_device_from_quirk(AqaraFeederAcn001) + device = zigpy_device_from_v2_quirk( + "Aqara", + "aqara.feeder.acn001", + cluster_ids={ + 1: { + OnOff.cluster_id: ClusterType.Server, + OppleCluster.cluster_id: ClusterType.Server, + } + }, + ) opple_cluster = device.endpoints[1].opple_cluster opple_cluster._write_attributes = mock.AsyncMock( return_value=[ @@ -1117,7 +1126,9 @@ async def test_aqara_feeder_write_attrs( 3, [ mock.call(ZCL_LAST_FEEDING_SIZE, 3, mock.ANY), - mock.call(ZCL_LAST_FEEDING_SOURCE, FeedingSource.Remote, mock.ANY), + mock.call( + ZCL_LAST_FEEDING_SOURCE, FeedingSource.HomeAssistant, mock.ANY + ), mock.call( FEEDER_ATTR, b"\x00\x05\xd0\x04\x15\x02\xbc\x040203", mock.ANY ), @@ -1210,10 +1221,19 @@ async def test_aqara_feeder_write_attrs( ], ) async def test_aqara_feeder_attr_reports( - zigpy_device_from_quirk, bytes_received, call_count, calls + zigpy_device_from_v2_quirk, bytes_received, call_count, calls ): """Test Aqara C1 pet feeder attr reports and parsing.""" - device = zigpy_device_from_quirk(AqaraFeederAcn001) + device = zigpy_device_from_v2_quirk( + "Aqara", + "aqara.feeder.acn001", + cluster_ids={ + 1: { + OnOff.cluster_id: ClusterType.Server, + OppleCluster.cluster_id: ClusterType.Server, + } + }, + ) opple_cluster = device.endpoints[1].opple_cluster attribute_updates: list[tuple[int, Any]] = [] diff --git a/zhaquirks/xiaomi/aqara/feeder_acn001.py b/zhaquirks/xiaomi/aqara/feeder_acn001.py index ca87c7c9ed..16aff42d3b 100644 --- a/zhaquirks/xiaomi/aqara/feeder_acn001.py +++ b/zhaquirks/xiaomi/aqara/feeder_acn001.py @@ -2,17 +2,12 @@ from __future__ import annotations -import contextlib -import json import logging -import string - -from datetime import datetime from typing import Any, Final from zigpy import types from zigpy.zcl import AttributeReportedEvent, AttributeUpdatedEvent, foundation -from zigpy.zcl.clusters.general import UTC, ZIGBEE_EPOCH, OnOff, Time +from zigpy.zcl.clusters.general import OnOff from zigpy.zcl.foundation import BaseAttributeDefs, ZCLAttributeDef from zhaquirks.builder import ( @@ -23,14 +18,6 @@ SensorStateClass, UnitOfMass, ) -from zhaquirks.const import ( - ATTRIBUTE_ID, - ATTRIBUTE_NAME, - COMMAND_ATTRIBUTE_UPDATED, - UNKNOWN, - VALUE, - ZHA_SEND_EVENT, -) from zhaquirks.xiaomi import XiaomiAqaraE1Cluster # 32 bit signed integer values that are encoded in FEEDER_ATTR = 0xFFF1 From 2368891d8d0762d9deec68deddf069412b8a5417 Mon Sep 17 00:00:00 2001 From: josh <28068117+iamjoshk@users.noreply.github.com> Date: Tue, 1 Sep 2026 17:41:11 -0400 Subject: [PATCH 3/6] replace Time cluster in feeder_acn001 --- zhaquirks/xiaomi/aqara/feeder_acn001.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/zhaquirks/xiaomi/aqara/feeder_acn001.py b/zhaquirks/xiaomi/aqara/feeder_acn001.py index 16aff42d3b..8e342bffa9 100644 --- a/zhaquirks/xiaomi/aqara/feeder_acn001.py +++ b/zhaquirks/xiaomi/aqara/feeder_acn001.py @@ -7,7 +7,7 @@ from zigpy import types from zigpy.zcl import AttributeReportedEvent, AttributeUpdatedEvent, foundation -from zigpy.zcl.clusters.general import OnOff +from zigpy.zcl.clusters.general import OnOff, Time from zigpy.zcl.foundation import BaseAttributeDefs, ZCLAttributeDef from zhaquirks.builder import ( @@ -268,6 +268,7 @@ async def write_attributes( QuirkBuilder(None, "aqara.feeder.acn001") .friendly_name(manufacturer="Aqara", model="aqara.feeder.acn001") .removes(OnOff.cluster_id) + .replaces(Time) .replaces(OppleCluster) .enum( attribute_name=OppleCluster.AttributeDefs.last_feeding_source.name, From f0805e2a5d2aa2766201f506735b39636edf56a4 Mon Sep 17 00:00:00 2001 From: iamjoshk <28068117+iamjoshk@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:08:48 -0400 Subject: [PATCH 4/6] replacing unique_id_suffix magic numbers --- zhaquirks/xiaomi/aqara/feeder_acn001.py | 25 ++++++++++++++----------- 1 file changed, 14 insertions(+), 11 deletions(-) diff --git a/zhaquirks/xiaomi/aqara/feeder_acn001.py b/zhaquirks/xiaomi/aqara/feeder_acn001.py index 8e342bffa9..c669c2dbb2 100644 --- a/zhaquirks/xiaomi/aqara/feeder_acn001.py +++ b/zhaquirks/xiaomi/aqara/feeder_acn001.py @@ -266,6 +266,9 @@ async def write_attributes( ( QuirkBuilder(None, "aqara.feeder.acn001") + # device does not provide manufacturer during interview + # The v2 entities only take over cleanly once the + # native classes are removed (zigpy/zha#883) if the model string matches. .friendly_name(manufacturer="Aqara", model="aqara.feeder.acn001") .removes(OnOff.cluster_id) .replaces(Time) @@ -276,14 +279,14 @@ async def write_attributes( cluster_id=OppleCluster.cluster_id, entity_platform=EntityPlatform.SENSOR, entity_type=EntityType.STANDARD, - unique_id_suffix="64704-last_feeding_source", + unique_id_suffix=f"{OppleCluster.cluster_id}-last_feeding_source", translation_key="last_feeding_source", fallback_name="Last feeding source", ) .sensor( attribute_name=OppleCluster.AttributeDefs.last_feeding_size.name, cluster_id=OppleCluster.cluster_id, - unique_id_suffix="64704-last_feeding_size", + unique_id_suffix=f"{OppleCluster.cluster_id}-last_feeding_size", translation_key="last_feeding_size", fallback_name="Last feeding size", ) @@ -291,7 +294,7 @@ async def write_attributes( attribute_name=OppleCluster.AttributeDefs.portions_dispensed.name, cluster_id=OppleCluster.cluster_id, state_class=SensorStateClass.TOTAL_INCREASING, - unique_id_suffix="64704-portions_dispensed", + unique_id_suffix=f"{OppleCluster.cluster_id}-portions_dispensed", translation_key="portions_dispensed_today", fallback_name="Portions dispensed today", ) @@ -300,7 +303,7 @@ async def write_attributes( cluster_id=OppleCluster.cluster_id, unit=UnitOfMass.GRAMS, state_class=SensorStateClass.TOTAL_INCREASING, - unique_id_suffix="64704-weight_dispensed", + unique_id_suffix=f"{OppleCluster.cluster_id}-weight_dispensed", translation_key="weight_dispensed_today", fallback_name="Weight dispensed today", ) @@ -308,14 +311,14 @@ async def write_attributes( attribute_name=OppleCluster.AttributeDefs.disable_led_indicator.name, cluster_id=OppleCluster.cluster_id, force_inverted=True, - unique_id_suffix="64704-disable_led_indicator", + unique_id_suffix=f"{OppleCluster.cluster_id}-disable_led_indicator", translation_key="led_indicator", fallback_name="LED indicator", ) .switch( attribute_name=OppleCluster.AttributeDefs.child_lock.name, cluster_id=OppleCluster.cluster_id, - unique_id_suffix="64704-child_lock", + unique_id_suffix=f"{OppleCluster.cluster_id}-child_lock", translation_key="child_lock", fallback_name="Child lock", ) @@ -323,7 +326,7 @@ async def write_attributes( attribute_name=OppleCluster.AttributeDefs.feeding_mode.name, enum_class=FeedingMode, cluster_id=OppleCluster.cluster_id, - unique_id_suffix="64704-feeding_mode", + unique_id_suffix=f"{OppleCluster.cluster_id}-feeding_mode", translation_key="feeding_mode", fallback_name="Feeding mode", ) @@ -333,7 +336,7 @@ async def write_attributes( min_value=1, max_value=10, mode="box", - unique_id_suffix="64704-serving_size", + unique_id_suffix=f"{OppleCluster.cluster_id}-serving_size", translation_key="serving_size", fallback_name="Serving size", ) @@ -344,7 +347,7 @@ async def write_attributes( max_value=100, unit=UnitOfMass.GRAMS, mode="box", - unique_id_suffix="64704-portion_weight", + unique_id_suffix=f"{OppleCluster.cluster_id}-portion_weight", translation_key="portion_weight", fallback_name="Portion weight", ) @@ -353,7 +356,7 @@ async def write_attributes( cluster_id=OppleCluster.cluster_id, entity_type=EntityType.STANDARD, device_class=BinarySensorDeviceClass.PROBLEM, - unique_id_suffix="64704-error_detected", + unique_id_suffix=f"{OppleCluster.cluster_id}-error_detected", fallback_name="Error detected", ) .write_attr_button( @@ -361,7 +364,7 @@ async def write_attributes( attribute_value=1, cluster_id=OppleCluster.cluster_id, entity_type=EntityType.STANDARD, - unique_id_suffix="64704-feeding", + unique_id_suffix=f"{OppleCluster.cluster_id}-feeding", translation_key="feed", fallback_name="Feed", ) From 86c6ee409aa7677d30ce7bcb7eda589769e02d10 Mon Sep 17 00:00:00 2001 From: iamjoshk <28068117+iamjoshk@users.noreply.github.com> Date: Thu, 3 Sep 2026 10:42:20 -0400 Subject: [PATCH 5/6] validate aqara.feeder.acn001 resolves since no manufacturer is passed during interview --- tests/test_xiaomi.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_xiaomi.py b/tests/test_xiaomi.py index d43ac4375d..7b8f49a800 100644 --- a/tests/test_xiaomi.py +++ b/tests/test_xiaomi.py @@ -1079,7 +1079,7 @@ async def test_aqara_feeder_write_attrs( """Test Aqara C1 pet feeder attr writing.""" device = zigpy_device_from_v2_quirk( - "Aqara", + None, "aqara.feeder.acn001", cluster_ids={ 1: { @@ -1225,7 +1225,7 @@ async def test_aqara_feeder_attr_reports( ): """Test Aqara C1 pet feeder attr reports and parsing.""" device = zigpy_device_from_v2_quirk( - "Aqara", + None, "aqara.feeder.acn001", cluster_ids={ 1: { From 8764556588b6494682e2b39761619bbd80b5c5fc Mon Sep 17 00:00:00 2001 From: iamjoshk <28068117+iamjoshk@users.noreply.github.com> Date: Mon, 7 Sep 2026 13:22:18 -0400 Subject: [PATCH 6/6] adding portions units --- zhaquirks/xiaomi/aqara/feeder_acn001.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/zhaquirks/xiaomi/aqara/feeder_acn001.py b/zhaquirks/xiaomi/aqara/feeder_acn001.py index c669c2dbb2..dce7e3ba4b 100644 --- a/zhaquirks/xiaomi/aqara/feeder_acn001.py +++ b/zhaquirks/xiaomi/aqara/feeder_acn001.py @@ -286,6 +286,7 @@ async def write_attributes( .sensor( attribute_name=OppleCluster.AttributeDefs.last_feeding_size.name, cluster_id=OppleCluster.cluster_id, + unit="portions", unique_id_suffix=f"{OppleCluster.cluster_id}-last_feeding_size", translation_key="last_feeding_size", fallback_name="Last feeding size", @@ -294,6 +295,7 @@ async def write_attributes( attribute_name=OppleCluster.AttributeDefs.portions_dispensed.name, cluster_id=OppleCluster.cluster_id, state_class=SensorStateClass.TOTAL_INCREASING, + unit="portions", unique_id_suffix=f"{OppleCluster.cluster_id}-portions_dispensed", translation_key="portions_dispensed_today", fallback_name="Portions dispensed today", @@ -336,6 +338,7 @@ async def write_attributes( min_value=1, max_value=10, mode="box", + unit="portions", unique_id_suffix=f"{OppleCluster.cluster_id}-serving_size", translation_key="serving_size", fallback_name="Serving size",