Skip to content

Commit 430c6cb

Browse files
raman325claude
andcommitted
Add LockUserChange event handling and optimistic push updates for Matter
Subscribe to DoorLock LockUserChange events (event ID 4) to get real-time credential change notifications. When a PIN is added/modified, push SlotCode.UNKNOWN to coordinator; when cleared, push SlotCode.EMPTY. This replaces waiting for the 5-minute poll cycle. Also add optimistic push updates in async_set_usercode and async_clear_usercode, matching the Z-Wave pattern where the service call success triggers an immediate coordinator update. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 03ca04bf0391
1 parent ac17328 commit 430c6cb

3 files changed

Lines changed: 365 additions & 31 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ Supported lock integrations:
2323
| Integration | Read PINs | Push Updates | Code Events | Notes |
2424
| --- | --- | --- | --- | --- |
2525
| [Z-Wave][wiki-zwave] | Varies | Yes | Yes | Some locks mask PINs |
26-
| [Matter][wiki-matter] | No | No | Yes | PINs write-only per spec |
26+
| [Matter][wiki-matter] | No | Yes | Yes | PINs write-only per spec |
2727
| [Schlage WiFi][wiki-schlage] | No | No | No | Cloud-based, PINs masked |
2828
| [Akuvox][wiki-akuvox]¹ | Yes | No | No | Local API, polling-based |
2929
| [Virtual][wiki-virtual]¹ | Yes | No | No | For testing only |

custom_components/lock_code_manager/providers/matter.py

Lines changed: 94 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,8 @@
22
33
Handles PIN credential management via Matter lock services.
44
PINs are write-only: occupied slots report SlotCode.UNKNOWN, cleared slots report
5-
SlotCode.EMPTY. Subscribes to LockOperation events for code slot event tracking.
5+
SlotCode.EMPTY. Subscribes to DoorLock cluster events for code slot tracking and
6+
push-based occupancy updates via LockOperation and LockUserChange events.
67
"""
78

89
from __future__ import annotations
@@ -30,8 +31,17 @@
3031
# DoorLock cluster ID (0x0101 = 257)
3132
_DOOR_LOCK_CLUSTER_ID = 257
3233

33-
# LockOperation event ID
34+
# DoorLock cluster event IDs
3435
_LOCK_OPERATION_EVENT_ID = 2
36+
_LOCK_USER_CHANGE_EVENT_ID = 4
37+
38+
# LockUserChange LockDataType values
39+
_LOCK_DATA_TYPE_PIN = 6
40+
41+
# LockUserChange DataOperationType values
42+
_DATA_OP_ADD = 0
43+
_DATA_OP_CLEAR = 1
44+
_DATA_OP_MODIFY = 2
3545

3646

3747
@dataclass(repr=False, eq=False)
@@ -168,11 +178,16 @@ async def async_is_device_available(self) -> bool:
168178

169179
@callback
170180
def _subscribe_to_events(self) -> None:
171-
"""Subscribe to Matter LockOperation events for code slot tracking.
181+
"""Subscribe to Matter DoorLock cluster events.
182+
183+
Handles two event types:
184+
- LockOperation (event 2): fires code slot events when a PIN is used
185+
- LockUserChange (event 4): pushes occupancy updates to coordinator
186+
when credentials are added, modified, or cleared
172187
173188
Called during setup. Does not use the push subscription framework
174-
because that would disable polling — Matter needs polling for value
175-
updates (PINs are write-only) and events only for code slot tracking.
189+
because that would disable polling — Matter needs polling for full
190+
reconciliation and events for real-time change detection.
176191
"""
177192
if self._event_unsub is not None:
178193
return
@@ -188,7 +203,7 @@ def _subscribe_to_events(self) -> None:
188203
return
189204

190205
self._event_unsub = client.subscribe_events(
191-
callback=self._on_lock_operation,
206+
callback=self._on_node_event,
192207
event_filter=EventType.NODE_EVENT,
193208
node_filter=node_id,
194209
)
@@ -206,20 +221,25 @@ async def async_unload(self, remove_permanently: bool) -> None:
206221
await super().async_unload(remove_permanently)
207222

