Repository navigation
Provider State Management
This guide explains how a Lock Code Manager provider exposes user/credential state to the core: how the core calls a provider, the rules a read must follow, push / poll / drift, the exception contract, and the optional hooks. For the core itself, see Architecture.
Every lock has one LockWorker (sync/worker.py) that owns every exchange with it. The worker does not call providers directly: ProviderPort (ha/adapter.py) wraps a BaseLock and gives the worker a read, a write, and pushes. The provider is also handed a small stand-in coordinator (ProviderCoordinator in providers/_base.py) that turns its pushes and refresh requests into worker jobs.
What the core calls on a provider:
| Core action | Provider call |
|---|---|
| Read slots (first read, read-back, poll, drift, probe) |
async_get_usercodes(slots), or async_hard_refresh_codes(slots) when fresh_read_is_hard_refresh is True
|
| Put a PIN in a slot | async_set_usercode(slot, pin, name, source="sync") |
| Clear a slot | async_clear_usercode(slot) |
| Release a slot no entry manages any more | async_release_managed_slot(slot, written=...) |
hard_refresh_usercodes service |
async_hard_refresh_codes() (unscoped), then a read |
| Before any read or write, if the provider never finished setup |
async_internal_is_reachable(), which retries a deferred setup |
async_set_usercode and async_clear_usercode are BaseLock orchestration over the primitives you implement (async_set_credential, async_delete_credential, and for native-user locks async_set_user). Do not override them.
Writes are serialized, paced and timed by the worker, not the provider. After every write the worker reads the slot back, and that read decides whether the slot is in sync.
A write is confirmed by reading its slot back. If the read comes from a cache that still holds the old value, a write that worked counts as a failure. So the read the core makes must ask the lock.
- If
async_get_usercodes/async_get_usersasks the device every time, nothing more is needed. - If your plain read serves a cache (Z-Wave JS reads the driver's value cache), set
fresh_read_is_hard_refresh = Trueand makeasync_hard_refresh_codes(slots)re-read the device. The core then reads through it. - If the device can't be read at all for a slot, leave that slot out (or mark it silent, below). The write is then trusted rather than contradicted.
A cached view can still reach the core as a push.
async_get_users(slots=None) and async_get_usercodes(slots=None) take the slots to report on; None means self.managed_slots. A provider that asks the lock about one index per round trip must honor it — the core asks for one or two slots for a read-back. A provider that reads the whole lock in one call may ignore it; the result may exceed the scope.
async_hard_refresh_codes(slots=None) re-reads slots from the device (None means everything) but always returns the full projection, every managed slot included. A provider with a cache re-reads only slots and projects the rest from the cache.
Getting this right is the whole job of a read. The wrong state is a defect, not an inaccuracy.
-
SlotCredential.known(pin)— the lock holds this code and is accepting it. A code the lock is refusing must not be reported this way: the slot would read in sync while the door stays shut. -
SlotCredential.empty()— confirmed clear. Only an explicit "nothing here" from the lock earns it. The core treats it as proof a clear landed, and allocation treats the index as free to hand out. -
SlotCredential.unreadable()— something is there and its value can't be trusted: a write-only code, a status you can't interpret, a slot the lock reports as disabled.
When in doubt, unreadable(). It costs a user one slot number; the other two mistakes cost them the code on their door.
Some reads reach the lock but learn nothing about a slot. Set these on the provider during each read (the port resets them before every read):
| Attribute | Meaning |
|---|---|
last_silent_slots: frozenset[int] |
Slots asked about that the lock said nothing about. The core reads them as silent, not unreadable. If every slot asked about is silent on a lock that has answered before, the read counts as no answer. |
last_cannot_tell_slots: frozenset[int] |
Of those, the slots the lock answered to say it cannot tell what they hold (Zigbee2MQTT not_supported_255, ZHA user status 0xFF). An answer, not an outage. |
last_foreign_slots: frozenset[int] |
Set by the base projection on native-user locks with user records: slots where only an untagged user holds a PIN. The core reads them as empty for that slot (someone else's code). |
A lock no read has ever described a slot of is one that "can't report its codes"; the config flows warn the user about it. See Architecture.
read_lag: ClassVar[float] (default 0.0) is how many seconds a read can trail a write — Schlage's cloud can serve the table from before the write. Until then, the slot just written reads as silent and the write is trusted.
┌─────────────────────────────────────────────────────────────────┐
│ LockWorker (sync/worker.py) via ProviderPort │
├─────────────────────────────────────────────────────────────────┤
│ read(slots) -> async_get_usercodes / async_hard_refresh_codes
│ write(slot, pin) -> async_set_usercode / async_clear_usercode │
│ / async_release_managed_slot │
│ pushes <- self._push_credential_update(slot, cred) │
└─────────────────────────────────────────────────────────────────┘
│
│ calls
▼
┌─────────────────────────────────────────────────────────────────┐
│ BaseLock │
├─────────────────────────────────────────────────────────────────┤
│ Properties: │
│ domain: str # integration domain │
│ supports_push: bool # opt in to push mode │
│ supports_native_users: bool # user lifecycle path │
│ credential_index_follows_slot: bool # slot IS device index │
│ usercode_scan_interval: timedelta # poll (non-push only) │
│ hard_refresh_interval: timedelta # drift read │
│ connection_check_interval: timedelta # connection polling │
├─────────────────────────────────────────────────────────────────┤
│ Always implement: │
│ async_get_users(slots=None) -> list[User] │
│ async_set_credential(user_id, credential, pin, ...) │
│ -> WriteResult │
│ async_delete_credential(ref) -> bool │
│ async_hard_refresh_codes(slots=None) │
│ -> dict[int, SlotCredential] │
├─────────────────────────────────────────────────────────────────┤
│ Native-user providers also implement: │
│ async_set_user(user) -> SetUserResult │
│ async_delete_user(user_id) -> None │
│ async_get_capabilities() -> LockCapabilities │
│ async_release_managed_slot(slot, *, written=False) │
├─────────────────────────────────────────────────────────────────┤
│ Optional: │
│ async_is_integration_connected() # default: entry LOADED │
│ async_is_device_available() # default: True │
│ async_setup(config_entry) # listeners; reruns │
│ setup_push_subscription() # set up listeners │
│ teardown_push_subscription() # clean up listeners │
│ describe_link_health() -> str | None # appended to repairs │
└─────────────────────────────────────────────────────────────────┘
│
│ 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 (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 JS, whose unified
node.access_controlAPI covers both User Code CC and User Credential CC locks). Setsupports_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 — a native-user provider can still serve a lock with no named users (capabilities reportingsupports_user_management=Falseormax_user_name_length=0skip the user path).
Orthogonal to this split is credential_index_follows_slot: True (the default) when the LCM slot number IS the lock's credential index (Z-Wave, every slot-only provider), so out-of-range slots are refused before anything is sent; False when the lock allocates its own index (Matter), making the slot number an LCM-side label with no device-side bound.
BaseLock turns the core's slot-shaped calls into calls on your primitives. You implement the primitives; you do not override any of this.
-
async_set_usercode(code_slot, usercode, name=None, source="direct")builds a PIN credential for the slot. Slot-only providers go straight toasync_set_credential(code_slot, credential, pin, ...), with the slot asuser_id. Native-user providers go through_set_credential. -
_set_credential(user, credential, pin, *, name, source)(@final) refuses a credential type the lock does not advertise (CodeRejectedError). On a lock with user records it builds the tagged user name with_build_tagged_user_name, callsasync_set_userwith it, then callsasync_set_credentialwith theuser_idthat came back. If that credential write raises andasync_set_userhad just created the user, it deletes the user again withasync_delete_user(a failed rollback is logged; the original error is raised). On a lock without user records (supports_user_managementfalse ormax_user_name_length0) it skips the user write. Returns yourWriteResult. -
_build_tagged_user_name(slot, display)produceslcm:<slot>:<display>, shortening only the display part to fitmax_user_name_length. If evenlcm:<slot>:does not fit, it uses the bare slot number; if that does not fit either, it returnsNoneand_set_credentialrefuses the write withLockOperationFailedrather than create a user it could never find again. -
async_clear_usercode(code_slot)on a slot-only provider callsasync_delete_credentialwith the slot as bothuser_idandslot. On a native-user provider it reads the users and looks for the user tagged for the slot that holds a PIN at that slot. On a lock without user records, an untagged user holding a PIN at the slot counts too. A clear never adopts an untagged user on a lock with user records. No owner means it returnsFalsewithout calling you; otherwise it calls_delete_credential. -
_delete_credential(ref)(@final) refuses a credential type the lock does not advertise, then callsasync_delete_credential(ref). It never deletes the owning user: the user stays as the slot's anchor untilasync_release_managed_slot.
ProviderPort schedules the provider's reads; each one is a worker job that reads every slot the worker tracks.
For providers with supports_push = False, a read every usercode_scan_interval. Home Assistant's "Disable polling" system option turns it off when every entry listing the lock has it set.
class MyLock(BaseLock):
@property
def usercode_scan_interval(self) -> timedelta:
return timedelta(minutes=1)For integrations that emit real-time events (Z-Wave value updates, Matter LockUserChange, ZHA programming events, Zigbee2MQTT MQTT messages). There is no scan poll for a push provider.
Flow:
- Provider subscribes to integration events in
setup_push_subscription() - 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._push_credential_update(slot, credential) - The worker takes it as a push job for that slot, in order
A push may come from a cache, so the core never treats it as a fresh read (it does not reset a slot's tries). A push about the slot the provider is writing at that moment is ignored as the write's own echo.
class MyLock(BaseLock):
@property
def supports_push(self) -> bool:
return True
@property
def hard_refresh_interval(self) -> timedelta | None:
return timedelta(hours=1) # the only periodic read a push provider getsA read every hard_refresh_interval, push or not (None disables it). It catches changes nothing pushed: keypad-enrolled codes, other controllers, a subscription that drifted off. It is a plain read through the worker, like any other.
The hard_refresh_usercodes service runs a probe read (which also brings a lock that is backing off back), then your unscoped async_hard_refresh_codes() in the lock's turn, then a read of the tracked slots. Use the unscoped call to re-read the whole lock, unmanaged slots included. With forget_reporting: true the service first forgets that the lock was ever seen describing a slot.
| Property | Default | Purpose |
|---|---|---|
supports_push |
False |
Opt in to push mode (no scan poll; the drift read still applies) |
supports_native_users |
False |
Opt in to the user lifecycle path |
credential_index_follows_slot |
True |
Whether the LCM slot number is the device credential index (False = lock allocates its own, e.g. Matter; disables the slot-capacity check) |
usercode_scan_interval |
1 minute | How often to poll (ignored if supports_push=True) |
hard_refresh_interval |
None |
How often to run the drift read (None = disabled) |
connection_check_interval |
30 seconds | How often to call async_internal_is_reachable() (None = disabled, for integrations whose config-entry state changes drive reconnects) |
supports_code_slot_events |
True |
Whether the lock reports which slot was used. Diagnostics only |
fresh_read_is_hard_refresh (class var) |
False |
The core reads through async_hard_refresh_codes(slots) instead of async_get_usercodes(slots)
|
check_available_before_exchange (class var) |
False |
Ask async_internal_is_reachable() before every read and write, and send nothing to an unavailable device (Z-Wave queues commands for a dead node) |
read_lag (class var) |
0.0 |
Seconds a read can trail a write |
per_exchange_budget (class var) |
None |
Seconds one exchange may take, for providers whose reads walk slots one exchange at a time; the read deadline becomes the larger of OPERATION_TIMEOUT (600 s) and this times the slots walked |
_min_operation_delay (dataclass field) |
2.0 |
The gap the worker leaves between exchanges with this lock |
The examples import from the provider's package (custom_components/lock_code_manager/providers/).
Return the lock's users with their credentials.
from collections.abc import Collection
from ..model.credentials import (
Credential,
CredentialType,
SlotCredential,
User,
user_from_slot,
)
from ..model.exceptions import LockDisconnected
# Slot-only: synthesize one user per slot read.
async def async_get_users(self, slots: Collection[int] | None = None) -> 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, slots: Collection[int] | None = None) -> 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())The base projection (async_get_usercodes) seeds every slot in scope as empty() and overlays what you return, so a slot you leave out of a whole-lock read reads as empty. Only report empty() (or leave a slot out of a whole-lock read) when the lock said so.
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).
from typing import Literal
from ..model.credentials import Credential, WriteResult
from ..model.exceptions import DuplicateCodeError, LockDisconnected
async def async_set_credential(
self,
user_id: int,
credential: Credential,
pin: str,
*,
name: str | None,
source: Literal["sync", "direct"],
) -> WriteResult:
"""Write a PIN to a slot; return the write outcome."""
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:
raise LockDisconnected(
f"set credential slot {credential.slot} failed: {err}"
) from err
return WriteResult.CONFIRMEDThe arguments:
-
user_id— on a native-user lock with user records, theuser_idyourasync_set_userreturned. Slot-only providers ignore it and address the credential bycredential.slot. -
credential— the credential to write;credential.slotis the LCM slot number. -
pin— the PIN as a string, already checked readable by the base (_require_readable_pin). Do not add your own check. -
name— the user's name. Slot-only providers use it (for example as the code's name on the lock); native-user providers carry the name on the user record and may treat this as advisory.Nonemeans leave any existing name unchanged, never clear it. -
source— the core always passes"sync". Matter uses it to clear and retry a duplicate on a credential LCM already owns.
Return WriteResult.NO_CHANGE if the lock confirmed the value was already set, WriteResult.CONFIRMED when the lock acknowledged the write, and WriteResult.OPTIMISTIC when the result is ambiguous (the write may have landed but is unconfirmed, for example on a write-only lock). The core does not act on the result — the read-back decides — but return it accurately.
Raise DuplicateCodeError when the lock rejects the PIN as a duplicate and CodeRejectedError when it refuses it for any other reason; see Exception Routing for the rest.
from ..model.credentials import CredentialRef
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 TrueSlot-only providers address the credential by ref.slot and ignore ref.user_id. Native-user providers get the owning user the base resolved by tag in ref.user_id.
Return False only when the lock answered that there was nothing to delete; a delete that found nothing is not evidence the slot is empty. When you can't tell, return True. Either way the core reads the slot back, but the answer matters: a False is not recorded as a clear LCM made, so a slot the lock will not show or says nothing about is not trusted as cleared on the strength of it.
The seam does not delete the owning user after async_delete_credential returns. The user is a persistent slot anchor, removed only by async_release_managed_slot.
Find-or-create the lock user for the LCM slot encoded in user.name.
-
user.nameis the tagged name the base built (lcm:<slot>:<display>, or the bare slot number on a lock whose name limit is too short for the prefix). Write it verbatim. ANonename means leave the existing name unchanged, not clear it. - Return
SetUserResult(user_id, created).user_idis passed to theasync_set_credentialcall that follows, so if the lock allocates the identifier, return the one it allocated.createdisTrueonly when this call added a new user; the base uses it to delete that user again if the credential write fails. - The base calls this before writing the credential because a native-user lock writes a credential under an existing user (the Z-Wave set-credential command requires one).
Find the user in two passes:
-
By tag. A user whose name carries LCM's tag for this slot. Read the tag with
self.slot_tag(user), notparse_tag: a bare-digit name counts as LCM's only on a lock whose name limit cannot fitlcm:<slot>:, which is the only place LCM writes one. Anywhere else a user named3is the homeowner's.slot_tagjudges from the cached capabilities, so make sure they are loaded first. -
Legacy adoption (set path only). An untagged user at the position your lock used for this slot before 4.0.0 that owns a PIN at the slot:
user_id == sloton Z-Wave,credential_index == sloton Matter. Without it, an upgrade creates a second user and leaves the old PIN active. Skip users tagged for any slot, so a user tagged for slot A is never adopted for slot B. Clears and releases never adopt: the release path passesadopt_untagged=False.
from ..model.credentials import SetUserResult
from ._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, *, adopt_untagged: bool = True
) -> int | None:
"""Return the lock user_id LCM owns for slot, if any."""
users = await self.async_get_users()
await self._get_cached_capabilities() # what slot_tag weighs
try:
return next(u.user_id for u in users if self.slot_tag(u) == slot)
except StopIteration:
if not adopt_untagged:
return None
# Replace `u.user_id == slot` with your lock's pre-4.0.0 position.
return next(
(
u.user_id
for u in users
if u.user_id == slot and self.slot_tag(u) is None
for cred in u.pin_credentials
if cred.slot == slot
),
None,
)ZWaveJSLock._find_user_index_for_slot in providers/zwave_js.py is the real implementation.
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 errDeleting a user cascades to every credential on it, including any added in the vendor's app. The base calls this only to roll back a user async_set_user just created when the credential write after it fails; your own async_release_managed_slot override calls it when LCM's PIN is all the user holds.
from ..model.credentials import CredentialTypeCapability, LockCapabilities
async 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 (both must be set: supports_user_management true and max_user_name_length above 0), and to size the tagged user name. It also refuses to write or delete a credential type missing from credential_types. Setup for a native-user provider fails validation (with a lock_setup_failed repair) when the lock does not advertise CredentialType.PIN. The PIN slot count also bounds credential_index_follows_slot locks.
The base caches the result for the provider's lifetime; a reload builds a new provider and probes again. cached_capabilities returns the cached value (or None) without asking the lock.
from homeassistant.config_entries import ConfigEntryState
async def async_is_integration_connected(self) -> bool:
"""Return whether the integration is connected."""
return self.lock_config_entry.state == ConfigEntryState.LOADEDThe base default already does exactly this (and raises LockCodeManagerError when the lock has no config entry, so such providers must override it). Override only for integration-specific connection signals. The device layer has its own hook, async_is_device_available() (default True) — the lock is reachable only when both are up.
@property
def supports_push(self) -> bool:
return TrueOverride setup_push_subscription() (the base's @final subscribe_push_updates() wraps it with error handling and retry-on-reconnect). Register each unsub callable via _register_push_unsub() so the base can release everything on teardown.
from homeassistant.core import callback
@callback
def setup_push_subscription(self) -> None:
"""Subscribe to real-time updates. Must be idempotent."""
if self._push_unsubs:
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()
)
self._push_credential_update(slot, state)
self._register_push_unsub(self._device.subscribe(on_code_changed))@callback
def teardown_push_subscription(self) -> None:
"""Unsubscribe. Must be idempotent."""
self._clear_push_unsubs()If an event handler must ask the lock something before it can push (Matter reads the user list to resolve a LockUserChange), run that exchange through the worker so it takes its turn and stops when the worker stops:
raw_users = await self.coordinator.async_in_turn(self._raw_lock_users)async_in_turn(call) runs call in the lock's turn and returns its result. Your own provider error comes back as raised; a worker that has stopped or is backing off answers LockDisconnected without asking the lock.
For 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 (from model/exceptions.py) for lock-side failures. On a write, ha/adapter.py lock_error() maps each to what the worker does:
| Exception | When to raise | Core behavior |
|---|---|---|
LockDisconnected |
Lock or integration unreachable (asleep, not loaded, network glitch) | No answer: counts toward the lock's backoff; the slot is read back and charged a try only once the lock answers |
LockOperationFailed |
Lock reachable but the operation didn't take | Same as LockDisconnected
|
LockOperationBlocked |
Failed for a cause the user should be told (subclass of LockOperationFailed) |
Same, and the message becomes the repair's reason if the slot is held |
LockOperationUnsupported |
The lock can never accept the request as configured (subclass of LockOperationFailed) |
Slot held at once, slot_suspended repair |
CodeRejectedError |
Lock will not accept this PIN (any reason) | On a set: slot held, user turned off, slot_disabled repair. On a clear: slot held, slot_clear_failed repair |
DuplicateCodeError |
PIN duplicates another slot's value (subclass of CodeRejectedError) |
Same as CodeRejectedError
|
ProviderNotImplementedError |
A primitive is missing (raised by BaseLock defaults) |
Programmer error; do not raise from real provider code |
A read that raises any of the mapped errors is no answer. Anything else a provider raises — bare Exception, HomeAssistantError, a KeyError — is treated as a provider bug: logged with a traceback and charged to the slot as a failed try every time. An OSError escaping a provider counts as no answer.
async_call_service() does this mapping for Home Assistant action calls: OSError becomes LockDisconnected; HomeAssistantError becomes LockOperationFailed, or CodeRejectedError when you pass code_slot on a write and the integration raised its own ServiceValidationError.
from ..model.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 WriteResult.CONFIRMEDWhen a slot is held, the repair also includes describe_link_health() if your provider can measure its transport (for example, how many replies it has lost). Report counters, not conclusions; return None if you can't measure.
The worker runs one exchange at a time per lock, waits _min_operation_delay (2 seconds by default) between exchanges, and bounds each read by _operation_budget(n) for a walk of n slots. Providers do not add their own rate limiting or serialization for single calls.
For a provider's own read-modify-write sequences that span several of its calls (Akuvox's and Schlage's list-then-modify), async with self._serialize_sequence(): makes the sequence atomic against the provider's other entry points, such as a setup-time tagging pass.
The base class handles connection state transitions, driven by async_internal_is_reachable() (called every connection_check_interval, and before exchanges when check_available_before_exchange is set):
-
Reconnection (reachable goes
False→True, push providers): a read of the tracked slots is requested and push subscriptions are re-established. A worker that is backing off probes at once instead of waiting. -
Disconnection (
True→False, push providers): push subscriptions are cleaned up. -
Config entry state changes: for integrations with a config entry, the base listens for state changes and re-runs
async_setup(with capability validation) when the integration reloads. - Deferred setup: if the integration was not connected when LCM set the provider up, setup is skipped and retried on the first reachable observation. Nothing is written to a lock whose setup has not succeeded.
For integrations whose config-entry state drives these transitions, set connection_check_interval = None.
async_setup() must be idempotent: it runs on initial load and again on every integration reload.
When a user is removed from LCM config, or a lock is removed from an entry, the core releases each of the user's slots on each lock: the worker's clear calls async_release_managed_slot(slot, written=...) instead of async_clear_usercode(slot).
async def async_release_managed_slot(
self, slot: int, *, written: bool = False
) -> None:-
writtenisTruewhen the core store shows LCM wrote a PIN to this slot on this lock. Use it to decide how loudly to say you found nothing of LCM's to remove: a warning whenwritten, debug otherwise. - The base implementation clears the slot (
async_clear_usercode), which is right for slot-only providers: leaving the code would let a deleted user keep opening the door, and the slot number could never be reissued. - Native-user providers override it to remove only what LCM wrote: find the user by its LCM tag only (never by legacy adoption), remove LCM's PIN, delete the user only if that PIN is all it holds, and otherwise strip LCM's tag so a later use of the slot number won't find it. Deleting a user cascades to every credential on it, including any added in the vendor's app, which is why a user holding anything else is kept. Never touch an untagged user. On a lock with no user records, call
super(). - Decide "is LCM's PIN all it holds" from the integration's raw credential list, not
async_get_users, which drops credential types LCM has no name for. - A kept user is renamed so it carries no tag. The Z-Wave provider keeps the display part of the tagged name (
Aliceoflcm:2:Alice), or no name when that is empty or would itself read as a tag. A failed rename is logged, not raised: LCM's PIN is already gone, so the release is done. -
Let errors propagate. The release is a retried worker clear: a
LockDisconnectedorLockOperationFailedyou swallow reads as a release that worked, and the code stays on the lock. Raised, it is retried, held after 2 tries with aslot_clear_failedrepair, and resumed after a restart.
@override
async def async_release_managed_slot(
self, slot: int, *, written: bool = False
) -> None:
"""Remove LCM's PIN for slot; delete its user only if that is all it holds."""
if not await self._supports_user_records():
await super().async_release_managed_slot(slot, written=written)
return
user_id = await self._find_user_index_for_slot(slot, adopt_untagged=False)
if user_id is None:
if written:
_LOGGER.warning(
"Lock %s: released slot %s, but no user carries LCM's tag for it",
self.lock.entity_id,
slot,
)
return
held = await self._driver.list_credentials(user_id=user_id) # raw list
lcm_pin = any(c.type == "pin" and c.slot == slot for c in held)
if len(held) == int(lcm_pin):
await self.async_delete_user(user_id) # the cascade takes the PIN
return
if lcm_pin:
await self._delete_credential(
CredentialRef(user_id=user_id, type=CredentialType.PIN, slot=slot)
)
await self._untag(user_id) # rename so it carries no tag; log a failureSee ZWaveJSLock.async_release_managed_slot and _async_untag in providers/zwave_js.py for the real implementation.
| Helper | What it does |
|---|---|
is_slot_managed(code_slot) |
Whether any LCM config entry manages this slot on this lock |
managed_slots |
The slot numbers any entry's users hold on this lock, disabled users included |
slot_tag(user) |
The slot user carries LCM's tag for, or None (see async_set_user()) |
cached_capabilities |
The capabilities already probed, or None; never asks the lock |
async_call_service(...) |
Calls a Home Assistant action and maps its errors (see Exception Routing) |
async_fire_code_slot_event(code_slot=None, to_locked=None, *, credential_type) |
Records that a credential was used (below) |
_serialize_sequence() |
An async lock for a provider's own multi-call sequences (see Pacing and Timeouts) |
_push_credential_update(slot, credential) |
Pushes one slot's state (see Implementing Push Updates) |
_register_push_unsub(unsub) / _clear_push_unsubs()
|
Track and release push subscriptions |
_require_readable_pin(credential) is called by the base before it reaches async_set_credential, so the pin you receive is already a string; do not add your own check.
For locks that report which code was used, call async_fire_code_slot_event(code_slot, to_locked, credential_type=...):
-
code_slot— the LCM slot whose credential was used. A slot no entry manages fires nothing. -
to_locked— the direction the lock moved:Truelocked,Falseunlocked,Nonewhen you can't tell. -
credential_type— required, keyword only: the kind of credential the lock said was presented (CredentialType.PINfor a code). There is no default; if the lock named no kind, do not call this at all.
It fires the lock_code_manager_credential_used event for the user who owns the slot. A provider whose protocol can report a use as a kind it cannot ask the lock about lists those kinds in reported_credential_types.
-
Never answer a read from a cache. Set
fresh_read_is_hard_refreshif your plain read is cached. -
Prefer push when the integration supports events, and add a drift read (
hard_refresh_interval) if codes can change without an event. -
When in doubt,
unreadable(). Only the lock saying "free" earnsempty(). -
Use the typed exceptions —
CodeRejectedErrorfor a PIN the lock refuses,LockOperationUnsupportedfor a request it can never accept,LockDisconnected/LockOperationFailedfor everything that may pass. - Let errors propagate from releases and writes; the core decides what they cost.
-
Make subscriptions and
async_setupidempotent — both may be called many times. -
Run your own follow-up exchanges in turn with
self.coordinator.async_in_turn(...). -
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
- Upgrading to 7.0
- Upgrading to 6.0
- Upgrading to 5.0
- Configuration Structure
- Adding and Removing Locks
- Migrating from keymaster
UI
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