-
-
Notifications
You must be signed in to change notification settings - Fork 16
Adding a Provider
This tutorial walks through adding support for a new lock integration to Lock Code Manager. We'll build a provider for a hypothetical "SmartLock" integration.
For background on the contract, see Architecture and Provider-State-Management.
Before starting:
- The lock integration exists in Home Assistant and creates
lock.*entities. - The integration provides a way to read, set, and clear PIN credentials.
- You understand the integration's user/credential model: does it expose users with credentials (Matter, Z-Wave User Credential CC), or just slot-indexed credentials (Z-Wave User Code CC, ZHA, Akuvox, Schlage)?
LCM providers split into two flavors based on the lock's user model. Choose the one that matches your lock:
-
Slot-only — the lock indexes credentials by slot directly with no user concept (Z-Wave UC, ZHA, Zigbee2MQTT, Virtual), OR the lock's "user" is just the credential's friendly name (Akuvox, Schlage). The provider implements
async_set_credential,async_delete_credential,async_get_users. The base seam writes credentials directly; theuser_idparameter inasync_set_credentialis ignored. -
Native-user — the lock has a real user record that owns one or more credentials (Matter, Z-Wave User Credential CC). The provider additionally implements
async_set_user,async_delete_user,async_get_capabilities. The base seam runs a user-first lifecycle: create-or-find the user (vialcm:<slot>:<name>tag), then write the credential under it.
This tutorial covers the slot-only path first, then adds the native-user extensions.
Create a new file in custom_components/lock_code_manager/providers/:
# custom_components/lock_code_manager/providers/smartlock.py
"""Module for SmartLock integration."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
import logging
from typing import Literal
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from homeassistant.core import callback
from ..domain.credentials import (
Credential,
CredentialRef,
CredentialType,
User,
user_from_slot,
)
from ..domain.exceptions import (
CodeRejectedError,
DuplicateCodeError,
LockDisconnected,
LockOperationFailed,
)
from ..domain.models import SlotCredential
from ._base import BaseLock
_LOGGER = logging.getLogger(__name__)
@dataclass(repr=False, eq=False)
class SmartLockLock(BaseLock):
"""Class to represent SmartLock lock."""
@property
def domain(self) -> str:
"""Return integration domain."""
return "smartlock"@property
def usercode_scan_interval(self) -> timedelta:
"""Return scan interval for usercodes (polling mode only)."""
return timedelta(minutes=2)
@property
def hard_refresh_interval(self) -> timedelta | None:
"""Return drift-detection interval (None to disable)."""
return timedelta(hours=1)
@property
def connection_check_interval(self) -> timedelta | None:
"""Return interval for connection state checks."""
return timedelta(seconds=30)For push-capable integrations, set connection_check_interval = None and rely on config-entry state changes instead.
@property
def supports_push(self) -> bool:
"""Return True if the integration emits real-time events."""
return False # change to True if you'll implement push
@property
def supports_native_users(self) -> bool:
"""Return True for locks with a real user record (Matter, Z-Wave U3C)."""
return False # slot-only path; see "Native-User Extension" belowdef is_integration_connected(self) -> bool:
"""Return whether the integration is reachable."""
# Option A: check config entry state
if self.lock_config_entry:
return self.lock_config_entry.state == ConfigEntryState.LOADED
# Option B: check the entity state
state = self.hass.states.get(self.lock.entity_id)
return state is not None and state.state not in ("unavailable", "unknown")Return the lock's current users and credentials as list[User]. Slot-only providers synthesize a single-credential user per occupied slot via user_from_slot.
async def async_get_users(self) -> list[User]:
"""Read credential state from the device.
Slot-only path: synthesize one user per occupied slot. The base
class projects these to dict[int, SlotCredential] via the
default async_get_usercodes() projection.
"""
try:
codes = await self._fetch_codes() # {slot: pin_or_None}
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 codes.items()
]For write-only locks (the lock confirms occupancy but won't reveal the PIN), use SlotCredential.unreadable() instead of SlotCredential.known(...).
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.
Slot-only: user_id is ignored; address by credential.slot.
"""
try:
await self._device.set_code(credential.slot, pin, name=name)
except DuplicatePinError as err:
raise DuplicateCodeError(
code_slot=credential.slot,
lock_entity_id=self.lock.entity_id,
) from err
except TransientError as err:
# Transient: route to retry, not slot suspension.
raise LockDisconnected(
f"set slot {credential.slot} failed: {err}"
) 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 TrueReturn True if the value changed, False if it was already set. When the integration doesn't tell you (write-only locks), 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._device.clear_code(ref.slot)
except TransientError as err:
raise LockDisconnected(
f"clear slot {ref.slot} failed: {err}"
) from err
except LockApiError as err:
raise LockOperationFailed(
f"clear slot {ref.slot} failed: {err}"
) from err
return TrueThe seam does not delete the owning user after async_delete_credential for native-user providers — the user is a persistent slot anchor; see Architecture for the lifecycle rationale.
If your integration caches data, bypass that cache to detect drift.
async def async_hard_refresh_codes(self) -> dict[int, SlotCredential]:
"""Force a fresh read and return the projected slot map."""
try:
await self._device.invalidate_cache()
except SomeDeviceError as err:
raise LockDisconnected(f"refresh failed: {err}") from err
return await self.async_get_usercodes()If the integration emits real-time events:
@property
def supports_push(self) -> bool:
return True
@dataclass(repr=False, eq=False)
class SmartLockLock(BaseLock):
# ...existing fields...
_event_unsub: Callable[[], None] | None = field(init=False, default=None)
@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 locks that report which code was used to unlock, also fire LCM events for automations:
@callback
def _handle_lock_event(self, event_data) -> None:
"""Fire an LCM code-slot event for automations."""
self.async_fire_code_slot_event(
code_slot=event_data["slot"],
to_locked=event_data.get("locked"),
action_text=event_data.get("action"),
source_data=event_data,
)Add your provider to the registry in providers/__init__.py:
"""Integrations module."""
from __future__ import annotations
from ._base import BaseLock
from .smartlock import SmartLockLock # add the import
from .virtual import VirtualLock
from .zwave_js import ZWaveJSLock
INTEGRATIONS_CLASS_MAP: dict[str, type[BaseLock]] = {
"smartlock": SmartLockLock, # add the mapping
"virtual": VirtualLock,
"zwave_js": ZWaveJSLock,
}If your lock has a real user record that owns credentials, set supports_native_users = True and add three more primitives.
The seam reads this to decide whether to invoke the user lifecycle and to size the tagged user name.
from ..domain.credentials import (
CredentialTypeCapability,
LockCapabilities,
)
async def async_get_capabilities(self) -> LockCapabilities:
"""Report the lock's user/credential capabilities."""
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 base seam passes a tagged user.name (lcm:<slot>:<display>). Discover or allocate the underlying lock user_id:
from ..domain.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 encoded 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 -> driver auto-allocates
user_name=user.name,
active=user.active,
)
except SomeDeviceError 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.
Two-pass:
1. Canonical: a user whose name parses to this LCM slot.
2. Legacy adoption: an untagged user at the pre-PR-C invariant
(the position your lock used pre-4.0.0) that owns a PIN at
the slot. The legacy pass MUST skip users already tagged for
a different LCM 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:
# Replace the legacy-position predicate with whatever your
# pre-4.0.0 invariant was (e.g. user_id == slot for U3C,
# credential_index == slot for Matter).
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,
)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 SomeDeviceError as err:
raise LockDisconnected(f"delete user {user_id} failed: {err}") from errWhen a slot is removed from LCM config, the base seam calls async_release_managed_slot(slot). Native-user providers override to release the persistent user anchor:
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 None:
return
try:
await self.async_delete_user(user_id)
except (LockDisconnected, LockOperationFailed) as err:
_LOGGER.debug(
"Lock %s: could not release slot %s anchor: %s",
self.lock.entity_id,
slot,
err,
)Wrap in try/except so slot teardown doesn't block on lock connectivity.
"""Module for SmartLock integration."""
from __future__ import annotations
from dataclasses import dataclass
from datetime import timedelta
from typing import Literal
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
from ..domain.credentials import (
Credential,
CredentialRef,
User,
user_from_slot,
)
from ..domain.exceptions import (
CodeRejectedError,
DuplicateCodeError,
LockDisconnected,
)
from ..domain.models import SlotCredential
from ._base import BaseLock
@dataclass(repr=False, eq=False)
class SmartLockLock(BaseLock):
"""Class to represent SmartLock lock."""
@property
def domain(self) -> str:
return "smartlock"
@property
def usercode_scan_interval(self) -> timedelta:
return timedelta(minutes=2)
def is_integration_connected(self) -> bool:
if self.lock_config_entry:
return self.lock_config_entry.state == ConfigEntryState.LOADED
return True
async def async_get_users(self) -> list[User]:
try:
codes = await self._fetch_codes_from_device()
except Exception as err:
raise LockDisconnected(str(err)) from err
return [
user_from_slot(
slot,
SlotCredential.known(pin) if pin else SlotCredential.empty(),
)
for slot, pin in codes.items()
]
async def async_set_credential(
self,
user_id: int,
credential: Credential,
pin: str,
*,
name: str | None,
source: Literal["sync", "direct"],
) -> bool:
try:
await self._set_code_on_device(credential.slot, pin)
except DuplicatePinError as err:
raise DuplicateCodeError(
code_slot=credential.slot,
lock_entity_id=self.lock.entity_id,
) from err
except Exception as err:
raise LockDisconnected(str(err)) from err
return True
async def async_delete_credential(self, ref: CredentialRef) -> bool:
try:
await self._clear_code_on_device(ref.slot)
except Exception as err:
raise LockDisconnected(str(err)) from err
return True
# ─── Integration-specific helpers ──────────────────────────────
async def _fetch_codes_from_device(self) -> dict[int, str]:
"""Replace with your integration's API."""
raise NotImplementedError
async def _set_code_on_device(self, slot: int, pin: str) -> None:
raise NotImplementedError
async def _clear_code_on_device(self, slot: int) -> None:
raise NotImplementedErrorCreate tests/providers/smartlock/test_provider.py. Mirror the structure of an existing provider's tests (tests/providers/zwave_js/test_provider.py for native-user, tests/providers/akuvox/test_provider.py for slot-only).
Key fixtures to exercise:
-
async_get_users()returns the expectedUserlist given seeded device state -
async_set_credential()calls the integration's API with the right slot + pin and returnsTrue -
async_delete_credential()calls the integration's API and returnsTrue - Exception mapping: device errors become
LockDisconnected, duplicate-pin errors becomeDuplicateCodeError, rejections becomeCodeRejectedError - For native-user:
async_set_user()does find-or-create-by-tag; legacy adoption picks up the pre-upgrade user; legacy pass skips users tagged for other slots
- Restart Home Assistant after registering the provider.
- Configure LCM with a lock using your integration.
- Verify:
- Codes can be set and cleared from LCM
- The code sensor shows correct values (or "unreadable" for write-only locks)
- Sync status updates correctly
- For native-user providers: lock-side users carry
lcm:<slot>:<name>tags - For push providers: changes made at the keypad show up in LCM in real time
- Hard refresh detects out-of-band changes
"Entity not found" errors: ensure the lock entity exists and is in the entity registry. Check that domain returns the correct integration domain.
Codes not syncing: verify async_get_users() returns valid User objects with pin_credentials populated. Check that async_set_credential() actually writes to the device. Enable debug logging.
Push updates not arriving: ensure supports_push returns True. Verify subscribe_push_updates() is called (the base seam calls it in async_setup). Check that coordinator.push_update({slot: SlotCredential}) is called with the right slot.
For native-user providers, the wrong slot is reported on push: under the user-tag identity model, the lock's user_id and credential_index may differ from the LCM slot. The push handler must resolve the LCM slot via parse_tag on the owning user's name, not from lock-side indices. See the Matter provider's _dispatch_lock_user_change for the canonical pattern.
# configuration.yaml
logger:
default: info
logs:
custom_components.lock_code_manager.providers.smartlock: debug- Review existing providers as references:
-
Slot-only:
virtual.py,akuvox.py,schlage.py,zha.py,zigbee2mqtt.py -
Native-user:
matter.py,zwave_js.py
-
Slot-only:
- Read Provider-State-Management for the coordinator + exception contract in depth.
- Read Architecture for the cross-cutting design (user-tag identity, persistent user anchor lifecycle, masked PINs, duplicate detection).
- Submit a PR to add your provider to the main repository.
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