Skip to content

Commit 50ea6a9

Browse files
raman325claude
andauthored
Add Matter LockUserChange events and optimistic push updates (#986)
* 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 * Use push framework for Matter event subscriptions Address review feedback: - Set supports_push=True so BaseLock skips post-set/clear coordinator refresh that would overwrite optimistic push updates - Implement setup_push_subscription/teardown_push_subscription instead of manual _subscribe_to_events, getting automatic retry on failure - Raise LockDisconnected when client/node unavailable (triggers retry) instead of silently skipping - Add defensive try/except for dataIndex int conversion Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 44c903d5ea75 * Address code review findings: logging, data guards, and test gaps - Log warning for non-integer dataIndex in LockUserChange events - Log debug for unhandled DoorLock cluster event IDs - Guard coordinator.data is not None before push_update to prevent TypeError if event arrives before first coordinator poll - Add supports_push property test - Add tests for service failure preventing optimistic push - Add tests for non-integer dataIndex and coordinator.data=None Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Entire-Checkpoint: c82ae55fd83a * Add TODO: consider event-driven vs optimistic push updates Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Entire-Checkpoint: 82e727c603a2 --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent ac17328 commit 50ea6a9

4 files changed

Lines changed: 540 additions & 64 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 |

TODO.md

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,17 @@
3535
**Cannot support:** esphome (no API), august/yale/yalexs_ble/yale_smart_alarm
3636
(library limitations)
3737

38+
## Architecture Considerations
39+
40+
- **Event-driven vs optimistic push updates** — For providers that support push
41+
events (Matter LockUserChange, Z-Wave value updates), consider removing
42+
optimistic pushes from set/clear methods and relying solely on events. The
43+
event is the lock's actual confirmation the credential was stored, while
44+
optimistic pushes only confirm the service call was accepted. Event-only
45+
updates give a single source of truth and simpler code, at the cost of a
46+
brief latency window before the coordinator updates. Z-Wave may still need
47+
optimistic pushes to avoid sync loops with stale cache reads.
48+
3849
## Code Quality
3950

4051
- **Dual storage pattern** — Simplify `data` + `options` config entry pattern.

custom_components/lock_code_manager/providers/matter.py

Lines changed: 124 additions & 33 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 via the push framework for
6+
code slot tracking (LockOperation) and occupancy updates (LockUserChange).
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)
@@ -52,6 +62,16 @@ def supports_code_slot_events(self) -> bool:
5262
"""Return whether this lock supports code slot events."""
5363
return True
5464

65+
@property
66+
def supports_push(self) -> bool:
67+
"""Return whether this lock supports push-based updates.
68+
69+
Matter locks push occupancy changes via LockUserChange events.
70+
PINs are still write-only (values are never pushed), but slot
71+
occupancy (UNKNOWN/EMPTY) is pushed in real time.
72+
"""
73+
return True
74+
5575
@property
5676
def usercode_scan_interval(self) -> timedelta:
5777
"""Return scan interval for usercodes."""
@@ -139,9 +159,6 @@ async def async_setup(self, config_entry: ConfigEntry) -> None:
139159
lock_info,
140160
)
141161

142-
# Subscribe to LockOperation events for code slot event tracking
143-
self._subscribe_to_events()
144-
145162
async def async_is_integration_connected(self) -> bool:
146163
"""Return whether the Matter integration is loaded."""
147164
if not self.lock_config_entry:
@@ -164,31 +181,31 @@ async def async_is_device_available(self) -> bool:
164181
return False
165182
return True
166183

167-
# -- Event subscription (separate from push value updates) ----------------
184+
# -- Event subscription via push framework --------------------------------
168185

169186
@callback
170-
def _subscribe_to_events(self) -> None:
171-
"""Subscribe to Matter LockOperation events for code slot tracking.
187+
def setup_push_subscription(self) -> None:
188+
"""Subscribe to Matter DoorLock cluster events.
189+
190+
Handles two event types:
191+
- LockOperation (event 2): fires code slot events when a PIN is used
192+
- LockUserChange (event 4): pushes occupancy updates to coordinator
193+
when credentials are added, modified, or cleared
172194
173-
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.
195+
Called by BaseLock.subscribe_push_updates() with automatic retry.
176196
"""
177197
if self._event_unsub is not None:
178198
return
179199

180200
client = self._get_matter_client()
181201
node_id = self._matter_node_id
182202
if not client or node_id is None:
183-
LOGGER.debug(
184-
"Lock %s: Matter client or node ID unavailable, "
185-
"skipping event subscription",
186-
self.lock.entity_id,
203+
raise LockDisconnected(
204+
f"Matter client or node ID unavailable for {self.lock.entity_id}"
187205
)
188-
return
189206

190207
self._event_unsub = client.subscribe_events(
191-
callback=self._on_lock_operation,
208+
callback=self._on_node_event,
192209
event_filter=EventType.NODE_EVENT,
193210
node_filter=node_id,
194211
)
@@ -198,28 +215,39 @@ def _subscribe_to_events(self) -> None:
198215
node_id,
199216
)
200217

201-
async def async_unload(self, remove_permanently: bool) -> None:
202-
"""Unload lock and unsubscribe from events."""
218+
@callback
219+
def teardown_push_subscription(self) -> None:
220+
"""Unsubscribe from Matter DoorLock cluster events."""
203221
if self._event_unsub:
204222
self._event_unsub()
205223
self._event_unsub = None
206-
await super().async_unload(remove_permanently)
207224

208225
@callback
209-
def _on_lock_operation(self, _event: Any, node_event: Any) -> None:
210-
"""Handle Matter LockOperation events.
226+
def _on_node_event(self, _event: Any, node_event: Any) -> None:
227+
"""Dispatch DoorLock cluster events to the appropriate handler."""
228+
if getattr(node_event, "cluster_id", None) != _DOOR_LOCK_CLUSTER_ID:
229+
return
230+
231+
event_id = getattr(node_event, "event_id", None)
232+
if event_id == _LOCK_OPERATION_EVENT_ID:
233+
self._handle_lock_operation(node_event)
234+
elif event_id == _LOCK_USER_CHANGE_EVENT_ID:
235+
self._handle_lock_user_change(node_event)
236+
else:
237+
LOGGER.debug(
238+
"Lock %s: unhandled DoorLock event_id=%s",
239+
self.lock.entity_id,
240+
event_id,
241+
)
242+
243+
@callback
244+
def _handle_lock_operation(self, node_event: Any) -> None:
245+
"""Handle LockOperation events (event ID 2).
211246
212247
Fires a code slot event when a PIN credential is used to lock/unlock.
213248
Only PIN credentials (credentialType=1) trigger the event — other
214249
credential types (RFID, fingerprint, etc.) are ignored.
215250
"""
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-
223251
data: dict[str, Any] = getattr(node_event, "data", None) or {}
224252
credentials = data.get("credentials")
225253
lock_operation_type = data.get("lockOperationType")
@@ -266,6 +294,62 @@ def _on_lock_operation(self, _event: Any, node_event: Any) -> None:
266294
source_data=data,
267295
)
268296

