-
-
Notifications
You must be signed in to change notification settings - Fork 16
Architecture
Lock Code Manager (LCM) manages PIN codes across locks via providers. Each lock gets a coordinator that holds the full slot-to-credential mapping (managed AND unmanaged). Provider implementations target a small contract on BaseLock that splits cleanly along two dimensions: how rich the lock's user model is, and whether the lock can push state changes.
Config Entry (desired state)
|
Binary Sensor (sync decision)
| set/clear
BaseLock seam (async_set_usercode / async_clear_usercode)
| for native-user locks: async_set_user -> async_set_credential
| for slot-only locks: async_set_credential
Provider (per-integration writes)
| integration service call / SDK
Lock (firmware)
| push notification / poll response
Coordinator (actual state: dict[int, SlotCredential])
| listener notification
Binary Sensor (re-evaluate sync)
LCM speaks a platform-neutral language at the seam: users own credentials.
-
User—user_id,name,active,credentials: list[Credential]. The lock's identity for a person. -
Credential—type(CredentialType.PINtoday;RFID,FINGERPRINT, etc. reserved),slot(LCM slot number),state(SlotCredential: empty / unreadable / known). -
CredentialRef—(user_id, type, slot)hashable address used when pointing at a credential without carrying its state. -
SlotCredential— frozen value with factoriesempty(),unreadable()(write-only locks report this on occupied slots),known(pin). -
LockCapabilities— what the lock advertises:supports_user_management,max_users,credential_types: dict[CredentialType, CredentialTypeCapability],max_user_name_length.
Providers expose users and credentials through async_get_users() -> list[User]. Slot-only providers (Z-Wave User Code CC, ZHA, Akuvox, Schlage, etc.) synthesize a single-credential user per occupied slot via user_from_slot(...). Native-user providers (Matter, Z-Wave User Credential CC) return the lock's actual user list.
Stores dict[int, SlotCredential] mapping LCM slot number to credential state 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, Matter, ZHA, Zigbee2MQTT) set
update_interval = None - Poll-based providers (Akuvox, Schlage, Virtual) use periodic refresh via
_async_update_data()
-
Push: device event → provider filters →
coordinator.push_update({slot: SlotCredential}) -
Poll: coordinator's
_async_update_data()→ provider'sasync_get_usercodes()on interval (default-implemented in BaseLock as a projection overasync_get_users(); providers may override for efficiency) -
Hard refresh:
async_hard_refresh_codes()→ bypasses any cache and re-reads from the device. Triggered periodically viahard_refresh_intervalfor drift detection, and on demand 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)
SlotCredential.unreadable() (write-only locks like Matter) is treated as in-sync against any expected PIN — the lock confirmed occupancy and we already set the value.
For locks with a user-name field (Matter, Z-Wave U3C, Akuvox, Schlage), LCM tags its own users with lcm:<slot>:<friendly name> so it can coexist with other controllers and recover the LCM slot identity on every read.
Mechanics:
- The base seam builds the tag via
_build_tagged_user_name(slot, display), truncating the display portion to fitmax_user_name_length. For length-constrained locks where the canonical prefix doesn't fit, a slot-only fallback (str(slot)) is written. - Providers do find-or-create-by-tag in
async_set_user: scan the lock's user list for a user whose name parses to the target LCM slot; UPDATE that user if found, CREATE a new one if not. - A second-pass legacy adoption scoops up pre-PR-1239/1240 users (Matter at
credential_index == slot, Z-Wave U3C atuser_id == slot) on the first write to each slot after upgrade, so existing PINs don't get orphaned. - The tolerant parser in
_util.pyaccepts both the canonicallcm:<slot>:<name>form and the legacy[LCM:<slot>] <name>form (Akuvox/Schlage); legacy-tagged codes are rewritten to canonical on next write.
Under the user-tag idempotency design, lock-side users are slot anchors that persist across PIN clear/replace cycles:
- A clear deletes the credential but leaves the user record. The next set on the same slot finds that user by tag and writes a new credential under it.
- The user is removed only when the slot itself is removed from LCM config, via
async_release_managed_slot(slot). - This decouples user lifecycle from credential lifecycle and keeps the find-or-create-by-tag invariant stable across operations.
Slot-only providers don't have this distinction — there's nothing to anchor besides the credential itself.
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 expose write-only PINs:
-
Cloud-backed (Schlage): the API returns
****for code values. LCM treats the slot as occupied but unreadable. - Spec-level (Matter): PINs are write-only per the Matter DoorLock cluster.
- Variable (Z-Wave UC on some firmwares): some chipsets mask the value, others expose it.
LCM handles this uniformly:
-
Managed slots: resolved via the text entity's expected PIN — if the expected PIN is set and the coordinator reports the slot occupied (
unreadable), the binary sensor treats it as in-sync. -
Unmanaged slots: kept as-is —
unreadablejust signals "slot in use."
Two layers of defense against duplicate PINs causing infinite sync loops:
-
Pre-flight check (in
BaseLock): scans coordinator data for matching readable PINs before sending to the lock. RaisesDuplicateCodeError. Unreadable values are skipped because they can't be compared. -
Provider-side duplicate signals (Z-Wave JS event 15, Matter's
DuplicateCodestatus, Schlage's "duplicate code value" error, etc.): reactive safety net for cases where the pre-flight check can't see the duplicate (e.g., unreadable codes on unmanaged slots, or a race with a keypad-entered code).
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.
LockCodeManagerError # base: LCM-internal error
└── LockCodeManagerProviderError # base: provider-side failure
├── LockDisconnected # transient: route to retry
├── LockOperationFailed # lock reachable but operation didn't take
├── CodeRejectedError # lock won't accept the PIN (any reason)
│ └── DuplicateCodeError # PIN duplicates another slot
└── ProviderNotImplementedError # bare BaseLock primitives raise this
Providers raise these subclasses; the seam catches them and routes:
-
LockDisconnected→ retry with backoff -
CodeRejectedError/DuplicateCodeError→ disable slot, persistent notification -
LockOperationFailed→ log + propagate to caller
Do NOT raise bare Exception or HomeAssistantError from providers — those bypass the routing.
@final seam methods are the single entry point for all lock operations:
-
async_set_usercode(slot, pin, *, name)— top-level "ensure this slot has this PIN" -
async_clear_usercode(slot)— top-level "clear this slot" -
async_get_usercodes()— default-implemented projection overasync_get_users()
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 primitives —
_set_credential/_delete_credentialorchestration helpers -
Coordinator refresh — update state (skipped for push-based providers that report
Truefrom their primitive)
-
_set_credential(user, credential, pin, *, name, source)— for native-user providers, builds the tagged user name, callsasync_set_user(user_for_write), thenasync_set_credential(user_id, credential, pin, ...). Rolls back a newly-created user if the credential write fails. For slot-only providers, just callsasync_set_credential. -
_delete_credential(ref)— callsasync_delete_credential(ref). Does NOT delete the owning user; the user is a persistent slot anchor (see lifecycle section above) and is removed viaasync_release_managed_slot. -
_build_tagged_user_name(slot, display)— produceslcm:<slot>:<display>, truncates to fitmax_user_name_length, falls back to slot-only or returnsNonefor length-constrained locks.
Slot-only providers implement:
async_set_credential(user_id, credential, pin, *, name, source) -> boolasync_delete_credential(ref) -> bool-
async_get_users() -> list[User](typically viauser_from_slot) -
is_integration_connected()and the property/interval surface
Native-user providers additionally implement:
-
async_set_user(user) -> SetUserResult(with find-or-create-by-tag) async_delete_user(user_id) -> Noneasync_get_capabilities() -> LockCapabilities
Helper methods available to all providers:
-
is_masked_or_empty(...)— detect unreadable or empty states -
is_slot_managed(code_slot)— check if any LCM config entry manages this slot -
_require_readable_pin(credential)— defensive guard that returns the readable PIN
The source parameter ("sync" or "direct") indicates whether the call came from the sync path (binary sensor) or a user action (websocket / service). Currently informational; reserved for future differentiated error-handling policies.
All lock operations are serialized via asyncio.Lock per provider instance, with a 2-second minimum delay between operations (MIN_OPERATION_DELAY). This prevents overwhelming the lock's radio (Z-Wave mesh, Matter fabric, Zigbee network).
A second outer _sequence_lock is available for read-modify-write sequences that span multiple primitive calls (e.g., Akuvox's list-then-modify pattern) without deadlocking on the inner per-call lock.
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.
- Provider-State-Management — coordinator update modes, push/poll/drift, exception routing
- Adding-a-Provider — step-by-step tutorial for implementing a new provider
- Supporting-new-lock-integrations — higher-level guide on assessing integration feasibility
Getting Started
UI
- Add a UI for lock code management — overview & decision guide
- UI Strategies
- Custom Cards
Features
- Services and Actions
- Blueprints
- Tracking lock state change events
- Using Condition Entities
- Unsupported Condition Entities
- Notifications
Advanced
Development
Troubleshooting
FAQ
Supported Integrations