From cd5bfd6bafa570c01403197e4fbee9cbd6f774f3 Mon Sep 17 00:00:00 2001 From: jvmahon Date: Wed, 27 Aug 2025 11:48:55 -0400 Subject: [PATCH] Add Matter lock event changed_by (#149861) Co-authored-by: Martin Hjelmare --- homeassistant/components/matter/lock.py | 63 +++++++++++++++++++ .../matter/snapshots/test_lock.ambr | 2 + tests/components/matter/test_lock.py | 23 ++++++- 3 files changed, 87 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/matter/lock.py b/homeassistant/components/matter/lock.py index 81de7482d46..c264ce65896 100644 --- a/homeassistant/components/matter/lock.py +++ b/homeassistant/components/matter/lock.py @@ -6,6 +6,7 @@ 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, @@ -22,6 +23,22 @@ 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 @@ -41,6 +58,52 @@ class MatterLock(MatterEntity, LockEntity): _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: diff --git a/tests/components/matter/snapshots/test_lock.ambr b/tests/components/matter/snapshots/test_lock.ambr index 7384449839c..4fbf8ddb822 100644 --- a/tests/components/matter/snapshots/test_lock.ambr +++ b/tests/components/matter/snapshots/test_lock.ambr @@ -37,6 +37,7 @@ # name: test_locks[door_lock][lock.mock_door_lock-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'changed_by': 'Unknown', 'friendly_name': 'Mock Door Lock', 'supported_features': , }), @@ -86,6 +87,7 @@ # name: test_locks[door_lock_with_unbolt][lock.mock_door_lock-state] StateSnapshot({ 'attributes': ReadOnlyDict({ + 'changed_by': 'Unknown', 'friendly_name': 'Mock Door Lock', 'supported_features': , }), diff --git a/tests/components/matter/test_lock.py b/tests/components/matter/test_lock.py index ab3995e6771..e6566202c59 100644 --- a/tests/components/matter/test_lock.py +++ b/tests/components/matter/test_lock.py @@ -4,10 +4,11 @@ from unittest.mock import MagicMock, call from chip.clusters import Objects as clusters from matter_server.client.models.node import MatterNode +from matter_server.common.models import EventType, MatterNodeEvent import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.lock import LockEntityFeature, LockState +from homeassistant.components.lock import ATTR_CHANGED_BY, LockEntityFeature, LockState from homeassistant.const import ATTR_CODE, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError @@ -112,6 +113,26 @@ async def test_lock( state = hass.states.get("lock.mock_door_lock") assert state.attributes["supported_features"] & LockEntityFeature.OPEN + # test handling of a node LockOperation event + await trigger_subscription_callback( + hass, + matter_client, + EventType.NODE_EVENT, + MatterNodeEvent( + node_id=matter_node.node_id, + endpoint_id=1, + cluster_id=257, + event_id=2, + event_number=0, + priority=1, + timestamp=0, + timestamp_type=0, + data={"operationSource": 3}, + ), + ) + state = hass.states.get("lock.mock_door_lock") + assert state.attributes[ATTR_CHANGED_BY] == "Keypad" + @pytest.mark.parametrize("node_fixture", ["door_lock"]) async def test_lock_requires_pin(