208223
@callback
209-
def _on_lock_operation(self, _event: Any, node_event: Any) -> None:
210-
"""Handle Matter LockOperation events.
224+
def _on_node_event(self, _event: Any, node_event: Any) -> None:
225+
"""Dispatch DoorLock cluster events to the appropriate handler."""
226+
if getattr(node_event, "cluster_id", None) != _DOOR_LOCK_CLUSTER_ID:
227+
return
228+
229+
event_id = getattr(node_event, "event_id", None)
230+
if event_id == _LOCK_OPERATION_EVENT_ID:
231+
self._handle_lock_operation(node_event)
232+
elif event_id == _LOCK_USER_CHANGE_EVENT_ID:
233+
self._handle_lock_user_change(node_event)
234+
235+
@callback
236+
def _handle_lock_operation(self, node_event: Any) -> None:
237+
"""Handle LockOperation events (event ID 2).
211238
212239
Fires a code slot event when a PIN credential is used to lock/unlock.
213240
Only PIN credentials (credentialType=1) trigger the event — other
214241
credential types (RFID, fingerprint, etc.) are ignored.
215242
"""
216-
# Filter to DoorLock cluster LockOperation events
217-
if (
218-
getattr(node_event, "cluster_id", None) != _DOOR_LOCK_CLUSTER_ID
219-
or getattr(node_event, "event_id", None) != _LOCK_OPERATION_EVENT_ID
220-
):
221-
return
222-
223243
data: dict[str, Any] = getattr(node_event, "data", None) or {}
224244
credentials = data.get("credentials")
225245
lock_operation_type = data.get("lockOperationType")
@@ -266,6 +286,54 @@ def _on_lock_operation(self, _event: Any, node_event: Any) -> None:
266286
source_data=data,
267287
)
268288

289+
@callback
290+
def _handle_lock_user_change(self, node_event: Any) -> None:
291+
"""Handle LockUserChange events (event ID 4).
292+
293+
Pushes occupancy updates to the coordinator when a PIN credential is
294+
added, modified, or cleared. This provides real-time change detection
295+
without waiting for the next poll cycle.
296+
297+
Only PIN credentials (LockDataType=6) are handled. The DataIndex field
298+
maps to the credential index (code slot number).
299+
"""
300+
data: dict[str, Any] = getattr(node_event, "data", None) or {}
301+
302+
# Only handle PIN credential changes (LockDataType 6 = PIN)
303+
if data.get("lockDataType") != _LOCK_DATA_TYPE_PIN:
304+
return
305+
306+
code_slot = data.get("dataIndex")
307+
if code_slot is None:
308+
return
309+
code_slot = int(code_slot)
310+
311+
operation = data.get("dataOperationType")
312+
313+
if operation == _DATA_OP_CLEAR:
314+
resolved: str | SlotCode = SlotCode.EMPTY
315+
elif operation in (_DATA_OP_ADD, _DATA_OP_MODIFY):
316+
resolved = SlotCode.UNKNOWN
317+
else:
318+
LOGGER.debug(
319+
"Lock %s: LockUserChange event with unknown operation %s for slot %s",
320+
self.lock.entity_id,
321+
operation,
322+
code_slot,
323+
)
324+
return
325+
326+
LOGGER.debug(
327+
"Lock %s: LockUserChange event — slot=%s, operation=%s, resolved=%s",
328+
self.lock.entity_id,
329+
code_slot,
330+
operation,
331+
resolved,
332+
)
333+
334+
if self.coordinator:
335+
self.coordinator.push_update({code_slot: resolved})
336+
269337
# -- Usercode CRUD -------------------------------------------------------
270338

271339
async def async_get_usercodes(self) -> dict[int, str | SlotCode]:
@@ -321,7 +389,8 @@ async def async_set_usercode(
321389
"""Set a usercode on a code slot.
322390
323391
Returns True unconditionally because Matter does not reveal whether
324-
the credential value actually changed.
392+
the credential value actually changed. Pushes SlotCode.UNKNOWN to the
393+
coordinator immediately — the LockUserChange event will confirm.
325394
"""
326395
await self._async_call_service(
327396
"set_lock_credential",
@@ -350,15 +419,17 @@ async def async_set_usercode(
350419
code_slot,
351420
name,
352421
)
422+
# Optimistic update: service call succeeded, push occupancy state
423+
# immediately. The LockUserChange event will confirm later.
424+
if self.coordinator:
425+
self.coordinator.push_update({code_slot: SlotCode.UNKNOWN})
353426
return True
354427

355428
async def async_clear_usercode(self, code_slot: int) -> bool:
356429
"""Clear a usercode on a code slot.
357430
358431
Returns True if a credential was cleared, False if the slot was already
359-
empty. Note: there is a TOCTOU race between the status check and the
360-
clear — if another party clears the credential between the two calls,
361-
the clear call may fail. This is inherent in the two-step protocol.
432+
empty. Pushes SlotCode.EMPTY to the coordinator immediately on success.
362433
"""
363434
lock_data = await self._async_call_service(
364435
"get_lock_credential_status",
@@ -379,6 +450,10 @@ async def async_clear_usercode(self, code_slot: int) -> bool:
379450
"credential_index": code_slot,
380451
},
381452
)
453+
# Optimistic update: clear succeeded, push empty state immediately.
454+
# The LockUserChange event will confirm later.
455+
if self.coordinator:
456+
self.coordinator.push_update({code_slot: SlotCode.EMPTY})
382457
return True
383458

384459
async def async_hard_refresh_codes(self) -> dict[int, str | SlotCode]:

0 commit comments

Comments
 (0)