ZHA quirk for Aqara Smart Door Lock S2 Pro (ZNMS13LM / lumi.lock.acn03)

Format: Python
 ( View Raw)
Date: Mon, 17 Aug 2026 at 13:21:42

"""ZHA quirk for Aqara Smart Door Lock S2 Pro (ZNMS13LM / lumi.lock.acn03).

Based on the Zigbee2MQTT converter for ZNMS13LM and the actual device signature
from a Z2M backup. The lock reports state and events via manufacturer-specific
attributes on the ``closuresDoorLock`` cluster (0x0101).

Events are emitted as ``zha_event`` and can be used in Home Assistant
automations. The standard ``lock_state`` attribute is updated so the HA lock
entity reflects the current lock/unlock state.
"""

from __future__ import annotations

import logging
from typing import Any, Final

from zigpy import types as t
from zigpy.profiles import zha
from zigpy.zcl.clusters.closures import DoorLock
from zigpy.zcl.clusters.general import (
    Basic,
    DeviceTemperature,
    Groups,
    Identify,
    Ota,
    PollControl,
    PowerConfiguration,
    Scenes,
    Time,
)
from zigpy.zcl.foundation import ZCLAttributeDef
from zigpy.zdo.types import NodeDescriptor

from zhaquirks import CustomCluster
from zhaquirks.const import (
    DEVICE_TYPE,
    ENDPOINTS,
    INPUT_CLUSTERS,
    MODELS_INFO,
    NODE_DESCRIPTOR,
    OUTPUT_CLUSTERS,
    PROFILE_ID,
    ZHA_SEND_EVENT,
)
from zhaquirks.xiaomi import (
    LUMI,
    BasicCluster,
    XIAOMI_NODE_DESC,
    XiaomiCustomDevice,
    XiaomiPowerConfiguration,
)

# Manufacturer-specific attributes on the DoorLock cluster used by lumi.lock.acn03.
# Values arrive as ASCII strings starting with "ML".
ATTR_FINGER_PW_SUCCESS = 0xFF10  # 65296
ATTR_FINGER_PW_FAIL_BELL = 0xFF11  # 65297
ATTR_PW_ADD_DEL = 0xFF01  # 65281
ATTR_SET_LANGUAGE = 0xFFE2  # 65522
ATTR_LOCK_STATUS = 0xFFF6  # 65526

# (action, state, reverse) for lock final status reports (attribute 0xFFE6).
LOCK_STATUS_COMMANDS = {
    "0341": ("reverse_lock_cancel", "UNLOCK", "UNLOCK"),
    "0351": ("reverse_lock_cancel", "LOCK", "UNLOCK"),
    "0245": ("reverse_lock", "UNLOCK", "LOCK"),
    "0255": ("reverse_lock", "LOCK", "LOCK"),
    "1355": ("reverse_lock", "LOCK", "LOCK"),
    "1351": ("locked", "LOCK", "UNLOCK"),
    "1451": ("locked", "LOCK", "UNLOCK"),
    "0640": ("lock_opened_outside", "UNLOCK", "UNLOCK"),
    "0600": ("lock_opened_outside", "UNLOCK", "UNLOCK"),
    "2300": ("lock_opened_inside", "UNLOCK", "UNLOCK"),
    "0540": ("lock_opened_inside", "UNLOCK", "UNLOCK"),
    "0440": ("lock_opened_inside", "UNLOCK", "UNLOCK"),
    "2400": ("door_closed", "UNLOCK", "UNLOCK"),
    "2401": ("door_closed", "UNLOCK", "UNLOCK"),
}

LOCK_STATE_MAP = {
    "LOCK": DoorLock.LockState.Locked,
    "UNLOCK": DoorLock.LockState.Unlocked,
}

_LOGGER = logging.getLogger(__name__)


class AqaraLockData(t.LVBytes):
    """Raw byte payload reported by Aqara manufacturer-specific attributes.

    The lock sends length-prefixed binary data using either the OctetString
    (0x41) or CharacterString (0x42) ZCL data type. zigpy decodes
    CharacterString as UTF-8 and keeps the original bytes in ``.raw``; this
    type restores those raw bytes so the quirk can decode the Aqara protocol.
    """

    def __new__(cls, value: Any) -> "AqaraLockData":
        if isinstance(value, (bytes, bytearray)):
            raw = bytes(value)
        elif isinstance(value, str):
            # zigpy's CharacterString keeps the raw bytes in `.raw`
            raw = getattr(value, "raw", None)
            if not isinstance(raw, bytes):
                raw = value.encode("latin-1", errors="replace")
        else:
            raw = bytes(value)
        return super().__new__(cls, raw)


