Skip to content

Provider State Management

raman325 edited this page Oct 27, 2026 · 6 revisions

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.

Overview

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.

Reads

Never a cache

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_users asks 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 = True and make async_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.

The slots argument

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.

Choosing the SlotCredential

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.

Saying the lock said nothing

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

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.

Provider Architecture

┌─────────────────────────────────────────────────────────────────┐
│           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                           │
└─────────────────────────────────────────────────────────────────┘

Slot-Only vs Native-User Providers

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_id in async_set_credential is ignored.
  • Native-user — the lock has a real user record that owns one or more credentials (Matter; Z-Wave JS, whose unified node.access_control API covers both User Code CC and User Credential CC locks). Set supports_native_users = True. LCM follows a user-first lifecycle: async_set_user then async_set_credential. Implement async_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 reporting supports_user_management=False or max_user_name_length=0 skip 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.

What the base does around your primitives

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 to async_set_credential(code_slot, credential, pin, ...), with the slot as user_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, calls async_set_user with it, then calls async_set_credential with the user_id that came back. If that credential write raises and async_set_user had just created the user, it deletes the user again with async_delete_user (a failed rollback is logged; the original error is raised). On a lock without user records (supports_user_management false or max_user_name_length 0) it skips the user write. Returns your WriteResult.
  • _build_tagged_user_name(slot, display) produces lcm:<slot>:<display>, shortening only the display part to fit max_user_name_length. If even lcm:<slot>: does not fit, it uses the bare slot number; if that does not fit either, it returns None and _set_credential refuses the write with LockOperationFailed rather than create a user it could never find again.
  • async_clear_usercode(code_slot) on a slot-only provider calls async_delete_credential with the slot as both user_id and slot. 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 returns False without calling you; otherwise it calls _delete_credential.
  • _delete_credential(ref) (@final) refuses a credential type the lock does not advertise, then calls async_delete_credential(ref). It never deletes the owning user: the user stays as the slot's anchor until async_release_managed_slot.

Update Modes

ProviderPort schedules the provider's reads; each one is a worker job that reads every slot the worker tracks.

Poll

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)

Push

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:

  1. Provider subscribes to integration events in setup_push_subscription()
  2. An event fires
  3. 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 the SlotCredential value
  4. Provider calls self._push_credential_update(slot, credential)
  5. 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 gets

Drift

A 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.

Hard refresh

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.

Interval and Hook Properties

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

Implementing the Provider Primitives

The examples import from the provider's package (custom_components/lock_code_manager/providers/).

async_get_users()

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.

async_set_credential()

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.CONFIRMED

The arguments:

  • user_id — on a native-user lock with user records, the user_id your async_set_user returned. Slot-only providers ignore it and address the credential by credential.slot.
  • credential — the credential to write; credential.slot is 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. None means 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.

async_delete_credential()

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 True

Slot-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.

async_set_user() (Native-User Only)

Find-or-create the lock user for the LCM slot encoded in user.name.

  • user.name is 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. A None name means leave the existing name unchanged, not clear it.
  • Return SetUserResult(user_id, created). user_id is passed to the async_set_credential call that follows, so if the lock allocates the identifier, return the one it allocated. created is True only 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:

  1. By tag. A user whose name carries LCM's tag for this slot. Read the tag with self.slot_tag(user), not parse_tag: a bare-digit name counts as LCM's only on a lock whose name limit cannot fit lcm:<slot>:, which is the only place LCM writes one. Anywhere else a user named 3 is the homeowner's. slot_tag judges from the cached capabilities, so make sure they are loaded first.
  2. 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 == slot on Z-Wave, credential_index == slot on 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 passes adopt_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_delete_user() (Native-User Only)

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 err

Deleting 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.

async_get_capabilities() (Native-User Only)

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.

async_is_integration_connected()

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.LOADED

The 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.

Implementing Push Updates

1. Enable push mode

@property
def supports_push(self) -> bool:
    return True

2. Subscribe to events

Override 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))

3. Tear down on unload

@callback
def teardown_push_subscription(self) -> None:
    """Unsubscribe. Must be idempotent."""
    self._clear_push_unsubs()

Exchanges a push handler starts

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.

Push under the tag identity model

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.

Exception Routing

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.CONFIRMED

When 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.

Pacing and Timeouts

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.

Connection State Management

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):

  1. 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.
  2. Disconnection (True → False, push providers): push subscriptions are cleaned up.
  3. 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.
  4. 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.

Slot Teardown Hook

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:
  • written is True when 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 when written, 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 (Alice of lcm: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 LockDisconnected or LockOperationFailed you swallow reads as a release that worked, and the code stays on the lock. Raised, it is retried, held after 2 tries with a slot_clear_failed repair, 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 failure

See ZWaveJSLock.async_release_managed_slot and _async_untag in providers/zwave_js.py for the real implementation.

Helpers on BaseLock

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.

Recording a code use

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: True locked, False unlocked, None when you can't tell.
  • credential_type — required, keyword only: the kind of credential the lock said was presented (CredentialType.PIN for 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.

Best Practices

  1. Never answer a read from a cache. Set fresh_read_is_hard_refresh if your plain read is cached.
  2. Prefer push when the integration supports events, and add a drift read (hard_refresh_interval) if codes can change without an event.
  3. When in doubt, unreadable(). Only the lock saying "free" earns empty().
  4. Use the typed exceptions — CodeRejectedError for a PIN the lock refuses, LockOperationUnsupported for a request it can never accept, LockDisconnected / LockOperationFailed for everything that may pass.
  5. Let errors propagate from releases and writes; the core decides what they cost.
  6. Make subscriptions and async_setup idempotent — both may be called many times.
  7. Run your own follow-up exchanges in turn with self.coordinator.async_in_turn(...).
  8. 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.

Clone this wiki locally