-
-
Notifications
You must be signed in to change notification settings - Fork 16
Provider State Management
This guide explains how Lock Code Manager providers expose user/credential state to the coordinator and the sync seam: the domain model providers speak in, the three update modes (push / poll / drift), and the exception-routing contract.
The LockUsercodeUpdateCoordinator manages state through three update modes:
| Mode | Mechanism | When to Use |
|---|---|---|
| Poll for updates | Periodic async_get_usercodes()
|
Integrations without real-time events (Akuvox, Schlage, Virtual) |
| Push for updates | Real-time event subscription | Integrations with push (Z-Wave JS, Matter, ZHA, Zigbee2MQTT) |
| Poll for drift | Periodic async_hard_refresh_codes()
|
Catch out-of-band changes (keypad, other controllers) |
All modes include an initial poll to populate coordinator data.
Important: even with push enabled, you must implement
async_get_users()(or the legacyasync_get_usercodes()). The coordinator calls it for the initial load and any manual refresh request.
Coordinator data is dict[int, SlotCredential]. Each entry maps an LCM slot number to a SlotCredential:
-
SlotCredential.empty()— slot has no credential -
SlotCredential.unreadable()— slot is occupied but the value is masked or write-only -
SlotCredential.known(pin)— slot has this readable PIN value
Providers expose this in two ways:
-
async_get_users() -> list[User](the new path, post-4.0.0) — return a list ofUserobjects each carryingcredentials: list[Credential]. The base class'sasync_get_usercodes()projects these todict[int, SlotCredential]viauser.pin_credentials. Native-user providers map the lock's actual users; slot-only providers synthesize a single-credential user per occupied slot usinguser_from_slot(slot, state). -
async_get_usercodes() -> dict[int, SlotCredential](default-implemented) — the projection layer. Override only if you have a faster path to slot data than walking users.
┌─────────────────────────────────────────────────────────────────┐
│ LockUsercodeUpdateCoordinator │
├─────────────────────────────────────────────────────────────────┤
│ data: dict[int, SlotCredential] # slot -> credential │
├─────────────────────────────────────────────────────────────────┤
│ async_get_usercodes() # poll method │
│ push_update({slot: SlotCredential}) # push entry point │
│ _async_drift_check() # hard refresh timer │
│ _async_connection_check() # connection poll timer │
└─────────────────────────────────────────────────────────────────┘
│
│ calls (via BaseLock seam)
▼
┌─────────────────────────────────────────────────────────────────┐
│ BaseLock │
├─────────────────────────────────────────────────────────────────┤
│ Properties: │
│ supports_push: bool # opt in to push mode │
│ supports_native_users: bool # user lifecycle path │
│ usercode_scan_interval: timedelta # polling interval │
│ hard_refresh_interval: timedelta # drift detection │
│ connection_check_interval: timedelta # connection polling │
├─────────────────────────────────────────────────────────────────┤
│ Always implement: │
│ async_get_users() -> list[User] │
│ async_set_credential(user_id, credential, pin, ...) -> bool │
│ async_delete_credential(ref) -> bool │
│ is_integration_connected() -> bool │
├─────────────────────────────────────────────────────────────────┤
│ Native-user providers also implement: │
│ async_set_user(user) -> SetUserResult │
│ async_delete_user(user_id) -> None │
│ async_get_capabilities() -> LockCapabilities │
├─────────────────────────────────────────────────────────────────┤
│ Optional: │
│ async_hard_refresh_codes() # re-fetch from device │
│ subscribe_push_updates() # set up listeners │
│ unsubscribe_push_updates() # clean up listeners │
│ async_release_managed_slot(slot) # slot teardown hook │
└─────────────────────────────────────────────────────────────────┘
│
│ implements
▼
┌─────────────────────────────────────────────────────────────────┐
│ YourLockProvider │
└─────────────────────────────────────────────────────────────────┘
Providers split into two flavors based on the lock's user model:
-
Slot-only — the lock indexes credentials by slot directly with no user concept (Z-Wave User Code CC, ZHA, Zigbee2MQTT, Virtual) OR the user is the credential's friendly name (Akuvox, Schlage). Set
supports_native_users = False(the default). LCM addresses credentials by slot;user_idinasync_set_credentialis ignored. -
Native-user — the lock has a real user record that owns one or more credentials (Matter, Z-Wave User Credential CC). Set
supports_native_users = True. LCM follows a user-first lifecycle:async_set_userthenasync_set_credential. Implementasync_get_capabilities()so the seam can decide whether to write a user record before the credential.
The seam (_set_credential / _delete_credential in BaseLock) routes the orchestration automatically; you just implement the primitives.
The coordinator periodically calls async_get_usercodes() at usercode_scan_interval.
Flow:
- Timer fires at
usercode_scan_interval - Coordinator calls
async_internal_get_usercodes() - Base class applies rate limiting, then calls
async_get_usercodes()(which by default projectsasync_get_users()) - Provider returns current credential state
- Coordinator updates
dataand notifies listening entities
When to use: integrations without push event support.
Example:
class MyLock(BaseLock):
@property
def usercode_scan_interval(self) -> timedelta:
return timedelta(minutes=1)
@property
def hard_refresh_interval(self) -> timedelta | None:
return None # No separate drift check neededFor integrations that emit real-time events (Z-Wave value updates, Matter LockUserChange, ZHA programming events, Zigbee2MQTT MQTT messages).
Flow:
- Provider subscribes to integration events in
subscribe_push_updates() - An event fires
- Provider's handler resolves the LCM slot (for native-user providers under the tag identity model, this means walking
userIndex → user.name → lcm:<slot>:tag) and theSlotCredentialvalue - Provider calls
self.coordinator.push_update({slot: SlotCredential}) - Coordinator merges and notifies entities immediately
When to use: any integration with push. Cuts polling overhead and gives the UI real-time feedback.
Example:
class MyLock(BaseLock):
@property
def supports_push(self) -> bool:
return True
@property
def hard_refresh_interval(self) -> timedelta | None:
return timedelta(hours=1) # Still recommended for driftDrift detection re-reads the lock to catch changes the push path missed (keypad-enrolled codes, races, other controllers).
Flow:
- Timer fires at
hard_refresh_interval - Coordinator calls
async_internal_hard_refresh_codes() - Provider queries the device directly, bypassing any cache (
async_hard_refresh_codes()) - Coordinator updates and notifies entities if anything changed
When to use:
- Codes can be changed outside Home Assistant
- The integration caches values that can go stale
- You want a backstop even with push mode
| Property | Default | Purpose |
|---|---|---|
supports_push |
False |
Opt-in to push mode (disables polling for updates; polling for drift still applies) |
supports_native_users |
False |
Opt-in to the user lifecycle path |
usercode_scan_interval |
1 minute | How often to poll for updates (ignored if supports_push=True) |
hard_refresh_interval |
None |
How often to hard refresh for drift detection (None = disabled) |
connection_check_interval |
30 seconds | How often to check connection state (None = disabled, used when the integration emits config-entry state changes) |
Return the lock's users with their credentials.
from custom_components.lock_code_manager.domain.credentials import (
Credential,
CredentialType,
User,
user_from_slot,
)
from custom_components.lock_code_manager.domain.models import SlotCredential
# Slot-only: synthesize one user per occupied slot.
async def async_get_users(self) -> list[User]:
"""Read current credential state from the device."""
try:
raw = await self._device.get_all_codes()
except SomeDeviceError as err:
raise LockDisconnected(f"get codes failed: {err}") from err
return [
user_from_slot(
slot,
SlotCredential.known(pin) if pin else SlotCredential.empty(),
)
for slot, pin in raw.items()
]
# Native-user: return the lock's actual users.
async def async_get_users(self) -> list[User]:
"""Read users + credentials from the lock."""
raw_users = await self._driver.list_users()
raw_creds = await self._driver.list_credentials()
users_by_id = {
u.user_id: User(user_id=u.user_id, name=u.name, active=u.active)
for u in raw_users
}
for cred in raw_creds:
owner = users_by_id.get(cred.user_id)
if owner is None:
continue
owner.credentials.append(
Credential(
type=CredentialType.PIN,
slot=cred.slot,
state=(
SlotCredential.known(cred.value)
if cred.value
else SlotCredential.unreadable()
),
)
)
return list(users_by_id.values())Write a single credential. The base seam has already resolved user_id (for native-user providers, via the find-or-create-by-tag in async_set_user).
async def async_set_credential(
self,
user_id: int,
credential: Credential,
pin: str,
*,
name: str | None,
source: Literal["sync", "direct"],
) -> bool:
"""Write a PIN to a slot; return whether the lock changed."""
try:
await self._driver.set_pin(
user_id=user_id,
slot=credential.slot,
pin=pin,
)
except DuplicateError as err:
raise DuplicateCodeError(
code_slot=credential.slot,
lock_entity_id=self.lock.entity_id,
) from err
except DeviceError as err:
# Transient: route to retry, not slot suspension.
raise LockDisconnected(
f"set credential slot {credential.slot} failed: {err}"
) from err
return TrueReturn True if the value changed, False if the lock confirmed it was already set. When you can't tell (write-only locks like Matter), return True — the seam uses this to drive a coordinator refresh.
async def async_delete_credential(self, ref: CredentialRef) -> bool:
"""Delete the credential addressed by ref."""
try:
await self._driver.clear_credential(
user_id=ref.user_id,
slot=ref.slot,
)
except DeviceError as err:
raise LockDisconnected(
f"delete credential slot {ref.slot} failed: {err}"
) from err
return TrueNote: the seam does not delete the owning user after async_delete_credential returns. The user is a persistent slot anchor (see Architecture for the lifecycle rationale). User deletion happens via async_release_managed_slot(slot).
Find-or-create the lock user for the LCM slot encoded in user.name.
from custom_components.lock_code_manager.providers._util import parse_tag
async def async_set_user(self, user: User) -> SetUserResult:
"""Find-or-create the lock user for the LCM slot in user.name."""
slot = self._slot_from_seam_user(user)
existing_user_id = await self._find_user_index_for_slot(slot)
try:
result = await self._driver.set_user(
user_id=existing_user_id, # None on CREATE
user_name=user.name,
active=user.active,
)
except DeviceError as err:
raise LockDisconnected(f"set user for slot {slot} failed: {err}") from err
return SetUserResult(
user_id=result["user_id"], created=existing_user_id is None
)
def _slot_from_seam_user(self, user: User) -> int:
"""Recover LCM slot from the tagged user name; fall back to user_id."""
if user.name:
slot, _ = parse_tag(user.name)
if slot is not None:
return slot
return user.user_id
async def _find_user_index_for_slot(self, slot: int) -> int | None:
"""Return the lock user_id LCM owns for slot, if any.
1. Canonical pass: a user whose name parses to this LCM slot.
2. Legacy adoption: an untagged user at the pre-PR-C invariant
(user_id == slot for U3C; credential_index == slot for Matter)
that also owns a PIN at the slot. Skip users already tagged for
a different slot so we don't re-bind cross-slot.
"""
users = await self.async_get_users()
try:
return next(
u.user_id
for u in users
if u.name and parse_tag(u.name)[0] == slot
)
except StopIteration:
return next(
(
u.user_id
for u in users
if u.user_id == slot
and parse_tag(u.name or "")[0] is None
for cred in u.pin_credentials
if cred.slot == slot
),
None,
)The legacy adoption pass is what makes pre-4.0.0 installs upgrade seamlessly without orphaning existing PINs.
async def async_delete_user(self, user_id: int) -> None:
"""Delete a lock user (cascades its credentials per spec)."""
try:
await self._driver.delete_user(user_id)
except DeviceError as err:
raise LockDisconnected(f"delete user {user_id} failed: {err}") from errasync def async_get_capabilities(self) -> LockCapabilities:
"""Report lock capabilities used by the seam to gate the user path."""
caps = await self._driver.get_capabilities()
return LockCapabilities(
supports_user_management=caps["supports_user_management"],
max_users=caps["max_users"],
credential_types={
CredentialType.PIN: CredentialTypeCapability(
num_slots=caps["pin_num_slots"],
min_length=caps["pin_min_length"],
max_length=caps["pin_max_length"],
supports_learn=False,
)
},
max_user_name_length=caps.get("max_user_name_length", 0),
)The seam reads supports_user_management and max_user_name_length to decide whether the user-lifecycle path applies, and to size the tagged user name.
def is_integration_connected(self) -> bool:
"""Return whether the integration is connected."""
return self.lock_config_entry.state == ConfigEntryState.LOADED@property
def supports_push(self) -> bool:
return True@callback
def subscribe_push_updates(self) -> None:
"""Subscribe to real-time updates. Must be idempotent."""
if self._event_unsub is not None:
return
@callback
def on_code_changed(event_data) -> None:
slot = event_data["slot"]
state = (
SlotCredential.known(event_data["pin"])
if event_data.get("pin")
else SlotCredential.empty()
)
if self.coordinator:
self.coordinator.push_update({slot: state})
self._event_unsub = self._device.subscribe(on_code_changed)@callback
def unsubscribe_push_updates(self) -> None:
"""Unsubscribe. Must be idempotent."""
if self._event_unsub:
self._event_unsub()
self._event_unsub = NoneFor native-user providers using lcm:<slot>: tags, the push handler must resolve the LCM slot via the owning user's tag, not via lock-side indices that may differ from LCM slots. See the Matter provider's _dispatch_lock_user_change for the canonical pattern: extract userIndex from the event, fetch the raw user list, parse its name's tag, and push to the resolved LCM slot.
Providers must raise LockCodeManagerProviderError subclasses for lock-side failures. The seam routes each subclass differently:
| Exception | When to raise | Seam behavior |
|---|---|---|
LockDisconnected |
Transient communication failure (lock asleep, integration not loaded, network glitch) | Retry with backoff |
LockOperationFailed |
Lock reachable but the operation didn't take (rejected input, response timeout) | Log + propagate |
CodeRejectedError |
Lock will not accept this PIN (any reason) | Disable slot, persistent notification |
DuplicateCodeError |
PIN duplicates another slot's value | Disable slot, persistent notification (subclass of CodeRejectedError) |
ProviderNotImplementedError |
A primitive is missing on a subclass (raised by BaseLock defaults) |
Programmer error; do not raise from real provider code |
from ..domain.exceptions import (
CodeRejectedError,
DuplicateCodeError,
LockDisconnected,
LockOperationFailed,
)
async def async_set_credential(self, user_id, credential, pin, *, name, source):
try:
await self._driver.set_pin(user_id, credential.slot, pin)
except TransientError as err:
raise LockDisconnected(f"set slot {credential.slot}: {err}") from err
except DuplicatePinError as err:
raise DuplicateCodeError(
code_slot=credential.slot,
lock_entity_id=self.lock.entity_id,
) from err
except RejectedError as err:
raise CodeRejectedError(
code_slot=credential.slot,
lock_entity_id=self.lock.entity_id,
reason=str(err),
) from err
return TrueDo not raise bare Exception or HomeAssistantError from providers — those bypass the routing.
The base class provides automatic rate limiting through _execute_rate_limited(). The @final async_internal_* methods apply it:
-
async_internal_get_usercodes()— rate-limited get -
async_set_usercode()— rate-limited set + refresh -
async_clear_usercode()— rate-limited clear + refresh -
async_internal_hard_refresh_codes()— rate-limited hard refresh
Default delay between operations is 2 seconds (MIN_OPERATION_DELAY).
For read-modify-write sequences (e.g., Akuvox's list-then-modify pattern), there is an outer _sequence_lock that providers can acquire to make the whole sequence atomic without deadlocking on the inner per-call lock.
The base class handles connection state transitions:
-
Reconnection detection: when
is_integration_connected()transitions fromFalsetoTrue:- Coordinator refresh is triggered
- Push subscriptions are re-established (if
supports_push=True)
-
Disconnection handling: when it transitions from
TruetoFalse:- Push subscriptions are cleaned up
-
Config entry state changes: for integrations that expose config entry state (Z-Wave JS, Matter):
- The base class listens for state changes
- Automatically re-runs setup when the integration reloads
For these "rich-state" integrations, set connection_check_interval = None since the connection event drives the transitions.
When a slot is removed from LCM config, the base seam calls async_release_managed_slot(slot) on each provider that manages that slot. The default implementation is a no-op. Native-user providers override to remove the persistent user anchor for the slot (and any remaining credentials cascade per spec).
async def async_release_managed_slot(self, slot: int) -> None:
"""Remove the lock user that anchors this LCM slot."""
user_id = await self._find_user_index_for_slot(slot)
if user_id is not None:
await self.async_delete_user(user_id)Wrap in try/except LockDisconnected — slot teardown can't block on lock connectivity.
- Prefer push when the integration supports events — more responsive, less device traffic, less mesh load.
- Enable drift detection when codes can change outside Home Assistant; pair it with push.
-
Cache appropriately —
async_get_users()may return cached data;async_hard_refresh_codes()must bypass cache. -
Use the typed exceptions —
LockDisconnectedfor transient,CodeRejectedErrorfor permanent rejections,LockOperationFailedfor everything in between. - Make subscriptions idempotent — both subscribe and unsubscribe may be called multiple times.
-
Return
Truewhen uncertain — for write-only locks, returningTruefrom set/delete primitives drives a coordinator refresh; returningFalseskips it. -
Use the orchestration helpers — call the
@finalseam methods (async_set_usercode/async_clear_usercode), not the primitives directly. The seam owns rate limiting, connection checks, and coordinator routing. -
Resolve LCM slots, not lock indices, in push handlers — for native-user providers under the tag identity model, the lock's user_id / credential_index may differ from the LCM slot. Always resolve via the owning user's
lcm:<slot>:tag.
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