def _to_hex(value: bytes | str | Any) -> str | None:
    """Convert an attribute value to a lowercase hex string.

    The device sends binary data, but zigpy may present it as ``bytes``, a
    ``CharacterString`` instance with a ``.raw`` bytes attribute, or a plain
    decoded ``str``. This helper recovers the original bytes in all cases.
    """
    if isinstance(value, (bytes, bytearray)):
        return bytes(value).hex()
    if isinstance(value, str):
        raw = getattr(value, "raw", None)
        if isinstance(raw, bytes):
            return raw.hex()
        return value.encode("latin-1", errors="replace").hex()
    return None


class AqaraDoorLockS2ProCluster(DoorLock):
    """DoorLock cluster for Aqara ZNMS13LM.

    Parses Aqara manufacturer-specific attributes and updates the standard
    ``lock_state`` attribute so Home Assistant sees the lock state. Every
    recognised event is also emitted as a ``zha_event``.
    """

    class AttributeDefs(DoorLock.AttributeDefs):
        """Manufacturer-specific attributes reported by the lock."""

        finger_password_success: Final = ZCLAttributeDef(
            id=ATTR_FINGER_PW_SUCCESS,
            type=AqaraLockData,
            is_manufacturer_specific=True,
        )
        finger_password_fail_bell: Final = ZCLAttributeDef(
            id=ATTR_FINGER_PW_FAIL_BELL,
            type=AqaraLockData,
            is_manufacturer_specific=True,
        )
        password_add_delete: Final = ZCLAttributeDef(
            id=ATTR_PW_ADD_DEL,
            type=AqaraLockData,
            is_manufacturer_specific=True,
        )
        set_language: Final = ZCLAttributeDef(
            id=ATTR_SET_LANGUAGE,
            type=AqaraLockData,
            is_manufacturer_specific=True,
        )
        lock_status: Final = ZCLAttributeDef(
            id=ATTR_LOCK_STATUS,
            type=AqaraLockData,
            is_manufacturer_specific=True,
        )

    def _update_attribute(self, attrid: int, value: t.TypeValue) -> None:
        """Intercept manufacturer reports and parse them."""
        hex_value = _to_hex(value)
        _LOGGER.debug(
            "[%s] DoorLock attribute report: attrid=0x%04x type=%s hex=%s",
            self.endpoint.device.ieee,
            attrid,
            type(value).__name__,
            hex_value,
        )

        if attrid == self.AttributeDefs.lock_status.id:
            self._parse_lock_status(value)
        elif attrid == self.AttributeDefs.finger_password_success.id:
            self._parse_finger_password_success(value)
        elif attrid == self.AttributeDefs.finger_password_fail_bell.id:
            self._parse_finger_password_fail_bell(value)
        elif attrid == self.AttributeDefs.password_add_delete.id:
            self._parse_password_add_delete(value)
        elif attrid == self.AttributeDefs.set_language.id:
            self._parse_set_language(value)

        # Always store the raw value as well, so it remains visible in the
        # cluster management UI for debugging.
        super()._update_attribute(attrid, value)

    def _update_lock_state(self, state: str) -> None:
        """Update the standard DoorLock ``lock_state`` attribute."""
        lock_state = LOCK_STATE_MAP.get(state)
        if lock_state is not None:
            super()._update_attribute(
                DoorLock.AttributeDefs.lock_state.id, lock_state
            )

    def _parse_lock_status(self, value: bytes | str) -> None:
        """Parse attribute 0xFFE6 (final lock status)."""
        data = _to_hex(value)
        if not data or len(data) < 10:
            return

        command = data[6:10]
        result = LOCK_STATUS_COMMANDS.get(command)
        if result is None:
            return

        action, state, reverse = result
        self._update_lock_state(state)
        self.listener_event(
            ZHA_SEND_EVENT,
            action,
            {"state": state, "reverse": reverse, "command": command},
        )

    def _parse_finger_password_success(self, value: bytes | str) -> None:
        """Parse attribute 0xFF10 (successful finger/password unlock)."""
        data = _to_hex(value)
        if not data or len(data) < 14:
            return

        command = data[6:8]
        user_type = data[8:9]
        user_id = data[12:14]

        base_action = {"01": "finger_open", "02": "password_open"}.get(command)
        if base_action is None:
            return

        suffix = "_admin" if user_type == "1" else "_user"
        action = f"{base_action}{suffix}_id{int(user_id, 16)}"
        action_user = int(user_id, 16)

        self.listener_event(
            ZHA_SEND_EVENT,
            action,
            {
                "action_user": action_user,
                "user_type": "admin" if user_type == "1" else "user",
            },
        )

    def _parse_finger_password_fail_bell(self, value: bytes | str) -> None:
        """Parse attribute 0xFF11 (failed auth or doorbell)."""
        data = _to_hex(value)
        if not data or len(data) < 14:
            return

        times = data[6:8]
        type_ = data[12:14]

        if type_ == "40":
            action = "finger_not_match"
            repeat = int(times, 16)
        elif type_ == "02":
            action = "password_not_match"
            repeat = int(times, 16)
        elif type_ == "00":
            action = "ring_bell"
            repeat = None
        else:
            return

        event_args: dict[str, int | None] = {}
        if repeat is not None:
            event_args["action_repeat"] = repeat

        self.listener_event(ZHA_SEND_EVENT, action, event_args)

    def _parse_password_add_delete(self, value: bytes | str) -> None:
        """Parse attribute 0xFF01 (password/finger add or delete)."""
        data = _to_hex(value)
        if not data or len(data) < 20:
            return

        command = data[18:20]
        user_id = data[12:14]

        action = {"01": "finger_add", "02": "finger_delete"}.get(command)
        if action is None:
            return

        action_user = int(user_id, 16)
        self.listener_event(
            ZHA_SEND_EVENT,
            action,
            {"action_user": action_user},
        )

    def _parse_set_language(self, value: bytes | str) -> None:
        """Parse attribute 0xFFE2 (language change)."""
        data = _to_hex(value)
        if not data or len(data) < 8:
            return

        lang_id = data[6:8]
        action = (
            "change_language_to_english"
            if lang_id == "2"
            else "change_language_to_chinese"
        )
        self.listener_event(ZHA_SEND_EVENT, action, {})


