core/homeassistant/components/matter/lock.py

265 lines
9.6 KiB
Python

"""Matter lock."""
from __future__ import annotations
import asyncio
from typing import Any
from chip.clusters import Objects as clusters
from matter_server.common.models import EventType, MatterNodeEvent
from homeassistant.components.lock import (
LockEntity,
LockEntityDescription,
LockEntityFeature,
)
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_CODE, Platform
from homeassistant.core import HomeAssistant, callback
from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback
from .const import LOGGER
from .entity import MatterEntity
from .helpers import get_matter
from .models import MatterDiscoverySchema
DOOR_LOCK_OPERATION_SOURCE = {
# mapping from operation source id's to textual representation
0: "Unspecified",
1: "Manual", # [Optional]
2: "Proprietary Remote", # [Optional]
3: "Keypad", # [Optional]
4: "Auto", # [Optional]
5: "Button", # [Optional]
6: "Schedule", # [HDSCH]
7: "Remote", # [M]
8: "RFID", # [RID]
9: "Biometric", # [USR]
10: "Aliro", # [Aliro]
}
DoorLockFeature = clusters.DoorLock.Bitmaps.Feature
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
async_add_entities: AddConfigEntryEntitiesCallback,
) -> None:
"""Set up Matter lock from Config Entry."""
matter = get_matter(hass)
matter.register_platform_handler(Platform.LOCK, async_add_entities)
class MatterLock(MatterEntity, LockEntity):
"""Representation of a Matter lock."""
_feature_map: int | None = None
_optimistic_timer: asyncio.TimerHandle | None = None
_platform_translation_key = "lock"
_attr_changed_by = "Unknown"
async def async_added_to_hass(self) -> None:
"""Subscribe to events."""
await super().async_added_to_hass()
# subscribe to NodeEvent events
self._unsubscribes.append(
self.matter_client.subscribe_events(
callback=self._on_matter_node_event,
event_filter=EventType.NODE_EVENT,
node_filter=self._endpoint.node.node_id,
)
)
@callback
def _on_matter_node_event(
self,
event: EventType,
node_event: MatterNodeEvent,
) -> None:
"""Call on NodeEvent."""
if (node_event.endpoint_id != self._endpoint.endpoint_id) or (
node_event.cluster_id != clusters.DoorLock.id
):
return
LOGGER.debug(
"Received node_event: event type %s, event id %s for %s with data %s",
event,
node_event.event_id,
self.entity_id,
node_event.data,
)
# handle the DoorLock events
node_event_data: dict[str, int] = node_event.data or {}
match node_event.event_id:
case (
clusters.DoorLock.Events.LockOperation.event_id
): # Lock cluster event 2
# update the changed_by attribute to indicate lock operation source
operation_source: int = node_event_data.get("operationSource", -1)
self._attr_changed_by = DOOR_LOCK_OPERATION_SOURCE.get(
operation_source, "Unknown"
)
self.async_write_ha_state()
@property
def code_format(self) -> str | None:
"""Regex for code format or None if no code is required."""
if self.get_matter_attribute_value(
clusters.DoorLock.Attributes.RequirePINforRemoteOperation
):
min_pincode_length = int(
self.get_matter_attribute_value(
clusters.DoorLock.Attributes.MinPINCodeLength
)
)
max_pincode_length = int(
self.get_matter_attribute_value(
clusters.DoorLock.Attributes.MaxPINCodeLength
)
)
return f"^\\d{{{min_pincode_length},{max_pincode_length}}}$"
return None
async def async_lock(self, **kwargs: Any) -> None:
"""Lock the lock with pin if needed."""
if not self._attr_is_locked:
# optimistically signal locking to state machine
self._attr_is_locking = True
self.async_write_ha_state()
# the lock should acknowledge the command with an attribute update
# but bad things may happen, so guard against it with a timer.
self._optimistic_timer = self.hass.loop.call_later(
30, self._reset_optimistic_state
)
code: str | None = kwargs.get(ATTR_CODE)
code_bytes = code.encode() if code else None
await self.send_device_command(
command=clusters.DoorLock.Commands.LockDoor(code_bytes),
timed_request_timeout_ms=1000,
)
async def async_unlock(self, **kwargs: Any) -> None:
"""Unlock the lock with pin if needed."""
if self._attr_is_locked:
# optimistically signal unlocking to state machine
self._attr_is_unlocking = True
self.async_write_ha_state()
# the lock should acknowledge the command with an attribute update
# but bad things may happen, so guard against it with a timer.
self._optimistic_timer = self.hass.loop.call_later(
30, self._reset_optimistic_state
)
code: str | None = kwargs.get(ATTR_CODE)
code_bytes = code.encode() if code else None
if self._attr_supported_features & LockEntityFeature.OPEN:
# if the lock reports it has separate unbolt support,
# the unlock command should unbolt only on the unlock command
# and unlatch on the HA 'open' command.
await self.send_device_command(
command=clusters.DoorLock.Commands.UnboltDoor(code_bytes),
timed_request_timeout_ms=1000,
)
else:
await self.send_device_command(
command=clusters.DoorLock.Commands.UnlockDoor(code_bytes),
timed_request_timeout_ms=1000,
)
async def async_open(self, **kwargs: Any) -> None:
"""Open the door latch."""
# optimistically signal opening to state machine
self._attr_is_opening = True
self.async_write_ha_state()
# the lock should acknowledge the command with an attribute update
# but bad things may happen, so guard against it with a timer.
self._optimistic_timer = self.hass.loop.call_later(
30 if self._attr_is_locked else 5, self._reset_optimistic_state
)
code: str | None = kwargs.get(ATTR_CODE)
code_bytes = code.encode() if code else None
await self.send_device_command(
command=clusters.DoorLock.Commands.UnlockDoor(code_bytes),
timed_request_timeout_ms=1000,
)
@callback
def _update_from_device(self) -> None:
"""Update the entity from the device."""
# always calculate the features as they can dynamically change
self._calculate_features()
lock_state = self.get_matter_attribute_value(
clusters.DoorLock.Attributes.LockState
)
# always reset the optimisically (un)locking state on state update
self._reset_optimistic_state(write_state=False)
LOGGER.debug("Lock state: %s for %s", lock_state, self.entity_id)
if lock_state == clusters.DoorLock.Enums.DlLockState.kUnlatched:
self._attr_is_locked = False
self._attr_is_open = True
elif lock_state == clusters.DoorLock.Enums.DlLockState.kLocked:
self._attr_is_locked = True
self._attr_is_open = False
elif lock_state in (
clusters.DoorLock.Enums.DlLockState.kUnlocked,
clusters.DoorLock.Enums.DlLockState.kNotFullyLocked,
):
self._attr_is_locked = False
self._attr_is_open = False
else:
# Treat any other state as unknown.
# NOTE: A null state can happen during device startup.
self._attr_is_locked = None
self._attr_is_open = None
@callback
def _reset_optimistic_state(self, write_state: bool = True) -> None:
if self._optimistic_timer and not self._optimistic_timer.cancelled():
self._optimistic_timer.cancel()
self._optimistic_timer = None
self._attr_is_locking = False
self._attr_is_unlocking = False
self._attr_is_opening = False
if write_state:
self.async_write_ha_state()
@callback
def _calculate_features(
self,
) -> None:
"""Calculate features for HA Lock platform from Matter FeatureMap."""
feature_map = int(
self.get_matter_attribute_value(clusters.DoorLock.Attributes.FeatureMap)
)
# NOTE: the featuremap can dynamically change, so we need to update the
# supported features if the featuremap changes.
if self._feature_map == feature_map:
return
self._feature_map = feature_map
supported_features = LockEntityFeature(0)
# determine if lock supports optional open/unbolt feature
if bool(feature_map & DoorLockFeature.kUnbolt):
supported_features |= LockEntityFeature.OPEN
self._attr_supported_features = supported_features
DISCOVERY_SCHEMAS = [
MatterDiscoverySchema(
platform=Platform.LOCK,
entity_description=LockEntityDescription(
key="MatterLock",
name=None,
),
entity_class=MatterLock,
required_attributes=(clusters.DoorLock.Attributes.LockState,),
),
]