22
33Handles PIN credential management via Matter lock services.
44PINs 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
89from __future__ import annotations
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