class AqaraLockS2Pro(XiaomiCustomDevice):
    """Aqara Smart Door Lock S2 Pro (ZNMS13LM / lumi.lock.acn03)."""

    signature = {
        MODELS_INFO: [(LUMI, "lumi.lock.acn03")],
        ENDPOINTS: {
            1: {
                PROFILE_ID: zha.PROFILE_ID,
                DEVICE_TYPE: zha.DeviceType.DOOR_LOCK,
                INPUT_CLUSTERS: [
                    Basic.cluster_id,
                    Identify.cluster_id,
                    PowerConfiguration.cluster_id,
                    DeviceTemperature.cluster_id,
                    Scenes.cluster_id,
                    Groups.cluster_id,
                    Time.cluster_id,
                    PollControl.cluster_id,
                    DoorLock.cluster_id,
                    Ota.cluster_id,
                ],
                OUTPUT_CLUSTERS: [
                    Ota.cluster_id,
                    Time.cluster_id,
                ],
            }
        },
    }

    replacement = {
        # The device reports itself as a Router, but it is a battery-powered
        # EndDevice. Force the correct node descriptor so ZHA uses proper
        # check-in intervals and keeps the device available.
        NODE_DESCRIPTOR: XIAOMI_NODE_DESC,
        ENDPOINTS: {
            1: {
                INPUT_CLUSTERS: [
                    BasicCluster,
                    XiaomiPowerConfiguration,
                    Identify.cluster_id,
                    DeviceTemperature.cluster_id,
                    Scenes.cluster_id,
                    Groups.cluster_id,
                    Time.cluster_id,
                    PollControl.cluster_id,
                    AqaraDoorLockS2ProCluster,
                    Ota.cluster_id,
                ],
                OUTPUT_CLUSTERS: [
                    Ota.cluster_id,
                    Time.cluster_id,
                ],
            }
        },
    }