-
-
Notifications
You must be signed in to change notification settings - Fork 18
Architecture
Lock Code Manager (LCM) manages PIN codes across locks via providers. Configuration is user-centric: a config entry stores users keyed by user name, and each user holds a slot number as internal bookkeeping (slot_assignment). The name is the identity — it's what the dashboard, services (add_user / delete_user), and entity/device titles work in — while the slot number surfaces only in provider writes and registry identifiers, so a rename moves nothing. 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: users keyed by name + slot assignment)
|
Per-user entities (PIN/name text, enabled switch, active binary sensor)
|
SlotSyncManager (per user x lock; sync decision on a 2s tick)
| set/clear
BaseLock seam (async_internal_set_usercode / async_internal_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[CredentialAddress, SlotCredential])
| listener notification
SlotSyncManager (re-evaluate; in-sync binary sensor displays the result)
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(slot index),state(SlotCredential: empty / unreadable / known). -
CredentialRef—(user_id, type, slot)hashable address used when pointing at a device-side credential without carrying its state. -
CredentialAddress—(user_ref, credential_type): the LCM-side address the coordinator and sync engine key on.user_refis the slot number today. -
SlotCredential— frozen value with factoriesempty(),unreadable()(write-only locks report this on occupied slots),known(pin). -
WriteResult— outcome of a credential write:NO_CHANGE/CONFIRMED/OPTIMISTIC(ambiguous write treated as completed but awaiting confirmation). -
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(slots=None) -> 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.
The slot number is pinned to the device credential index for providers where the LCM number IS the lock's index (credential_index_follows_slot, the default — Z-Wave passes it as credential_slot, and every slot-only provider keys its code by slot). On those locks a slot beyond the advertised capacity is rejected before the write (_assert_slot_within_capacity). Matter is the exception: the lock auto-allocates the credential index, so the slot number is an LCM-side label recovered from the user tag, with no device-side bound.
Stores dict[CredentialAddress, SlotCredential] mapping each credential address (slot number + credential type; PIN-only today) to its state, for ALL slots on the lock. Does not distinguish managed vs unmanaged — that distinction lives in the config entries. Also tracks which addresses are verified: an optimistic write stays unverified until a push event or hard-refresh read confirms it.
- 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 update method → provider's
async_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 a user in any LCM config entry that includes this lock is assigned to it.
Detection: is_slot_managed(code_slot) / find_entry_for_lock_slot(...) consult every entry's user → slot assignment.
Coordinator stores both managed and unmanaged; sync only operates on managed slots; the UI displays both. A one-time sweep at entry migration raises a repair for each occupied unmanaged slot, because allocation refuses to issue a number it can still read a code at — the person decides whether the code is theirs to keep or a stranded leftover.
Each user × lock pair gets a SlotSyncManager that compares desired state (PIN from the user's text entity, active state from the active binary sensor, which aggregates the enabled switch and any condition entity) against actual state (coordinator data), reconciling on a 2-second tick via a state machine (LOADING / IN_SYNC / OUT_OF_SYNC / SYNCING / PENDING_CONFIRMATION / SUSPENDED):
- If active + PIN differs from coordinator → set
- If inactive + code present in coordinator → clear
- If states match → in sync (no operation)
The in-sync binary sensor just displays the manager's state. SlotCredential.unreadable() (write-only locks like Matter) is in-sync only when the configured PIN matches the last PIN LCM successfully set — tracked in memory, so a restart re-sets masked slots rather than assuming them. An unreadable slot being cleared is trusted via last_write_was_clear, since the lock can neither confirm nor deny the clear.
For locks with a user-name field (Matter, Z-Wave User Credential CC, Akuvox, Schlage), LCM tags its own users with lcm:<slot>:<user name> so it can coexist with other controllers and recover the slot binding 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. When the canonical prefix doesn't fit, it falls back to slot-only digits (str(slot)); when even those don't fit, it returnsNoneand the seam refuses the write rather than create a user it could never re-identify. - Matter additionally tries a compact
lcm<slot>tier (alphanumeric-only, for firmwares that reject the colons) between canonical and slot-only when the lock rejects a candidate name. - 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-4.0 (pre-PR-1239/1240) users — Matter: an untagged user owning a PIN at
credential_index == slot; Z-Wave: an untagged user atuser_id == slot— on the first write to each slot after upgrade, so existing PINs don't get orphaned. Users already tagged for ANY slot are never adopted for another. - The tolerant parser in
_util.pyaccepts all four formats: canonicallcm:<slot>:<name>, compactlcm<slot>, slot-only digits, and the legacy[LCM:<slot>] <name>form (read-only; the next write rewrites it to canonical).
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 lock-side user is removed only when the LCM user (and so the slot) 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 a user record to anchor, but release still clears the credential — otherwise a deleted user's PIN keeps opening the door and the slot number can never be reissued. The delete_user service can opt out with clear_credentials: false, handing the programmed code over instead of deleting it.
When someone clears the PIN text entity on an enabled user, the write is applied as a single config update that also disables the user (the enabled switch turns off). This ensures the sync logic will clear the code on the lock rather than leaving a stale code active. Enabling a user without a PIN is rejected outright.
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 last-set tracking — an occupied-but-unreadable slot is in-sync only while the configured PIN matches the last PIN LCM set on it. A masked write returns
WriteResult.OPTIMISTICand sits inPENDING_CONFIRMATIONuntil a push event or hard-refresh read confirms occupancy. -
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. -
Firmware-side rejections surfaced from the write itself (Z-Wave JS's
credential_rejected_duplicateerror, Matter'sduplicatestatus, Schlage's duplicate-name errors): safety net for duplicates the pre-flight check can't see (e.g., unreadable codes on unmanaged slots, or a race with a keypad-entered code). Matter self-heals one case — a sync-path duplicate on a credential LCM already owns is cleared and retried rather than surfaced.
Both paths disable the user (turn off the enabled switch) and raise a repair issue.
Each user × lock pair has a circuit breaker: MAX_SYNC_ATTEMPTS failures within SYNC_ATTEMPT_WINDOW suspend the slot (with a repair issue naming the lock and, where the provider can measure it, its link health). Counted failures are SET operations that don't converge against the lock readback, LockOperationFailed errors, and optimistic writes whose confirmation never arrives; clears that succeed are not counted.
LockDisconnected is transient: the tick simply retries, and repeated connectivity failures feed the lock-level breaker, which backs off polling until the lock is reachable again. A suspended slot stays suspended until the desired target changes (PIN edited, user toggled) or it comes back into sync; the breaker resets when the slot syncs or is disabled.
LockCodeManagerError # base: LCM-internal error
└── LockCodeManagerProviderError # base: provider-side failure
├── LockDisconnected # transient: route to retry
├── LockOperationFailed # lock reachable but operation didn't take
│ └── LockOperationUnsupported # request the lock can never accept (e.g. slot out of range)
├── 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 sync manager catches them and routes:
-
LockDisconnected→ retry on next tick, feed the lock-level breaker -
CodeRejectedError/DuplicateCodeError→ disable the user, repair issue -
LockOperationFailed→ retry on next tick, feed the slot breaker -
LockOperationUnsupported→ suspend immediately (retrying can't help), repair issue
Do NOT raise bare Exception or HomeAssistantError from providers — those bypass the routing (an unexpected error suspends the slot and asks for a bug report).
@final internal wrappers are the single entry point for all lock operations:
-
async_internal_set_usercode(slot, pin, name, source)— top-level "ensure this slot has this PIN" -
async_internal_clear_usercode(slot, source)— top-level "clear this slot" -
async_internal_get_usercodes()— rate-limited read;async_get_usercodes()is default-implemented as a projection overasync_get_users()
They enforce cross-cutting concerns in this order:
-
Slot capacity check (set only) — reject slots beyond the lock's advertised range (
LockOperationUnsupported) - 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 — skipped when the write reported
NO_CHANGEand always for push-based providers (they update viapush_update()); anOPTIMISTICresult records a pending write and triggers a confirmation read instead
-
_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, ...); returns the provider'sWriteResult. Rolls back a newly-created user if the credential write fails, and refuses the write entirely when no stable slot tag fits the lock's name length. 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 digits or returnsNonefor length-constrained locks.
Slot-only providers implement:
async_set_credential(user_id, credential, pin, *, name, source) -> WriteResultasync_delete_credential(ref) -> bool-
async_get_users(slots=None) -> list[User](typically viauser_from_slot) -
async_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-
async_release_managed_slot(slot)override (delete the anchoring user; the lock's cascade removes its credentials)
Helper methods available to all providers:
-
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 -
last_write_was_clear(code_slot)— the only evidence a clear landed on a lock that won't say what a slot holds
The source parameter ("sync" or "direct") indicates whether the call came from the sync path or a user action (websocket / service). Providers may branch on it: Matter resolves a sync-path duplicate on a credential LCM already owns by clearing and retrying, while a direct write surfaces the error to the caller.
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 (via _serialize_sequence()) is available for read-modify-write sequences that span multiple primitive calls (e.g., Akuvox's and Schlage's list-then-modify patterns) without deadlocking on the inner per-call lock.
WebSocket subscriptions for 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 (users 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-user, lcm-lock-codes, lcm-add-user) for managing users and their PINs. lcm-slot remains registered as a deprecated alias of lcm-user that takes slot instead of name.
- 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
- Managing Guests and Rentals
- Services and Actions
- Blueprints
- Tracking lock state change events
- Using Condition Entities
- Unsupported Condition Entities
Advanced
Development
Troubleshooting
FAQ
Supported Integrations