Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 3 additions & 8 deletions custom_components/lock_code_manager/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -322,7 +322,6 @@ async def async_setup(hass: HomeAssistant, config: Config) -> bool:

await _async_register_strategy_resource(hass)

# Set up websocket API
await async_websocket_setup(hass)
_LOGGER.debug("Finished setting up websocket API")

Expand Down Expand Up @@ -776,12 +775,9 @@ async def async_remove_entry(

Called by Home Assistant only on entry deletion -- not on unload,
reload, disable, or HA restart. The repair issues created by this
integration are flagged ``is_persistent=True`` precisely so they
survive restarts and reloads; deleting them in ``async_unload_entry``
(the previous behavior) wiped them on every restart, causing the
"click an issue and it says repaired" short-circuit. With cleanup
moved here, persistent issues persist until the user actually
removes the entry.
integration are flagged ``is_persistent=True`` so they survive
restarts and reloads; clearing them belongs here, not in
``async_unload_entry``, so they outlive any non-deletion unload.
"""
entry_id = config_entry.entry_id
config = get_entry_config(config_entry)
Expand Down Expand Up @@ -1018,7 +1014,6 @@ async def async_update_listener(
err,
)

# Remove old lock entities
if locks_to_remove:
_LOGGER.debug(
"%s (%s): Removing locks %s", entry_id, entry_title, locks_to_remove
Expand Down
4 changes: 0 additions & 4 deletions custom_components/lock_code_manager/domain/callbacks.py
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,6 @@ class EntityCallbackRegistry:
lock_added: list[LockUpdateCallback] = field(default_factory=list)
lock_removed: list[LockRemoveCallback] = field(default_factory=list)

# --- Registration methods (return unregister functions) ---

def register_standard_adder(
self, callback: StandardEntityCallback
) -> UnregisterFunc:
Expand Down Expand Up @@ -157,8 +155,6 @@ def register_lock_removed_handler(
else None
)

# --- Invocation methods (called by __init__.py orchestrator) ---

@callback
def invoke_standard_adders(self, slot_num: int, ent_reg: er.EntityRegistry) -> None:
"""Invoke all standard entity creation callbacks."""
Expand Down
23 changes: 10 additions & 13 deletions custom_components/lock_code_manager/domain/credentials.py
Original file line number Diff line number Diff line change
Expand Up @@ -191,19 +191,16 @@ class WriteResult(StrEnum):
"""
Outcome of a credential write (``async_set_credential``).

Replaces the old ``bool`` return, distinguishing three cases the seam
needs:

- ``NO_CHANGE`` -- the value was already set; nothing was written (the old
``False``). The coordinator is not refreshed.
- ``CONFIRMED`` -- the lock acknowledged the write (the old ``True``). The
slot is marked verified; non-push providers refresh to read it back.
- ``OPTIMISTIC`` -- the write returned an ambiguous result we are treating
as completed but have NOT confirmed (e.g. a Z-Wave driver
``ERROR_UNKNOWN`` from a masked read-back). The slot is marked unverified
and awaits confirmation via a push event or hard refresh; if none
arrives, it re-syncs rather than silently reporting success. See the
Phase 2 push-as-commit spec.
- ``NO_CHANGE`` -- the value was already set; nothing was written. The
coordinator is not refreshed.
- ``CONFIRMED`` -- the lock acknowledged the write. The slot is marked
verified; non-push providers refresh to read it back.
- ``OPTIMISTIC`` -- the write returned an ambiguous result we treat as
completed but have NOT confirmed (e.g. a Z-Wave driver
``ERROR_UNKNOWN`` from a masked read-back). The slot is marked
unverified and awaits confirmation via a push event or hard refresh;
if none arrives, it re-syncs rather than silently reporting success.
See the Phase 2 push-as-commit spec.
"""

NO_CHANGE = "no_change"
Expand Down
5 changes: 0 additions & 5 deletions custom_components/lock_code_manager/domain/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -151,11 +151,6 @@ class LockCodeManagerConfigEntryRuntimeData:
# unload -- so an in-flight tick cannot keep running against torn-down
# state.
sync_managers: set[SlotSyncManager] = field(default_factory=set)
# Per-slot entity coordinators. Created when a slot is added and torn
# down when it is removed. Owns the derived "active" state and the
# intent-dispatch surface used by text/switch/active entities so they
# do not have to mutate the config entry or call sibling-entity
# services directly.
slot_coordinators: dict[int, SlotEntityCoordinator] = field(default_factory=dict)
# True once the options update listener has been registered for this
# entry. Guards against stacking when _setup_entry_after_start runs more
Expand Down
1 change: 0 additions & 1 deletion custom_components/lock_code_manager/domain/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,6 @@ async def async_set_slot_condition(
if not hass.states.get(entity_id):
raise ServiceValidationError(f"Entity {entity_id} not found")

# Check for excluded platforms
ent_reg = er.async_get(hass)
entity_entry = ent_reg.async_get(entity_id)
if entity_entry and entity_entry.platform in EXCLUDED_CONDITION_PLATFORMS:
Expand Down
14 changes: 7 additions & 7 deletions custom_components/lock_code_manager/domain/slot_coordinator.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,13 @@
"""
Per-slot entity coordinator.

A SlotEntityCoordinator instance owns the per-slot state surface that
text, switch, and active-binary-sensor entities used to compute on their
own. Entities become read-only views over the coordinator: they register
write callbacks for state changes and dispatch user intent (set a PIN,
toggle enabled) through the coordinator. The coordinator updates the
canonical config entry, manages slot-level repair issues, and asks the
per-lock SlotSyncManagers to re-evaluate on the next tick.
A SlotEntityCoordinator instance owns the per-slot state surface for the
text, switch, and active-binary-sensor entities. Entities are read-only
views over the coordinator: they register write callbacks for state
changes and dispatch user intent (set a PIN, toggle enabled) through the
coordinator. The coordinator updates the canonical config entry, manages
slot-level repair issues, and asks the per-lock SlotSyncManagers to
re-evaluate on the next tick.

There is one SlotEntityCoordinator per (config_entry, slot_num); the per-
lock SlotSyncManager remains one per (config_entry, slot_num, lock).
Expand Down
2 changes: 0 additions & 2 deletions custom_components/lock_code_manager/event.py
Original file line number Diff line number Diff line change
Expand Up @@ -131,14 +131,12 @@ def _handle_event(self, event: Event) -> None:
def _handle_add_locks(self, locks: list[BaseLock]) -> None:
"""Handle lock entities being added."""
super()._handle_add_locks(locks)
# Write state to reflect new event_types and unsupported_locks
self.async_write_ha_state()

@callback
def _handle_remove_lock(self, lock_entity_id: str) -> None:
"""Handle lock entity being removed."""
super()._handle_remove_lock(lock_entity_id)
# Write state to reflect new event_types and unsupported_locks
self.async_write_ha_state()

async def async_added_to_hass(self) -> None:
Expand Down
12 changes: 6 additions & 6 deletions custom_components/lock_code_manager/providers/_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -1038,12 +1038,12 @@ async def async_clear_usercode(self, code_slot: int) -> bool:
# Owner resolution is two-pass to match the same identity rule the
# set path uses (see Matter's _find_user_index_for_slot). The
# canonical pass matches by the ``lcm:<slot>:`` tag in user.name;
# the legacy fallback handles pre-PR-B installs where
# ``credential.slot`` was pinned to the LCM slot. Matching by
# ``credential.slot == code_slot`` alone is unsafe once providers
# let the lock auto-allocate the credential index -- a tagged
# user for slot A whose credential lands at index B would be
# mis-matched when clearing slot B.
# the fallback adopts installs from before user-tag matching,
# where ``credential.slot`` was pinned to the LCM slot. Matching
# by ``credential.slot == code_slot`` alone is unsafe once
# providers let the lock auto-allocate the credential index -- a
# tagged user for slot A whose credential lands at index B would
# be mis-matched when clearing slot B.
users = await self.async_get_users()
# Both lookups require the user to actually own a PIN credential
# at the slot we're clearing. Under the persistent-user-anchor
Expand Down
5 changes: 2 additions & 3 deletions custom_components/lock_code_manager/providers/_zwave_js_uc.py
Original file line number Diff line number Diff line change
Expand Up @@ -372,9 +372,8 @@ async def _async_uc_set_usercode(
lock_entity_id=self.lock.entity_id,
reason=f"set value returned {result.status.name}",
)
# Transient non-OK (canonically ``FAIL``): match the 3.x
# behavior of the HA service we used to call -- log and
# let the optimistic push + next sync tick converge.
# Transient non-OK (canonically ``FAIL``) is non-fatal: the
# optimistic push covers UI and the next sync tick reconciles.
_LOGGER.info(
"Lock %s slot %s: set returned %s; "
"trusting optimistic push and continuing",
Expand Down
7 changes: 3 additions & 4 deletions custom_components/lock_code_manager/providers/akuvox.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,10 +74,9 @@ class AkuvoxLock(BaseLock):
PIN value. Cleared slots report ``SlotCredential.empty()``.
"""

# Tracks whether the initial auto-tag pass already ran for this
# provider instance. Skips re-tagging on reconnects so a drifted
# device list does not produce double-tag / rename storms. Reset
# naturally when the provider instance is recreated on full reload.
# Guards the initial auto-tag pass: skips re-tagging on reconnects so a
# drifted device list cannot produce double-tag / rename storms. Reset
# on full reload when the provider instance is recreated.
_tagged_once: bool = field(default=False, init=False)

@property
Expand Down
57 changes: 27 additions & 30 deletions custom_components/lock_code_manager/providers/matter.py
Original file line number Diff line number Diff line change
Expand Up @@ -399,13 +399,12 @@ async def async_set_user(self, user: User) -> SetUserResult:
if existing_user_index is not None:
# UPDATE: rename via set_lock_user.
#
# set_lock_user here is a metadata-only name update. The
# historical Matter contract (PR #1077) tolerated name-set
# failures so a transient 500 or a name the lock rejects
# does not block the subsequent credential write; the user
# still exists at the known index, the only thing lost is
# the name update. If every candidate in the cascade fails
# with MatterError we log a warning and fall through.
# set_lock_user here is a metadata-only name update.
# Name-set failures must not block the subsequent credential
# write -- the user still exists at the known index and only
# the cosmetic name update is lost. The cascade tries each
# candidate name; if every one fails with MatterError we log
# a warning and fall through.
candidates = self._user_name_candidates(slot, user.name)
try:
(
Expand All @@ -419,16 +418,15 @@ async def async_set_user(self, user: User) -> SetUserResult:
candidate_names=candidates,
)
except (LockDisconnected, LockOperationFailed, MatterError) as err:
# UPDATE's historical contract (PR #1077): tolerate any
# rename failure so the subsequent credential write still
# proceeds. The user record is still valid at
# ``existing_user_index`` -- the only thing lost is the
# cosmetic name update. The helper raises typed seam
# exceptions (LockDisconnected for transport failures,
# UPDATE tolerates any rename failure so the subsequent
# credential write still proceeds. The user record is
# still valid at ``existing_user_index`` -- only the
# cosmetic name update is lost. The helper raises typed
# seam exceptions (LockDisconnected for transport,
# LockOperationFailed for validation rejections,
# MatterError when every candidate hit a lock-side
# rejection); we swallow all three here on the UPDATE
# path and log a warning instead.
# rejection); all three are swallowed here on the UPDATE
# path and logged as a warning.
LOGGER.warning(
"Lock %s: failed to update user name on slot %s "
"(user_index=%s); continuing without name update. "
Expand Down Expand Up @@ -693,8 +691,8 @@ async def _send_set_credential(

``credential_index=None`` auto-allocates the next free credential slot
(CREATE). Passing an existing index addresses the user's current PIN
credential for MODIFY. ``code_slot`` is the LCM slot only and is used
for error reporting; it is no longer pinned to the Matter index.
credential for MODIFY. ``code_slot`` is the LCM slot, used only for
error reporting; the Matter credential index is opaque to LCM.

Raises SetCredentialFailedError on lock rejection,
CodeRejectedError on validation failure,
Expand Down Expand Up @@ -733,11 +731,10 @@ async def _find_pin_credential_index_for_user(self, user_id: int) -> int | None:
"""
Return the user's current Matter PIN credential index, or ``None``.

LCM no longer pins ``credential_index`` to the LCM slot; instead it
treats Matter's credential index as opaque and rediscovers it per
operation. This helper deliberately walks the **raw** lock-side
user data (not ``async_get_users``) so the returned value is the
Matter credential index Matter expects for
LCM treats Matter's credential index as opaque and rediscovers it
per operation. This helper deliberately walks the **raw**
lock-side user data (not ``async_get_users``) so the returned
value is the Matter credential index Matter expects for
``set_lock_credential`` / ``clear_lock_credential`` -- not the
LCM-projected slot that ``async_get_users`` exposes upward.
"""
Expand Down Expand Up @@ -1004,8 +1001,8 @@ def _handle_lock_operation(self, node_event: Any) -> None:
Only PIN credentials (credentialType=1) trigger the event -- other
credential types (RFID, fingerprint, etc.) are ignored.

The event's ``credentials[].credentialIndex`` is the Matter credential
index, which is no longer pinned to the LCM slot under the user-tag
The event's ``credentials[].credentialIndex`` is the Matter
credential index, which LCM treats as opaque under the user-tag
model. To find the LCM slot we resolve via the event's top-level
``userIndex`` -> user.name -> ``lcm:<slot>:`` tag, falling back to
walking the user list for a PIN credential at ``credentialIndex``
Expand Down Expand Up @@ -1131,12 +1128,12 @@ def _handle_lock_user_change(self, node_event: Any) -> None:
the owning user's name and parsing its ``lcm:<slot>:`` tag --
``userIndex`` alone is sufficient. ``dataIndex`` (the Matter
credential index) is captured best-effort for log context only;
it's no longer pinned to the LCM slot under the user-tag model
and dropping otherwise-resolvable events when it's missing or
malformed would silently lose state updates. The lookup is async
(a fresh ``_raw_lock_users`` round-trip) so the callback
schedules a task rather than blocking the event loop. Events
for users LCM doesn't own (untagged names) are ignored.
under the user-tag model it is opaque to LCM, and dropping
otherwise-resolvable events when it's missing or malformed would
silently lose state updates. The lookup is async (a fresh
``_raw_lock_users`` round-trip) so the callback schedules a task
rather than blocking the event loop. Events for users LCM
doesn't own (untagged names) are ignored.
"""
data: dict[str, Any] = getattr(node_event, "data", None) or {}

Expand Down
7 changes: 3 additions & 4 deletions custom_components/lock_code_manager/providers/schlage.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,10 +60,9 @@ class SchlageLock(BaseLock):
and ``SlotCredential.empty()`` for cleared slots.
"""

# Tracks whether the initial auto-tag pass already ran for this
# provider instance. Skips re-tagging on reconnects so a drifted
# device list does not produce double-tag / rename storms. Reset
# naturally when the provider instance is recreated on full reload.
# Guards the initial auto-tag pass: skips re-tagging on reconnects so a
# drifted device list cannot produce double-tag / rename storms. Reset
# on full reload when the provider instance is recreated.
_tagged_once: bool = field(default=False, init=False)

@property
Expand Down
9 changes: 0 additions & 9 deletions custom_components/lock_code_manager/providers/zha.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,10 +37,6 @@

_LOGGER = logging.getLogger(__name__)

# ---------------------------------------------------------------------------
# ZCL DoorLock event mappings
# ---------------------------------------------------------------------------

OPERATION_TO_LOCKED: dict[int, bool] = {
DoorLock.OperationEvent.Lock: True,
DoorLock.OperationEvent.KeyLock: True,
Expand All @@ -63,11 +59,6 @@
}


# ---------------------------------------------------------------------------
# Provider
# ---------------------------------------------------------------------------


@dataclass(repr=False, eq=False)
class ZHALock(BaseLock):
"""
Expand Down
8 changes: 3 additions & 5 deletions custom_components/lock_code_manager/providers/zigbee2mqtt.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,6 @@ def _process_z2m_device_payload(self, payload: dict[str, Any]) -> None:
)
return

# Handle users data in state update
users_data = payload.get("users")
if users_data and isinstance(users_data, dict):
updates: dict[int, SlotCredential] = {}
Expand Down Expand Up @@ -274,7 +273,6 @@ def _process_z2m_device_payload(self, payload: dict[str, Any]) -> None:
)
self.coordinator.push_update(updates)

