-
-
Notifications
You must be signed in to change notification settings - Fork 14
Architecture
Lock Code Manager (LCM) manages user codes across locks via providers. Each lock gets a coordinator that holds the full slot-to-code mapping (managed AND unmanaged).
Config Entry (desired state)
|
Binary Sensor (sync decision)
| set/clear
Provider (async_set_usercode / async_clear_usercode)
| Z-Wave service call
Lock (firmware)
| push notification / poll response
Coordinator (actual state: ALL slots)
| listener notification
Binary Sensor (re-evaluate sync)
Stores dict[int, int | str] mapping slot number to code for ALL slots on the
lock. Does not distinguish managed vs unmanaged — that distinction lives in the
config entries.
- Push-based providers (Z-Wave JS) set
update_interval = None - Poll-based providers use periodic refresh via
_async_update_data()
-
Push: Z-Wave value update events → provider filters →
coordinator.push_update() -
Poll: Coordinator's
_async_update_data()→ provider'sasync_get_usercodes()on interval -
Hard refresh:
async_hard_refresh_codes()→ refreshes Z-Wave CC values cache from device, then reads all slots. Triggered via thehard_refresh_usercodesservice.
A slot is managed if it exists in any LCM config entry's CONF_SLOTS for this
lock AND the corresponding LCM entities exist.
Detection: _get_slot_entity_states() returns None for unmanaged slots.
Coordinator stores both managed and unmanaged; sync only operates on managed slots; the UI displays both.
The in-sync binary sensor compares desired state (PIN from text entity, active state from condition) against actual state (coordinator data):
- If active + PIN differs from coordinator → set
- If inactive + code present in coordinator → clear
- If states match → in sync (no operation)
When a user clears the PIN text entity on an enabled slot, the text entity automatically disables the slot (turns off the enabled switch) before clearing the PIN value. This ensures the sync logic will clear the code on the lock rather than leaving a stale code active.
Some locks return **** instead of actual codes. LCM handles this:
-
Managed slots: Resolved via the text entity's expected PIN — if the expected
PIN is set and the coordinator shows
****, the binary sensor treats it as in-sync (the lock has a code, and we set it, so we trust it matches). -
Unmanaged slots: Kept as-is —
****just indicates "slot in use."
Two layers of defense against duplicate PINs causing infinite sync loops:
-
Pre-flight check (
_check_duplicate_code()inBaseLock): Scans coordinator data for matching PINs before sending to the lock. RaisesDuplicateCodeError. Skips masked values (****) since they can't be compared. -
Event 15 handler (Z-Wave JS only,
_async_handle_duplicate_code()): Reactive safety net for cases where the pre-flight check can't detect the duplicate (e.g., masked codes from unmanaged slots).
Both paths disable the slot and create a persistent notification.
The sync mechanism tracks consecutive successful SET attempts (provider call did
not raise) that fail to resolve the out-of-sync state. If MAX_SYNC_ATTEMPTS
are reached within SYNC_ATTEMPT_WINDOW, the lock is assumed to be rejecting
the PIN. The slot is disabled and the user is notified.
Only SET operations are tracked — clears always proceed and are not counted.
LockDisconnected exceptions are transient and use the separate retry mechanism
with RETRY_DELAY. The tracker resets when the slot is disabled or when the
code is successfully synced.
-
CodeRejectedError— base: the lock won't accept this PIN (any reason) -
DuplicateCodeError(CodeRejectedError)— specific: PIN duplicates another slot
The pre-flight duplicate check raises DuplicateCodeError. The sync path catches
the parent CodeRejectedError so any rejection reason results in disable + notify.
The @final methods async_internal_set_usercode and async_internal_clear_usercode
are the single entry point for all lock operations. They enforce cross-cutting concerns
in this order:
- Integration connectivity check — verify integration is connected
- Device availability check — verify physical device is responsive
-
Acquire operation lock — serialize operations via
asyncio.Lock - Pre-execute hook (set only) — duplicate code check runs inside the lock
- Rate limit delay — minimum delay between operations
-
Provider call — delegate to subclass
async_set_usercode/async_clear_usercode - Coordinator refresh — update state (skipped for push-based providers)
Helper methods available to all providers:
-
is_masked_or_empty(code)— detect****masked or empty codes -
is_slot_managed(code_slot)— check if any LCM config entry manages this slot
The source parameter ("sync" or "direct") indicates whether the call came from
the sync path (binary sensor) or a user action (websocket). Currently informational;
future use for differentiated error handling policies.
All lock operations are serialized via asyncio.Lock per provider instance, with
a 2-second minimum delay between operations. This prevents overwhelming the lock's
radio and Z-Wave mesh.
WebSocket subscriptions for slot card data re-resolve entity IDs dynamically on each update. This handles entities that are created after the subscription is established (for example, during initial config setup). The resolution uses lightweight entity registry lookups.
When structural config changes occur (slots or locks added/removed), LCM fires
lovelace_updated events for each registered dashboard. This triggers the
"Configuration changed" toast in the Home Assistant frontend, prompting users to
refresh so the strategy re-generates cards.
The frontend is built with TypeScript 6.0 and provides Lovelace strategies
(dashboard, view, and section level) and custom cards (lcm-slot,
lcm-lock-codes) for managing PINs.
Getting Started
Features
- Blueprints
- Tracking lock state change events
- Using Condition Entities
- Unsupported Condition Entities
- Number of Uses (deprecated)
- Notifications
Advanced
Development
Troubleshooting
FAQ
Supported Integrations