Skip to content
raman325 edited this page Jun 10, 2026 · 2 revisions

Lock Code Manager Architecture

Overview

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.

Data Flow

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)

Domain Model: User → Credential

LCM speaks a platform-neutral language at the seam: users own credentials.

  • Useruser_id, name, active, credentials: list[Credential]. The lock's identity for a person.
  • Credentialtype (CredentialType.PIN today; 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 factories empty(), 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.

Coordinator

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 vs Poll vs Hard Refresh

  • Push: device event → provider filters → coordinator.push_update({slot: SlotCredential})
  • Poll: coordinator's _async_update_data() → provider's async_get_usercodes() on interval (default-implemented in BaseLock as a projection over async_get_users(); providers may override for efficiency)
  • Hard refresh: async_hard_refresh_codes() → bypasses any cache and re-reads from the device. Triggered periodically via hard_refresh_interval for drift detection, and on demand via the hard_refresh_usercodes service.

Managed vs Unmanaged Slots

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.

Sync Decision

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.

User-Tag Identity Model (4.0.0+)

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 fit max_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 at user_id == slot) on the first write to each slot after upgrade, so existing PINs don't get orphaned.
  • The tolerant parser in _util.py accepts both the canonical lcm:<slot>:<name> form and the legacy [LCM:<slot>] <name> form (Akuvox/Schlage); legacy-tagged codes are rewritten to canonical on next write.

Persistent User Anchor Lifecycle (Native-User Providers)

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.

PIN Clearing Auto-Disables Slots

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.

Masked / Unreadable PINs

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 — unreadable just signals "slot in use."

Duplicate Code Detection

Two layers of defense against duplicate PINs causing infinite sync loops:

  1. Pre-flight check (in BaseLock): scans coordinator data for matching readable PINs before sending to the lock. Raises DuplicateCodeError. Unreadable values are skipped because they can't be compared.
  2. Provider-side duplicate signals (Z-Wave JS event 15, Matter's DuplicateCode status, 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.

Sync Attempt Tracking

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.

Exception Hierarchy

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.

Base Provider (BaseLock)

@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 over async_get_users()

They enforce cross-cutting concerns in this order:

  1. Integration connectivity check — verify integration is connected
  2. Device availability check — verify physical device is responsive
  3. Acquire operation lock — serialize operations via asyncio.Lock
  4. Pre-execute hook (set only) — duplicate code check runs inside the lock
  5. Rate limit delay — minimum delay between operations
  6. Provider primitives_set_credential / _delete_credential orchestration helpers
  7. Coordinator refresh — update state (skipped for push-based providers that report True from their primitive)

Orchestration helpers (also @final)

  • _set_credential(user, credential, pin, *, name, source) — for native-user providers, builds the tagged user name, calls async_set_user(user_for_write), then async_set_credential(user_id, credential, pin, ...). Rolls back a newly-created user if the credential write fails. For slot-only providers, just calls async_set_credential.
  • _delete_credential(ref) — calls async_delete_credential(ref). Does NOT delete the owning user; the user is a persistent slot anchor (see lifecycle section above) and is removed via async_release_managed_slot.
  • _build_tagged_user_name(slot, display) — produces lcm:<slot>:<display>, truncates to fit max_user_name_length, falls back to slot-only or returns None for length-constrained locks.

Provider primitives (overridden per integration)

Slot-only providers implement:

  • async_set_credential(user_id, credential, pin, *, name, source) -> bool
  • async_delete_credential(ref) -> bool
  • async_get_users() -> list[User] (typically via user_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) -> None
  • async_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.

Rate Limiting

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

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.

Lovelace Dashboard Updates

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.

Frontend

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.

See Also

Clone this wiki locally