# Handle response to get request with pin_code data
pin_code_data = payload.get("pin_code")
if pin_code_data and isinstance(pin_code_data, dict):
raw_user = pin_code_data.get("user")
Expand Down Expand Up @@ -581,9 +579,9 @@ async def async_get_users(self) -> list[User]:
slot_states: dict[int, SlotCredential] = {}

# Query one slot at a time so Zigbee2MQTT / firmware can answer each GET before
# the next. Parallel gathers plus per-slot timeouts used to raise and fail the
# entire refresh, leaving coordinator.data empty sync then skips every slot
# (see SlotSyncManager._resolve_slot_state).
# the next. Parallel gather + per-slot timeouts can fail the entire refresh and
# leave coordinator.data empty -- sync then skips every slot (see
# SlotSyncManager._resolve_slot_state).
# Transient publish/timeout/read failures use the unreadable credential so sync
# does not treat the slot as confirmed-empty and storm reprogramming after MQTT
# recovery.
Expand Down
13 changes: 5 additions & 8 deletions custom_components/lock_code_manager/providers/zwave_js.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,8 +64,6 @@

_LOGGER = logging.getLogger(__name__)

# String key used by lock_helpers for Personal Identification Number credentials
# in the supported_credential_types dict returned by async_get_credential_capabilities.
_PIN_TYPE_STR = lock_helpers.CREDENTIAL_TYPE_MAP[UserCredentialType.PIN_CODE]