297+
@callback
298+
def _handle_lock_user_change(self, node_event: Any) -> None:
299+
"""Handle LockUserChange events (event ID 4).
300+
301+
Pushes occupancy updates to the coordinator when a PIN credential is
302+
added, modified, or cleared. This provides real-time change detection
303+
without waiting for the next poll cycle.
304+
305+
Only PIN credentials (LockDataType=6) are handled. The DataIndex field
306+
maps to the credential index (code slot number).
307+
"""
308+
data: dict[str, Any] = getattr(node_event, "data", None) or {}
309+
310+
# Only handle PIN credential changes (LockDataType 6 = PIN)
311+
if data.get("lockDataType") != _LOCK_DATA_TYPE_PIN:
312+
return
313+
314+
raw_index = data.get("dataIndex")
315+
if raw_index is None:
316+
return
317+
try:
318+
code_slot = int(raw_index)
319+
except (TypeError, ValueError):
320+
LOGGER.warning(
321+
"Lock %s: LockUserChange has non-integer dataIndex %r, ignoring",
322+
self.lock.entity_id,
323+
raw_index,
324+
)
325+
return
326+
327+
operation = data.get("dataOperationType")
328+
329+
if operation == _DATA_OP_CLEAR:
330+
resolved: str | SlotCode = SlotCode.EMPTY
331+
elif operation in (_DATA_OP_ADD, _DATA_OP_MODIFY):
332+
resolved = SlotCode.UNKNOWN
333+
else:
334+
LOGGER.debug(
335+
"Lock %s: LockUserChange event with unknown operation %s for slot %s",
336+
self.lock.entity_id,
337+
operation,
338+
code_slot,
339+
)
340+
return
341+
342+
LOGGER.debug(
343+
"Lock %s: LockUserChange event — slot=%s, operation=%s, resolved=%s",
344+
self.lock.entity_id,
345+
code_slot,
346+
operation,
347+
resolved,
348+
)
349+
350+
if self.coordinator and self.coordinator.data is not None:
351+
self.coordinator.push_update({code_slot: resolved})
352+
269353
# -- Usercode CRUD -------------------------------------------------------
270354

271355
async def async_get_usercodes(self) -> dict[int, str | SlotCode]:
@@ -321,7 +405,8 @@ async def async_set_usercode(
321405
"""Set a usercode on a code slot.
322406
323407
Returns True unconditionally because Matter does not reveal whether
324-
the credential value actually changed.
408+
the credential value actually changed. Pushes SlotCode.UNKNOWN to the
409+
coordinator immediately — the LockUserChange event will confirm.
325410
"""
326411
await self._async_call_service(
327412
"set_lock_credential",
@@ -350,15 +435,17 @@ async def async_set_usercode(
350435
code_slot,
351436
name,
352437
)
438+
# Optimistic update: service call succeeded, push occupancy state
439+
# immediately. The LockUserChange event will confirm later.
440+
if self.coordinator and self.coordinator.data is not None:
441+
self.coordinator.push_update({code_slot: SlotCode.UNKNOWN})
353442
return True
354443

355444
async def async_clear_usercode(self, code_slot: int) -> bool:
356445
"""Clear a usercode on a code slot.
357446
358447
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.
448+
empty. Pushes SlotCode.EMPTY to the coordinator immediately on success.
362449
"""
363450
lock_data = await self._async_call_service(
364451
"get_lock_credential_status",
@@ -379,6 +466,10 @@ async def async_clear_usercode(self, code_slot: int) -> bool:
379466
"credential_index": code_slot,
380467
},
381468
)
469+
# Optimistic update: clear succeeded, push empty state immediately.
470+
# The LockUserChange event will confirm later.
471+
if self.coordinator and self.coordinator.data is not None:
472+
self.coordinator.push_update({code_slot: SlotCode.EMPTY})
382473
return True
383474

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

0 commit comments

Comments
 (0)