diff --git a/tests/test_xiaomi.py b/tests/test_xiaomi.py index 318dd393c7..2cb266f891 100644 --- a/tests/test_xiaomi.py +++ b/tests/test_xiaomi.py @@ -1,6 +1,8 @@ """Tests for xiaomi.""" import asyncio +from datetime import datetime +import json import logging import math from typing import Any @@ -19,6 +21,8 @@ ) from zigpy.zcl.clusters.closures import WindowCovering from zigpy.zcl.clusters.general import ( + UTC, + ZIGBEE_EPOCH, AnalogInput, AnalogOutput, DeviceTemperature, @@ -88,11 +92,13 @@ ZCL_LAST_FEEDING_SOURCE, ZCL_PORTION_WEIGHT, ZCL_PORTIONS_DISPENSED, + ZCL_SCHEDULE, ZCL_SERVING_SIZE, ZCL_WEIGHT_DISPENSED, - AqaraFeederAcn001, + FeederTimeCluster, FeedingMode, FeedingSource, + OppleCluster, ) from zhaquirks.xiaomi.aqara.light_acn import AqaraLightT1M, LumiPowerOnStateMode import zhaquirks.xiaomi.aqara.magnet_ac01 @@ -1074,11 +1080,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=[ @@ -1101,6 +1116,196 @@ async def test_aqara_feeder_write_attrs( assert call_args.kwargs["manufacturer"] == 0x115F +async def test_aqara_feeder_write_schedule(zigpy_device_from_v2_quirk): + """Verify that schedule attribute is encoded and sent correctly.""" + + 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=[ + [foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)] + ] + ) + + schedule_list = [ + {"days": "everyday", "hour": 11, "minute": 0, "portions": 3}, + {"days": "everyday", "hour": 16, "minute": 0, "portions": 2}, + ] + schedule_str = json.dumps(schedule_list) + + # attach listener so we can later verify zha_event + zha_listener = mock.MagicMock() + opple_cluster.add_listener(zha_listener) + + await opple_cluster.write_attributes({"schedule": schedule_str}) + + expected_bytes = opple_cluster._encode_schedule(schedule_str) + assert expected_bytes is not None + + expected_attr_def = opple_cluster.find_attribute(FEEDER_ATTR) + expected = foundation.Attribute(FEEDER_ATTR, 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) + + assert len(opple_cluster._write_attributes.mock_calls) == 1 + call_args = opple_cluster._write_attributes.mock_calls[0] + assert call_args.args[0] == [expected] + assert call_args.kwargs["manufacturer"] == 0x115F + + found = False + for call in zha_listener.zha_send_event.mock_calls: + if len(call.args) > 1 and isinstance(call.args[1], dict): + data = call.args[1] + attr_id = None + if "attribute_id" in data: + attr_id = data.get("attribute_id") + elif "args" in data: + attr_id = data["args"].get("attribute_id") + if attr_id == ZCL_SCHEDULE: + found = True + break + assert found, "write_attributes did not fire schedule zha_event" + + +async def test_aqara_feeder_schedule_sensor(zigpy_device_from_v2_quirk): + """Test Aqara C1 pet feeder schedule sensor state.""" + + 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 + + device.packet_received( + t.ZigbeePacket( + profile_id=zha.PROFILE_ID, + cluster_id=opple_cluster.cluster_id, + src_ep=opple_cluster.endpoint.endpoint_id, + dst_ep=opple_cluster.endpoint.endpoint_id, + data=t.SerializableBytes( + b"\x1c_\x11}\n\xf1\xffA(\x00\x05\x15\x08\x00\x08\xc8 " + b"7F09000100,7F0D000100,7F13000100" + ), + ) + ) + + assert opple_cluster[ZCL_SCHEDULE] == ( + '[{"days":"everyday","hour":9,"minute":0,"portions":1},' + '{"days":"everyday","hour":13,"minute":0,"portions":1},' + '{"days":"everyday","hour":19,"minute":0,"portions":1}]' + ) + + +async def test_aqara_feeder_malformed_schedule_is_ignored( + zigpy_device_from_v2_quirk, +): + """Test Aqara C1 pet feeder ignores malformed schedule payloads.""" + + 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 + schedule = '[{"days":"everyday","hour":9,"minute":0,"portions":1}]' + opple_cluster._update_attribute(ZCL_SCHEDULE, schedule) + + opple_cluster._parse_schedule(b"not-a-valid-schedule") + + assert opple_cluster[ZCL_SCHEDULE] == schedule + + +async def test_aqara_feeder_time_response(zigpy_device_from_v2_quirk): + """The custom Time cluster should return local time for this device.""" + + device = zigpy_device_from_v2_quirk( + "Aqara", + "aqara.feeder.acn001", + cluster_ids={ + 1: { + OnOff.cluster_id: ClusterType.Server, + OppleCluster.cluster_id: ClusterType.Server, + } + }, + ) + # accessing the time cluster via the endpoint attribute + time_cluster = device.endpoints[1].time + + assert isinstance(time_cluster, FeederTimeCluster) + + # call the handler and compare with local-time calculation + retval = time_cluster.handle_read_attribute_time() + + now = datetime.now(UTC) + tz_offset = datetime.now().astimezone().utcoffset() + assert tz_offset is not None + expected = int((now + tz_offset - ZIGBEE_EPOCH).total_seconds()) + assert abs(retval - expected) < 3 + + +# helper for constructing a fake attribute report event +def _make_string_event(device, cluster, attr_id, value): + + return AttributeReportedEvent( + device_ieee=device.ieee, + endpoint_id=cluster.endpoint.endpoint_id, + cluster_type=ClusterType.Server, + cluster_id=cluster.cluster_id, + attribute_name="feeder_attr", + attribute_id=attr_id, + manufacturer_code=0x115F, + raw_value=None, + value=value, + ) + + +async def test_aqara_feeder_string_event_is_ignored(zigpy_device_from_v2_quirk): + """Providing a string value should not crash the parser.""" + + device = zigpy_device_from_v2_quirk( + "Aqara", + "aqara.feeder.acn001", + cluster_ids={ + 1: { + OnOff.cluster_id: ClusterType.Server, + OppleCluster.cluster_id: ClusterType.Server, + } + }, + ) + opple = device.endpoints[1].opple_cluster + + # populate schedule cache so we can verify it is untouched + await opple.write_attributes({"schedule": json.dumps([{"hour": 1}])}) + before = opple._attr_cache.get(ZCL_SCHEDULE) + + evt = _make_string_event(device, opple, FEEDER_ATTR, "deadbeef") + opple._handle_attribute_event(evt) + + assert opple._attr_cache.get(ZCL_SCHEDULE) == before + + @pytest.mark.parametrize( "bytes_received, call_count, calls", [ @@ -1117,7 +1322,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,13 +1417,25 @@ 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 + # listen for attributes and fired zha events attribute_updates: list[tuple[int, Any]] = [] + zha_listener = mock.MagicMock() + opple_cluster.add_listener(zha_listener) def on_attribute_event(event: AttributeReportedEvent | AttributeUpdatedEvent): attribute_updates.append((event.attribute_id, event.value)) @@ -1234,6 +1453,13 @@ def on_attribute_event(event: AttributeReportedEvent | AttributeUpdatedEvent): ) ) + for call in zha_listener.zha_send_event.mock_calls: + if len(call.args) > 1 and isinstance(call.args[1], dict): + val = call.args[1].get(VALUE) + assert not isinstance(val, (bytes, t.LVBytes)), ( + "zha_send_event value must not be raw bytes" + ) + # Check the expected attribute updates occurred expected_updates = [(c.args[0], c.args[1]) for c in calls] actual_updates = attribute_updates[-call_count:] diff --git a/zhaquirks/xiaomi/aqara/feeder_acn001.py b/zhaquirks/xiaomi/aqara/feeder_acn001.py index cf2ef74feb..e283634e38 100644 --- a/zhaquirks/xiaomi/aqara/feeder_acn001.py +++ b/zhaquirks/xiaomi/aqara/feeder_acn001.py @@ -2,34 +2,36 @@ from __future__ import annotations +import contextlib +from datetime import datetime +import json import logging +import string 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 import EventableCluster, LocalDataCluster +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 @@ -47,6 +49,23 @@ FEEDER_ATTR = 0xFFF1 FEEDER_ATTR_NAME = "feeder_attr" +# Day mapping used for schedule encoding/decoding +DAYS_MAP = { + "everyday": 0x7F, + "workdays": 0x1F, + "weekend": 0x60, + "mon": 0x01, + "tue": 0x02, + "wed": 0x04, + "thu": 0x08, + "fri": 0x10, + "sat": 0x20, + "sun": 0x40, +} + +# reverse map for parsing replies +DAYS_REVERSE_MAP = {v: k for k, v in DAYS_MAP.items()} + # Fake ZCL attribute ids we can use for entities for the opple cluster ZCL_FEEDING = 0x1388 ZCL_LAST_FEEDING_SOURCE = 0x1389 @@ -59,6 +78,7 @@ ZCL_FEEDING_MODE = 0x1390 ZCL_SERVING_SIZE = 0x1391 ZCL_PORTION_WEIGHT = 0x1392 +ZCL_SCHEDULE = 0x1393 AQARA_TO_ZCL: dict[int, int] = { FEEDING: ZCL_FEEDING, @@ -78,16 +98,40 @@ ZCL_SERVING_SIZE: SERVING_SIZE, ZCL_PORTION_WEIGHT: PORTION_WEIGHT, ZCL_ERROR_DETECTED: ERROR_DETECTED, + ZCL_SCHEDULE: SCHEDULING_STRING, } LOGGER = logging.getLogger(__name__) +class FeederTimeCluster(LocalDataCluster, Time): + """Use local time instead of UTC. + + The feeder polls the time cluster during interview and expects the + value returned in the time attribute to already be adjusted to the + user's local timezone. This benefits the onboard schedule, as well + as the weight and portions distributed per day, and the LED indicator. + """ + + def handle_read_attribute_time(self) -> types.UTCTime: + """Return local-adjusted time value for the *time* attribute.""" + now = datetime.now(UTC) + tz_offset = datetime.now().astimezone().utcoffset() + assert tz_offset is not None + return types.UTCTime((now + tz_offset - ZIGBEE_EPOCH).total_seconds()) + + class FeedingSource(types.enum8): - """Feeding source.""" + """Feeding source. + Schedule = onboard schedule, + Feeder = feed button on feeder, + HomeAssistant = feed button (including automations) in HA + """ + + Schedule = 0x00 Feeder = 0x01 - Remote = 0x02 + HomeAssistant = 0x02 class FeedingMode(types.enum8): @@ -97,7 +141,7 @@ class FeedingMode(types.enum8): Schedule = 0x01 -class OppleCluster(XiaomiAqaraE1Cluster): +class OppleCluster(XiaomiAqaraE1Cluster, EventableCluster): """Opple cluster.""" class AttributeDefs(BaseAttributeDefs): @@ -136,6 +180,11 @@ class AttributeDefs(BaseAttributeDefs): portion_weight: Final = ZCLAttributeDef( id=ZCL_PORTION_WEIGHT, type=types.uint8_t, manufacturer_code=0x115F ) + schedule: Final = ZCLAttributeDef( + id=ZCL_SCHEDULE, + type=types.CharacterString, + manufacturer_code=0x115F, + ) feeder_attr: Final = ZCLAttributeDef( id=FEEDER_ATTR, type=types.LVBytes, manufacturer_code=0x115F ) @@ -161,6 +210,8 @@ def __init__(self, *args, **kwargs): self._update_attribute(ZCL_PORTIONS_DISPENSED, 0) if ZCL_WEIGHT_DISPENSED not in self._attr_cache: self._update_attribute(ZCL_WEIGHT_DISPENSED, 0) + if ZCL_SCHEDULE not in self._attr_cache: + self._update_attribute(ZCL_SCHEDULE, "[]") # Subscribe to attribute events to parse feeder_attr self.on_event(AttributeReportedEvent.event_type, self._handle_attribute_event) @@ -170,19 +221,82 @@ def _handle_attribute_event( self, event: AttributeReportedEvent | AttributeUpdatedEvent ) -> None: """Handle attribute report/update event to parse feeder attribute.""" - if event.attribute_id == FEEDER_ATTR: - self._parse_feeder_attribute(event.value) + if event.attribute_id != FEEDER_ATTR: + return + raw: bytes | None = None + if isinstance(event.value, (bytes, types.LVBytes)): + with contextlib.suppress(Exception): + raw = bytes(event.value) + + if raw is not None: + self._parse_feeder_attribute(raw) + + def _handle_attribute_report( + self, event: AttributeReportedEvent | AttributeUpdatedEvent + ) -> None: + """Sanitize values when EventableCluster creates zha_event.""" + LOGGER.debug( + "OppleCluster._handle_attribute_report called: attr_id=%s value=%r type=%s", + event.attribute_id, + event.value, + type(event.value), + ) + + out_value = event.value + if isinstance(out_value, (bytes, types.LVBytes)): + try: + out_value = bytes(out_value).hex() + LOGGER.debug( + "OppleCluster._handle_attribute_report sanitized value -> %r", + out_value, + ) + except Exception as exc: + LOGGER.exception("Failed sanitizing event value: %s", exc) + + self.listener_event( + ZHA_SEND_EVENT, + COMMAND_ATTRIBUTE_UPDATED, + { + ATTRIBUTE_ID: event.attribute_id, + ATTRIBUTE_NAME: event.attribute_name or UNKNOWN, + VALUE: out_value, + }, + ) def _update_feeder_attribute(self, attrid: int, value: Any) -> None: zcl_attr_def = self.attributes.get(AQARA_TO_ZCL[attrid]) self._update_attribute(zcl_attr_def.id, zcl_attr_def.type.deserialize(value)[0]) - def _parse_feeder_attribute(self, value: bytes) -> None: + def _parse_feeder_attribute(self, value: Any) -> None: """Parse the feeder attribute.""" - attribute, _ = types.int32s_be.deserialize(value[3:7]) + if isinstance(value, str): + try: + value = bytes.fromhex(value) + except ValueError: + return + + try: + value = bytes(value) + except Exception: + return + + if len(value) < 8: + return + + try: + attribute, _ = types.int32s_be.deserialize(value[3:7]) + except ValueError: + return + LOGGER.debug("OppleCluster._parse_feeder_attribute: attribute: %s", attribute) - length, _ = types.uint8_t.deserialize(value[7:8]) + try: + length, _ = types.uint8_t.deserialize(value[7:8]) + except ValueError: + return + LOGGER.debug("OppleCluster._parse_feeder_attribute: length: %s", length) + if len(value) < 8 + length: + return attribute_value = value[8 : (length + 8)] LOGGER.debug("OppleCluster._parse_feeder_attribute: value: %s", attribute_value) @@ -190,12 +304,28 @@ def _parse_feeder_attribute(self, value: bytes) -> None: self._update_feeder_attribute(attribute, attribute_value) elif attribute == FEEDING_REPORT: attr_str = attribute_value.decode("utf-8") - feeding_source = attr_str[0:2] - feeding_size = attr_str[3:4] - self._update_attribute( - ZCL_LAST_FEEDING_SOURCE, FeedingSource(feeding_source) + feeding_source = FeedingSource(int(attr_str[0:2], 16)) + feeding_size = int(attr_str[2:4], 16) + self._update_attribute(ZCL_LAST_FEEDING_SOURCE, feeding_source) + self._update_attribute(ZCL_LAST_FEEDING_SIZE, feeding_size) + self.listener_event( + ZHA_SEND_EVENT, + COMMAND_ATTRIBUTE_UPDATED, + { + ATTRIBUTE_ID: ZCL_LAST_FEEDING_SOURCE, + ATTRIBUTE_NAME: "last_feeding_source", + VALUE: feeding_source.name, + }, + ) + self.listener_event( + ZHA_SEND_EVENT, + COMMAND_ATTRIBUTE_UPDATED, + { + ATTRIBUTE_ID: ZCL_LAST_FEEDING_SIZE, + ATTRIBUTE_NAME: "last_feeding_size", + VALUE: feeding_size, + }, ) - self._update_attribute(ZCL_LAST_FEEDING_SIZE, int(feeding_size, base=16)) elif attribute == PORTIONS_DISPENSED: portions_per_day, _ = types.uint16_t_be.deserialize(attribute_value) self._update_attribute(ZCL_PORTIONS_DISPENSED, portions_per_day) @@ -203,11 +333,7 @@ def _parse_feeder_attribute(self, value: bytes) -> None: weight_per_day, _ = types.uint32_t_be.deserialize(attribute_value) self._update_attribute(ZCL_WEIGHT_DISPENSED, weight_per_day) elif attribute == SCHEDULING_STRING: - LOGGER.debug( - "OppleCluster._parse_feeder_attribute: schedule not currently handled: attribute: %s value: %s", - attribute, - attribute_value, - ) + self._parse_schedule(attribute_value) else: LOGGER.debug( "OppleCluster._parse_feeder_attribute: unhandled attribute: %s value: %s", @@ -227,7 +353,6 @@ def _build_feeder_attribute( ) self._send_sequence = ((self._send_sequence or 0) + 1) % 256 val = bytes([0x00, 0x02, self._send_sequence]) - self._send_sequence += 1 val += types.int32s_be(attribute_id).serialize() if length is not None and value is not None: val += types.uint8_t(length).serialize() @@ -248,12 +373,183 @@ def _build_feeder_attribute( ) return FEEDER_ATTR_NAME, val + def _parse_schedule(self, value: bytes) -> None: + """Parse schedule data from the feeder and update ZCL_SCHEDULE.""" + try: + schedule_value = value.decode("utf-8", errors="ignore").strip() + idx = 0 + while ( + idx < len(schedule_value) + and schedule_value[idx] not in string.hexdigits + ): + idx += 1 + schedule_value = schedule_value[idx:] + + if not schedule_value: + self._update_attribute(ZCL_SCHEDULE, "[]") + return + + schedules = [] + schedule_parts = ( + schedule_value.split(",") + if "," in schedule_value + else schedule_value.split() + ) + for part in schedule_parts: + part = part.strip() + if len(part) >= 8: + try: + days_mask = int(part[0:2], 16) + hour = int(part[2:4], 16) + minute = int(part[4:6], 16) + portions = int(part[6:8], 16) + day_name = DAYS_REVERSE_MAP.get(days_mask) + if day_name and hour < 24 and minute < 60 and portions > 0: + schedules.append( + { + "days": day_name, + "hour": hour, + "minute": minute, + "portions": portions, + } + ) + except ValueError: + continue + if not schedules: + LOGGER.warning( + "OppleCluster._parse_schedule: invalid schedule payload: %s", + value, + ) + return + self._update_attribute( + ZCL_SCHEDULE, json.dumps(schedules, separators=(",", ":")) + ) + except (UnicodeDecodeError, json.JSONDecodeError): + pass + + def _encode_schedule(self, schedule_input: Any) -> bytes | None: + """Encode a JSON schedule into the feeder string format.""" + try: + if isinstance(schedule_input, str): + schedule_list = json.loads(schedule_input) + elif isinstance(schedule_input, list): + schedule_list = schedule_input + else: + LOGGER.error( + "[0x%04X] Invalid schedule format", self._endpoint.device.nwk + ) + return None + + if not isinstance(schedule_list, list): + LOGGER.error( + "[0x%04X] Invalid schedule format", self._endpoint.device.nwk + ) + return None + if len(schedule_list) > 5: + LOGGER.error( + "[0x%04X] Too many schedule entries (max 5)", + self._endpoint.device.nwk, + ) + return None + + parts = [] + for schedule in schedule_list: + if not isinstance(schedule, dict): + LOGGER.error( + "[0x%04X] Invalid schedule entry format", + self._endpoint.device.nwk, + ) + return None + days = schedule.get("days", "everyday") + hour = schedule.get("hour") + minute = schedule.get("minute") + portions = schedule.get("portions", 1) + if hour is None or minute is None: + LOGGER.error( + "[0x%04X] Invalid schedule values: missing hour or minute", + self._endpoint.device.nwk, + ) + return None + if ( + not (0 <= hour <= 23) + or not (0 <= minute <= 59) + or not (1 <= portions <= 5) + ): + LOGGER.error( + "[0x%04X] Invalid schedule values: hour=%s, minute=%s, portions=%s", + self._endpoint.device.nwk, + hour, + minute, + portions, + ) + return None + days_mask = DAYS_MAP.get(days, 0x7F) + parts.append(f"{days_mask:02X}{hour:02X}{minute:02X}{portions:02X}00") + data = ",".join(parts) + header = bytes([0x05, 0x15, 0x08, 0x00, 0x08, 0xC8]) + return header + b" " + data.encode() + except (json.JSONDecodeError, KeyError, TypeError, ValueError) as e: + LOGGER.error( + "[0x%04X] Failed to encode schedule: %s", + self._endpoint.device.nwk, + str(e), + ) + return None + async def write_attributes( self, attributes: dict[str | int | foundation.ZCLAttributeDef, Any], **kwargs, ) -> list[list[foundation.WriteAttributesStatusRecord]]: """Write attributes to device with internal 'attributes' validation.""" + if any( + (attr == ZCL_SCHEDULE or (isinstance(attr, str) and attr == "schedule")) + for attr in attributes + ): + schedule_val = str( + getattr( + attributes.get(ZCL_SCHEDULE, attributes.get("schedule")), + "value", + attributes.get(ZCL_SCHEDULE, attributes.get("schedule")), + ) + ) + if schedule_val.strip(): + packet = self._encode_schedule(schedule_val) + if packet: + self._update_attribute(ZCL_SCHEDULE, schedule_val) + self.listener_event( + ZHA_SEND_EVENT, + COMMAND_ATTRIBUTE_UPDATED, + { + ATTRIBUTE_ID: ZCL_SCHEDULE, + ATTRIBUTE_NAME: "schedule", + VALUE: schedule_val, + }, + ) + tv = foundation.TypeValue() + tv.type = 0x41 + tv.value = types.LongOctetString(packet) + return await self._write_attributes( + [foundation.Attribute(FEEDER_ATTR, tv)], + manufacturer=0x115F, + ) + else: + LOGGER.error( + "[0x%04X] Failed to encode schedule", self._endpoint.device.nwk + ) + return [ + [ + foundation.WriteAttributesStatusRecord( + foundation.Status.FAILURE + ) + ] + ] + else: + self._update_attribute(ZCL_SCHEDULE, "[]") + return [ + [foundation.WriteAttributesStatusRecord(foundation.Status.SUCCESS)] + ] + attrs = {} for attr, value in attributes.items(): attr_def = self.find_attribute(attr) @@ -262,77 +558,124 @@ async def write_attributes( attribute, cooked_value = self._build_feeder_attribute( ZCL_TO_AQARA[attr_id], value, - 4 if attr_def.name in ["serving_size", "portion_weight"] else 1, + 4 if attr_def.name in ("serving_size", "portion_weight") else 1, ) attrs[attribute] = cooked_value else: attrs[attr] = value LOGGER.debug("OppleCluster.write_attributes: %s", attrs) - # Skip attr cache because of the encoding from Xiaomi and - # the attributes are reported back by the device - kwargs.pop("update_cache", None) # To not break when this is passed already + kwargs.pop("update_cache", None) 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) + .adds(FeederTimeCluster) + .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", + ) + .sensor( + attribute_name=OppleCluster.AttributeDefs.schedule.name, + cluster_id=OppleCluster.cluster_id, + entity_type=EntityType.DIAGNOSTIC, + unique_id_suffix="64704-schedule", + translation_key="schedule", + fallback_name="Schedule", + ) + .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() +)