# Z-Wave UserCredentialType -> domain CredentialType. The domain vocabulary
Expand Down Expand Up @@ -318,7 +316,7 @@ async def async_set_user(self, user: User) -> SetUserResult:
The base seam passes a tagged ``user.name`` (``lcm:<slot>:<display>``)
whose slot is the LCM-side identity for this credential. The Z-Wave
lock's own ``user_id`` is whatever Z-Wave happens to allocate; LCM
no longer pins it to the slot. Discovery on every call:
treats it as opaque and rediscovers it via the tag on every call:

1. Scan the lock's current user list for a user whose name carries
the same ``lcm:<slot>:`` tag.
Expand Down Expand Up @@ -553,11 +551,10 @@ def setup_push_subscription(self) -> None:
deleted`` node events. In UC-fallback mode those events never
fire (the driver only emits them from its own unified API
methods, which the fallback bypasses), so we subscribe to raw
``value updated`` events for the User Code CC values instead --
the same push source the legacy 3.x provider used. When the
mode is not yet known (capability probe hasn't run), subscribe
to both; the handlers are self-filtering and pushes are
idempotent.
``value updated`` events for the User Code CC values instead.
When the mode is not yet known (capability probe hasn't run),
subscribe to both; the handlers are self-filtering and pushes
are idempotent.
"""
if self._push_unsubs:
return
Expand Down
Loading
Loading